Skip to content

Commit efc4313

Browse files
committed
chore(refactor): refactor exit handler and tests
* npm mock logger writes to npm.log.record too now * No more extra process.exit from within the process `exit` event handle. * No more `exit()` function. Logic is rolled up into the exit handler. * Now there is only an exit handler and an exit event listener. `lib/utils/perf.js` was rolled up into npm.js itself. Unfortunately the tests were written in such a way that any further refactoring of the exit handler was going to require also rewriting the tests. Fortunately NOW the tests are interacting with the exit handler in a way that shouldn't require them to be rewritten AGAIN if we change the internals of the exit handler. PR-URL: #3482 Credit: @wraithgar Close: #3482 Reviewed-by: @nlf
1 parent 103c8c3 commit efc4313

File tree

9 files changed

+346
-545
lines changed

9 files changed

+346
-545
lines changed

lib/npm.js

+41-19
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
// The order of the code in this file is relevant, because a lot of things
2-
// require('npm.js'), but also we need to use some of those modules. So,
3-
// we define and instantiate the singleton ahead of loading any modules
4-
// required for its methods.
5-
6-
// these are all dependencies used in the ctor
71
const EventEmitter = require('events')
82
const { resolve, dirname } = require('path')
93
const Config = require('@npmcli/config')
4+
const log = require('npmlog')
105

116
// Patch the global fs module here at the app level
127
require('graceful-fs').gracefulify(require('fs'))
@@ -37,23 +32,51 @@ const proxyCmds = new Proxy({}, {
3732
},
3833
})
3934

35+
// Timers in progress
36+
const timers = new Map()
37+
// Finished timers
38+
const timings = {}
39+
40+
const processOnTimeHandler = (name) => {
41+
timers.set(name, Date.now())
42+
}
43+
44+
const processOnTimeEndHandler = (name) => {
45+
if (timers.has(name)) {
46+
const ms = Date.now() - timers.get(name)
47+
log.timing(name, `Completed in ${ms}ms`)
48+
timings[name] = ms
49+
timers.delete(name)
50+
} else
51+
log.silly('timing', "Tried to end timer that doesn't exist:", name)
52+
}
53+
4054
const { definitions, flatten, shorthands } = require('./utils/config/index.js')
4155
const { shellouts } = require('./utils/cmd-list.js')
4256
const usage = require('./utils/npm-usage.js')
4357

