|
1 | 1 | const { log, output, META } = require('proc-log')
|
2 |
| -const errorMessage = require('../utils/error-message.js') |
3 |
| -const { redactLog: replaceInfo } = require('@npmcli/redact') |
| 2 | +const { errorMessage, getExitCodeFromError } = require('../utils/error-message.js') |
4 | 3 |
|
5 |
| -let npm = null // set by the cli |
6 |
| -let exitHandlerCalled = false |
7 |
| -let showLogFileError = false |
| 4 | +class ExitHandler { |
| 5 | + #npm = null |
| 6 | + #process = null |
| 7 | + #exited = false |
| 8 | + #exitErrorMessage = false |
8 | 9 |
|
9 |
| -process.on('exit', code => { |
10 |
| - const hasLoadedNpm = npm?.config.loaded |
| 10 | + #noNpmError = false |
11 | 11 |
|
12 |
| - if (!code) { |
13 |
| - log.info('ok') |
14 |
| - } else { |
15 |
| - log.verbose('code', code) |
| 12 | + get #hasNpm () { |
| 13 | + return !!this.#npm |
16 | 14 | }
|
17 | 15 |
|
18 |
| - if (!exitHandlerCalled) { |
19 |
| - process.exitCode = code || 1 |
20 |
| - log.error('', 'Exit handler never called!') |
21 |
| - log.error('', 'This is an error with npm itself. Please report this error at:') |
22 |
| - log.error('', ' <https://github.com/npm/cli/issues>') |
23 |
| - // eslint-disable-next-line no-console |
24 |
| - console.error('') |
25 |
| - showLogFileError = true |
| 16 | + get #loaded () { |
| 17 | + return !!this.#npm?.loaded |
26 | 18 | }
|
27 | 19 |
|
28 |
| - // npm must be loaded to know where the log file was written |
29 |
| - if (hasLoadedNpm) { |
30 |
| - npm.finish({ showLogFileError }) |
31 |
| - // This removes any listeners npm setup, mostly for tests to avoid max listener warnings |
32 |
| - npm.unload() |
| 20 | + get #showExitErrorMessage () { |
| 21 | + if (!this.#loaded) { |
| 22 | + return false |
| 23 | + } |
| 24 | + if (!this.#exited) { |
| 25 | + return true |
| 26 | + } |
| 27 | + return this.#exitErrorMessage |
33 | 28 | }
|
34 | 29 |
|
35 |
| - // these are needed for the tests to have a clean slate in each test case |
36 |
| - exitHandlerCalled = false |
37 |
| - showLogFileError = false |
38 |
| -}) |
| 30 | + get #notLoadedOrExited () { |
| 31 | + return !this.#loaded && !this.#exited |
| 32 | + } |
39 | 33 |
|
40 |
| -const exitHandler = err => { |
41 |
| - exitHandlerCalled = true |
| 34 | + setNpm (npm) { |
| 35 | + this.#npm = npm |
| 36 | + } |
42 | 37 |
|
43 |
| - const hasLoadedNpm = npm?.config.loaded |
| 38 | + constructor ({ process }) { |
| 39 | + this.#process = process |
| 40 | + this.#process.on('exit', this.#handleProcesExitAndReset) |
| 41 | + } |
44 | 42 |
|
45 |
| - if (!npm) { |
46 |
| - err = err || new Error('Exit prior to setting npm in exit handler') |
47 |
| - // Don't use proc-log here since npm was never set |
48 |
| - // eslint-disable-next-line no-console |
49 |
| - console.error(err.stack || err.message) |
50 |
| - return process.exit(1) |
| 43 | + registerUncaughtHandlers () { |
| 44 | + this.#process.on('uncaughtException', this.#handleExit) |
| 45 | + this.#process.on('unhandledRejection', this.#handleExit) |
51 | 46 | }
|
52 | 47 |
|
53 |
| - if (!hasLoadedNpm) { |
54 |
| - err = err || new Error('Exit prior to config file resolving.') |
55 |
| - // Don't use proc-log here since npm was never loaded |
56 |
| - // eslint-disable-next-line no-console |
57 |
| - console.error(err.stack || err.message) |
| 48 | + exit (err) { |
| 49 | + this.#handleExit(err) |
58 | 50 | }
|
59 | 51 |
|
60 |
| - // only show the notification if it finished. |
61 |
| - if (typeof npm.updateNotification === 'string') { |
62 |
| - log.notice('', npm.updateNotification, { [META]: true, force: true }) |
| 52 | + #handleProcesExitAndReset = (code) => { |
| 53 | + this.#handleProcessExit(code) |
| 54 | + |
| 55 | + // Reset all the state. This is only relevant for tests since |
| 56 | + // in reality the process fully exits here. |
| 57 | + this.#process.off('exit', this.#handleProcesExitAndReset) |
| 58 | + this.#process.off('uncaughtException', this.#handleExit) |
| 59 | + this.#process.off('unhandledRejection', this.#handleExit) |
| 60 | + if (this.#loaded) { |
| 61 | + this.#npm.unload() |
| 62 | + } |
| 63 | + this.#npm = null |
| 64 | + this.#exited = false |
| 65 | + this.#exitErrorMessage = false |
63 | 66 | }
|
64 | 67 |
|
65 |
| - let exitCode = process.exitCode || 0 |
66 |
| - let noLogMessage = exitCode !== 0 |
67 |
| - let jsonError |
68 |
| - |
69 |
| - if (err) { |
70 |
| - exitCode = 1 |
71 |
| - // if we got a command that just shells out to something else, then it |
72 |
| - // will presumably print its own errors and exit with a proper status |
73 |
| - // code if there's a problem. If we got an error with a code=0, then... |
74 |
| - // something else went wrong along the way, so maybe an npm problem? |
75 |
| - const isShellout = npm.isShellout |
76 |
| - const quietShellout = isShellout && typeof err.code === 'number' && err.code |
77 |
| - if (quietShellout) { |
78 |
| - exitCode = err.code |
79 |
| - noLogMessage = true |
80 |
| - } else if (typeof err === 'string') { |
81 |
| - // XXX: we should stop throwing strings |
82 |
| - log.error('', err) |
83 |
| - noLogMessage = true |
84 |
| - } else if (!(err instanceof Error)) { |
85 |
| - log.error('weird error', err) |
86 |
| - noLogMessage = true |
87 |
| - } else { |
88 |
| - const os = require('node:os') |
89 |
| - const fs = require('node:fs') |
90 |
| - if (!err.code) { |
91 |
| - const matchErrorCode = err.message.match(/^(?:Error: )?(E[A-Z]+)/) |
92 |
| - err.code = matchErrorCode && matchErrorCode[1] |
93 |
| - } |
| 68 | + #handleProcessExit (code) { |
| 69 | + // Force exit code to a number if it has not been set |
| 70 | + const exitCode = typeof code === 'number' ? code : (this.#exited ? 0 : 1) |
| 71 | + this.#process.exitCode = exitCode |
94 | 72 |
|
95 |
| - for (const k of ['type', 'stack', 'statusCode', 'pkgid']) { |
96 |
| - const v = err[k] |
97 |
| - if (v) { |
98 |
| - log.verbose(k, replaceInfo(v)) |
99 |
| - } |
100 |
| - } |
| 73 | + if (this.#notLoadedOrExited) { |
| 74 | + // Exit handler was not called and npm was not loaded so we have to log something |
| 75 | + this.#logConsoleError(new Error(`Process exited unexpectedly with code: ${exitCode}`)) |
| 76 | + return |
| 77 | + } |
101 | 78 |
|
102 |
| - log.verbose('cwd', process.cwd()) |
103 |
| - log.verbose('', os.type() + ' ' + os.release()) |
104 |
| - log.verbose('node', process.version) |
105 |
| - log.verbose('npm ', 'v' + npm.version) |
| 79 | + if (this.#logNoNpmError()) { |
| 80 | + return |
| 81 | + } |
106 | 82 |
|
107 |
| - for (const k of ['code', 'syscall', 'file', 'path', 'dest', 'errno']) { |
108 |
| - const v = err[k] |
109 |
| - if (v) { |
110 |
| - log.error(k, v) |
111 |
| - } |
112 |
| - } |
| 83 | + const os = require('node:os') |
| 84 | + log.verbose('cwd', this.#process.cwd()) |
| 85 | + log.verbose('os', `${os.type()} ${os.release()}`) |
| 86 | + log.verbose('node', this.#process.version) |
| 87 | + log.verbose('npm ', `v${this.#npm.version}`) |
113 | 88 |
|
114 |
| - const { summary, detail, json, files = [] } = errorMessage(err, npm) |
115 |
| - jsonError = json |
116 |
| - |
117 |
| - for (let [file, content] of files) { |
118 |
| - file = `${npm.logPath}${file}` |
119 |
| - content = `'Log files:\n${npm.logFiles.join('\n')}\n\n${content.trim()}\n` |
120 |
| - try { |
121 |
| - fs.writeFileSync(file, content) |
122 |
| - detail.push(['', `\n\nFor a full report see:\n${file}`]) |
123 |
| - } catch (logFileErr) { |
124 |
| - log.warn('', `Could not write error message to ${file} due to ${logFileErr}`) |
125 |
| - } |
126 |
| - } |
| 89 | + // only show the notification if it finished |
| 90 | + if (typeof this.#npm.updateNotification === 'string') { |
| 91 | + log.notice('', this.#npm.updateNotification, { [META]: true, force: true }) |
| 92 | + } |
127 | 93 |
|
128 |
| - for (const errline of [...summary, ...detail]) { |
129 |
| - log.error(...errline) |
| 94 | + if (!this.#exited) { |
| 95 | + log.error('', 'Exit handler never called!') |
| 96 | + log.error('', 'This is an error with npm itself. Please report this error at:') |
| 97 | + log.error('', ' <https://github.com/npm/cli/issues>') |
| 98 | + if (this.#npm.silent) { |
| 99 | + output.error('') |
130 | 100 | }
|
| 101 | + } |
131 | 102 |
|
132 |
| - if (typeof err.errno === 'number') { |
133 |
| - exitCode = err.errno |
134 |
| - } else if (typeof err.code === 'number') { |
135 |
| - exitCode = err.code |
136 |
| - } |
| 103 | + log.verbose('exit', exitCode) |
| 104 | + |
| 105 | + if (exitCode) { |
| 106 | + log.verbose('code', exitCode) |
| 107 | + } else { |
| 108 | + log.info('ok') |
| 109 | + } |
| 110 | + |
| 111 | + if (this.#showExitErrorMessage) { |
| 112 | + log.error('', this.#npm.exitErrorMessage()) |
137 | 113 | }
|
138 | 114 | }
|
139 | 115 |
|
140 |
| - if (hasLoadedNpm) { |
141 |
| - output.flush({ [META]: true, jsonError }) |
| 116 | + #logConsoleError (err) { |
| 117 | + // Run our error message formatters on all errors even if we |
| 118 | + // have no npm or an unloaded npm. This will clean the error |
| 119 | + // and possible return a formatted message about EACCESS or something. |
| 120 | + const { summary, detail } = errorMessage(err, this.#npm) |
| 121 | + const formatted = [...new Set([...summary, ...detail].flat().filter(Boolean))].join('\n') |
| 122 | + // If we didn't get anything from the formatted message then just display the full stack |
| 123 | + // eslint-disable-next-line no-console |
| 124 | + console.error(formatted === err.message ? err.stack : formatted) |
142 | 125 | }
|
143 | 126 |
|
144 |
| - log.verbose('exit', exitCode || 0) |
| 127 | + #logNoNpmError (err) { |
| 128 | + if (this.#hasNpm) { |
| 129 | + return false |
| 130 | + } |
| 131 | + // Make sure we only log this error once |
| 132 | + if (!this.#noNpmError) { |
| 133 | + this.#noNpmError = true |
| 134 | + this.#logConsoleError( |
| 135 | + new Error(`Exit prior to setting npm in exit handler`, err ? { cause: err } : {}) |
| 136 | + ) |
| 137 | + } |
| 138 | + return true |
| 139 | + } |
| 140 | + |
| 141 | + #handleExit = (err) => { |
| 142 | + this.#exited = true |
| 143 | + |
| 144 | + // No npm at all |
| 145 | + if (this.#logNoNpmError(err)) { |
| 146 | + return this.#process.exit(this.#process.exitCode || getExitCodeFromError(err) || 1) |
| 147 | + } |
145 | 148 |
|
146 |
| - showLogFileError = (hasLoadedNpm && npm.silent) || noLogMessage |
147 |
| - ? false |
148 |
| - : !!exitCode |
| 149 | + // npm was never loaded but we still might have a config loading error or |
| 150 | + // something similar that we can run through the error message formatter |
| 151 | + // to give the user a clue as to what happened.s |
| 152 | + if (!this.#loaded) { |
| 153 | + this.#logConsoleError(new Error('Exit prior to config file resolving', { cause: err })) |
| 154 | + return this.#process.exit(this.#process.exitCode || getExitCodeFromError(err) || 1) |
| 155 | + } |
| 156 | + |
| 157 | + this.#exitErrorMessage = err?.suppressError === true ? false : !!err |
149 | 158 |
|
150 |
| - // explicitly call process.exit now so we don't hang on things like the |
151 |
| - // update notifier, also flush stdout/err beforehand because process.exit doesn't |
152 |
| - // wait for that to happen. |
153 |
| - let flushed = 0 |
154 |
| - const flush = [process.stderr, process.stdout] |
155 |
| - const exit = () => ++flushed === flush.length && process.exit(exitCode) |
156 |
| - flush.forEach((f) => f.write('', exit)) |
| 159 | + // Prefer the exit code of the error, then the current process exit code, |
| 160 | + // then set it to 1 if we still have an error. Otherwise we call process.exit |
| 161 | + // with undefined so that it can determine the final exit code |
| 162 | + const exitCode = err?.exitCode ?? this.#process.exitCode ?? (err ? 1 : undefined) |
| 163 | + |
| 164 | + // explicitly call process.exit now so we don't hang on things like the |
| 165 | + // update notifier, also flush stdout/err beforehand because process.exit doesn't |
| 166 | + // wait for that to happen. |
| 167 | + this.#process.stderr.write('', () => this.#process.stdout.write('', () => { |
| 168 | + this.#process.exit(exitCode) |
| 169 | + })) |
| 170 | + } |
157 | 171 | }
|
158 | 172 |
|
159 |
| -module.exports = exitHandler |
160 |
| -module.exports.setNpm = n => (npm = n) |
| 173 | +module.exports = ExitHandler |
0 commit comments