58+
const which = require('which')
59+
60+
const deref = require('./utils/deref-command.js')
61+
const setupLog = require('./utils/setup-log.js')
62+
const cleanUpLogFiles = require('./utils/cleanup-log-files.js')
63+
const getProjectScope = require('./utils/get-project-scope.js')
64+
4465
let warnedNonDashArg = false
4566
const _runCmd = Symbol('_runCmd')
4667
const _load = Symbol('_load')
4768
const _tmpFolder = Symbol('_tmpFolder')
4869
const _title = Symbol('_title')
70+
4971
const npm = module.exports = new class extends EventEmitter {
5072
constructor () {
5173
super()
52-
// TODO make this only ever load once (or unload) in tests
53-
this.timers = require('./utils/perf.js').timers
5474
this.started = Date.now()
5575
this.command = null
5676
this.commands = proxyCmds
77+
this.timings = timings
78+
this.timers = timers
79+
this.perfStart()
5780
procLogListener()
5881
process.emit('time', 'npm')
5982
this.version = require('../package.json').version
@@ -67,6 +90,16 @@ const npm = module.exports = new class extends EventEmitter {
6790
this.updateNotification = null
6891
}
6992

93+
perfStart () {
94+
process.on('time', processOnTimeHandler)
95+
process.on('timeEnd', processOnTimeEndHandler)
96+
}
97+
98+
perfStop () {
99+
process.off('time', processOnTimeHandler)
100+
process.off('timeEnd', processOnTimeEndHandler)
101+
}
102+
70103
get shelloutCommands () {
71104
return shellouts
72105
}
@@ -317,16 +350,5 @@ const npm = module.exports = new class extends EventEmitter {
317350
}
318351
}()
319352

320-
// now load everything required by the class methods
321-
322-
const log = require('npmlog')
323-
324-
const which = require('which')
325-
326-
const deref = require('./utils/deref-command.js')
327-
const setupLog = require('./utils/setup-log.js')
328-
const cleanUpLogFiles = require('./utils/cleanup-log-files.js')
329-
const getProjectScope = require('./utils/get-project-scope.js')
330-
331353
if (require.main === module)
332354
require('./cli.js')(process)

lib/utils/exit-handler.js

+95-112
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
const log = require('npmlog')
21
const os = require('os')
32
const path = require('path')
43
const writeFileAtomic = require('write-file-atomic')
@@ -13,8 +12,6 @@ let logFileName
1312
let npm // set by the cli
1413
let wroteLogFile = false
1514

16-
const timings = {}
17-
1815
const getLogFile = () => {
1916
// we call this multiple times, so we need to treat it as a singleton because
2017
// the date is part of the name
@@ -24,20 +21,13 @@ const getLogFile = () => {
2421
return logFileName
2522
}
2623

27-
process.on('timing', (name, value) => {
28-
if (timings[name])
29-
timings[name] += value
30-
else
31-
timings[name] = value
32-
})
33-
3424
process.on('exit', code => {
3525
// process.emit is synchronous, so the timeEnd handler will run before the
3626
// unfinished timer check below
3727
process.emit('timeEnd', 'npm')
38-
log.disableProgress()
28+
npm.log.disableProgress()
3929
for (const [name, timers] of npm.timers)
40-
log.verbose('unfinished npm timer', name, timers)
30+
npm.log.verbose('unfinished npm timer', name, timers)
4131

4232
if (npm.config.loaded && npm.config.get('timing')) {
4333
try {
@@ -49,7 +39,7 @@ process.on('exit', code => {
4939
command: process.argv.slice(2),
5040
logfile: getLogFile(),
5141
version: npm.version,
52-
...timings,
42+
...npm.timings,
5343
}) + '\n')
5444

5545
const st = fs.lstatSync(path.dirname(npm.config.get('cache')))
@@ -61,14 +51,14 @@ process.on('exit', code => {
6151
}
6252

6353
if (!code)
64-
log.info('ok')
54+
npm.log.info('ok')
6555
else {
66-
log.verbose('code', code)
56+
npm.log.verbose('code', code)
6757
if (!exitHandlerCalled) {
68-
log.error('', 'Exit handler never called!')
58+
npm.log.error('', 'Exit handler never called!')
6959
console.error('')
70-
log.error('', 'This is an error with npm itself. Please report this error at:')
71-
log.error('', ' <https://github.com/npm/cli/issues>')
60+
npm.log.error('', 'This is an error with npm itself. Please report this error at:')
61+
npm.log.error('', ' <https://github.com/npm/cli/issues>')
7262
// TODO this doesn't have an npm.config.loaded guard
7363
writeLogFile()
7464
}
@@ -78,10 +68,10 @@ process.on('exit', code => {
7868
writeLogFile()
7969
if (wroteLogFile) {
8070
// just a line break
81-
if (log.levels[log.level] <= log.levels.error)
71+
if (npm.log.levels[npm.log.level] <= npm.log.levels.error)
8272
console.error('')
8373

84-
log.error(
74+
npm.log.error(
8575
'',
8676
[
8777
'A complete log of this run can be found in:',
@@ -93,121 +83,114 @@ process.on('exit', code => {
9383
// these are needed for the tests to have a clean slate in each test case
9484
exitHandlerCalled = false
9585
wroteLogFile = false
96-
97-
// actually exit.
98-
process.exit(code)
9986
})
10087

101-
const exit = (code, noLog) => {
102-
log.verbose('exit', code || 0)
103-
if (log.level === 'silent')
104-
noLog = true
105-
106-
// noLog is true if there was an error, including if config wasn't loaded, so
107-
// this doesn't need a config.loaded guard
108-
if (code && !noLog)
109-
writeLogFile()
110-
111-
// Exit directly -- nothing in the CLI should still be running in the
112-
// background at this point, and this makes sure anything left dangling
113-
// for whatever reason gets thrown away, instead of leaving the CLI open
114-
process.stdout.write('', () => {
115-
process.exit(code)
116-
})
117-
}
118-
11988
const exitHandler = (err) => {
120-
log.disableProgress()
89+
npm.log.disableProgress()
12190
if (!npm.config.loaded) {
122-
// logging won't work unless we pretend that it's ready
12391
err = err || new Error('Exit prior to config file resolving.')
12492
console.error(err.stack || err.message)
12593
}
12694

127-
if (exitHandlerCalled)
128-
err = err || new Error('Exit handler called more than once.')
129-
130-
// only show the notification if it finished before the other stuff we
131-
// were doing. no need to hang on `npm -v` or something.
95+
// only show the notification if it finished.
13296
if (typeof npm.updateNotification === 'string') {
133-
const { level } = log
134-
log.level = log.levels.notice
135-
log.notice('', npm.updateNotification)
136-
log.level = level
97+
const { level } = npm.log
98+
npm.log.level = 'notice'
99+
npm.log.notice('', npm.updateNotification)
100+
npm.log.level = level
137101
}
138102

139103
exitHandlerCalled = true
140-
if (!err)
141-
return exit()
142-
143-
// if we got a command that just shells out to something else, then it
144-
// will presumably print its own errors and exit with a proper status
145-
// code if there's a problem. If we got an error with a code=0, then...
146-
// something else went wrong along the way, so maybe an npm problem?
147-
const isShellout = npm.shelloutCommands.includes(npm.command)
148-
const quietShellout = isShellout && typeof err.code === 'number' && err.code
149-
if (quietShellout)
150-
return exit(err.code, true)
151-
else if (typeof err === 'string') {
152-
log.error('', err)
153-
return exit(1, true)
154-
} else if (!(err instanceof Error)) {
155-
log.error('weird error', err)
156-
return exit(1, true)
157-
}
158104

159-
if (!err.code) {
160-
const matchErrorCode = err.message.match(/^(?:Error: )?(E[A-Z]+)/)
161-
err.code = matchErrorCode && matchErrorCode[1]
162-
}
163-
164-
for (const k of ['type', 'stack', 'statusCode', 'pkgid']) {
165-
const v = err[k]
166-
if (v)
167-
log.verbose(k, replaceInfo(v))
105+
let exitCode
106+
let noLog
107+
108+
if (err) {
109+
exitCode = 1
110+
// if we got a command that just shells out to something else, then it
111+
// will presumably print its own errors and exit with a proper status
112+
// code if there's a problem. If we got an error with a code=0, then...
113+
// something else went wrong along the way, so maybe an npm problem?
114+
const isShellout = npm.shelloutCommands.includes(npm.command)
115+
const quietShellout = isShellout && typeof err.code === 'number' && err.code
116+
if (quietShellout) {
117+
exitCode = err.code
118+
noLog = true
119+
} else if (typeof err === 'string') {
120+
noLog = true
121+
npm.log.error('', err)
122+
} else if (!(err instanceof Error)) {
123+
noLog = true
124+
npm.log.error('weird error', err)
125+
} else {
126+
if (!err.code) {
127+
const matchErrorCode = err.message.match(/^(?:Error: )?(E[A-Z]+)/)
128+
err.code = matchErrorCode && matchErrorCode[1]
129+
}
130+
131+
for (const k of ['type', 'stack', 'statusCode', 'pkgid']) {
132+
const v = err[k]
133+
if (v)
134+
npm.log.verbose(k, replaceInfo(v))
135+
}
136+
137+
npm.log.verbose('cwd', process.cwd())
138+
139+
const args = replaceInfo(process.argv)
140+
npm.log.verbose('', os.type() + ' ' + os.release())
141+
npm.log.verbose('argv', args.map(JSON.stringify).join(' '))
142+
npm.log.verbose('node', process.version)
143+
npm.log.verbose('npm ', 'v' + npm.version)
144+
145+
for (const k of ['code', 'syscall', 'file', 'path', 'dest', 'errno']) {
146+
const v = err[k]
147+
if (v)
148+
npm.log.error(k, v)
149+
}
150+
151+
const msg = errorMessage(err, npm)
152+
for (const errline of [...msg.summary, ...msg.detail])
153+
npm.log.error(...errline)
154+
155+
if (npm.config.loaded && npm.config.get('json')) {
156+
const error = {
157+
error: {
158+
code: err.code,
159+
summary: messageText(msg.summary),
160+
detail: messageText(msg.detail),
161+
},
162+
}
163+
console.error(JSON.stringify(error, null, 2))
164+
}
165+
166+
if (typeof err.errno === 'number')
167+
exitCode = err.errno
168+
else if (typeof err.code === 'number')
169+
exitCode = err.code
170+
}
168171
}
172+
npm.log.verbose('exit', exitCode || 0)
169173

170-
log.verbose('cwd', process.cwd())
171-
172-
const args = replaceInfo(process.argv)
173-
log.verbose('', os.type() + ' ' + os.release())
174-
log.verbose('argv', args.map(JSON.stringify).join(' '))
175-
log.verbose('node', process.version)
176-
log.verbose('npm ', 'v' + npm.version)
177-
178-
for (const k of ['code', 'syscall', 'file', 'path', 'dest', 'errno']) {
179-
const v = err[k]
180-
if (v)
181-
log.error(k, v)
182-
}
174+
if (npm.log.level === 'silent')
175+
noLog = true
183176

184-
const msg = errorMessage(err, npm)
185-
for (const errline of [...msg.summary, ...msg.detail])
186-
log.error(...errline)
187-
188-
if (npm.config.loaded && npm.config.get('json')) {
189-
const error = {
190-
error: {
191-
code: err.code,
192-
summary: messageText(msg.summary),
193-
detail: messageText(msg.detail),
194-
},
195-
}
196-
console.error(JSON.stringify(error, null, 2))
197-
}
177+
// noLog is true if there was an error, including if config wasn't loaded, so
178+
// this doesn't need a config.loaded guard
179+
if (exitCode && !noLog)
180+
writeLogFile()
198181

199-
exit(typeof err.errno === 'number' ? err.errno : typeof err.code === 'number' ? err.code : 1)
182+
// explicitly call process.exit now so we don't hang on things like the
183+
// update notifier, also flush stdout beforehand because process.exit doesn't
184+
// wait for that to happen.
185+
process.stdout.write('', () => process.exit(exitCode))
200186
}
201187

202188
const messageText = msg => msg.map(line => line.slice(1).join(' ')).join('\n')
203189

204190
const writeLogFile = () => {
205-
if (wroteLogFile)
206-
return
207-
208191
try {
209192
let logOutput = ''
210-
log.record.forEach(m => {
193+
npm.log.record.forEach(m => {
211194
const p = [m.id, m.level]
212195
if (m.prefix)
213196
p.push(m.prefix)
@@ -230,7 +213,7 @@ const writeLogFile = () => {
230213
fs.chownSync(file, st.uid, st.gid)
231214

232215
// truncate once it's been written.
233-
log.record.length = 0
216+
npm.log.record.length = 0
234217
wroteLogFile = true
235218
} catch (ex) {
236219

0 commit comments

Comments
 (0)