Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,16 @@ class ExitHandler {
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)

// Handle signals that might bypass normal exit flow
// These signals can cause the process to exit without calling the exit handler
const signalsToHandle = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToHandle) {
this.#process.on(signal, () => {
// Call the exit handler to ensure proper cleanup
this.#handleExit(new Error(`Process received ${signal}`))
})
}
}

exit (err) {
Expand All@@ -57,6 +67,17 @@ class ExitHandler {
this.#process.off('exit', this.#handleProcesExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)

const signalsToCleanup = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToCleanup) {
try {
this.#process.off(signal, this.#handleExit)
} catch (err) {
// Ignore errors during cleanup - this is defensive programming for edge cases
// where the process object might be in an unexpected state during shutdown
}
}

if (this.#loaded) {
this.#npm.unload()
}
Expand Down
135 changes: 134 additions & 1 deletion test/lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ const EventEmitter = require('node:events')
const os = require('node:os')
const t = require('tap')
const fsMiniPass = require('fs-minipass')
const { output, time } = require('proc-log')
const { output, time, log } = require('proc-log')
const errorMessage = require('../../../lib/utils/error-message.js')
const ExecCommand = require('../../../lib/commands/exec.js')
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
Expand DownExpand Up@@ -707,3 +707,136 @@ t.test('do no fancy handling for shellouts', async t => {
})
})
})

t.test('container scenarios that trigger exit handler bug', async t => {
t.test('process.exit() called before exit handler cleanup', async (t) => {
// Simulates when npm process exits directly without going through proper cleanup

let exitHandlerNeverCalledLogged = false
let npmBugReportLogged = false

await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to capture the specific error messages
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
if (msg === 'This is an error with npm itself. Please report this error at:') {
npmBugReportLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// This happens when containers are stopped/killed before npm can clean up properly
process.emit('exit', 1)

// Verify the bug is detected and logged correctly
t.equal(exitHandlerNeverCalledLogged, true, 'should log "Exit handler never called!" error')
t.equal(npmBugReportLogged, true, 'should log npm bug report message')
})

t.test('SIGTERM signal is handled properly', (t) => {
// This test verifies that our fix handles SIGTERM signals

const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

const initialSigtermCount = process.listeners('SIGTERM').length
const initialSigintCount = process.listeners('SIGINT').length
const initialSighupCount = process.listeners('SIGHUP').length

// Register signal handlers
exitHandler.registerUncaughtHandlers()

const finalSigtermCount = process.listeners('SIGTERM').length
const finalSigintCount = process.listeners('SIGINT').length
const finalSighupCount = process.listeners('SIGHUP').length

// Verify the fix: signal handlers should be registered
t.ok(finalSigtermCount > initialSigtermCount, 'SIGTERM handler should be registered')
t.ok(finalSigintCount > initialSigintCount, 'SIGINT handler should be registered')
t.ok(finalSighupCount > initialSighupCount, 'SIGHUP handler should be registered')

// Clean up listeners to avoid affecting other tests
const sigtermListeners = process.listeners('SIGTERM')
const sigintListeners = process.listeners('SIGINT')
const sighupListeners = process.listeners('SIGHUP')

for (const listener of sigtermListeners) {
process.removeListener('SIGTERM', listener)
}
for (const listener of sigintListeners) {
process.removeListener('SIGINT', listener)
}
for (const listener of sighupListeners) {
process.removeListener('SIGHUP', listener)
}

t.end()
})

t.test('signal handler execution', async (t) => {
const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

// Register signal handlers
exitHandler.registerUncaughtHandlers()

process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')

// Clean up listeners
process.removeAllListeners('SIGTERM')
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGHUP')

t.pass('signal handlers executed successfully')
t.end()
})

t.test('hanging async operation interrupted by signal', async (t) => {
// This test simulates the scenario where npm hangs on a long operation and receives SIGTERM/SIGKILL before it can complete

let exitHandlerNeverCalledLogged = false

const { exitHandler } = await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to detect the bug message
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// Track if exit handler was called properly
let exitHandlerCalled = false
exitHandler.exit = () => {
exitHandlerCalled = true
}

// Simulate sending signal to the process without proper cleanup
// This mimics what happens when a container is terminated
process.emit('exit', 1)

// Verify the bug conditions
t.equal(exitHandlerCalled, false, 'exit handler should not be called in this scenario')
t.equal(exitHandlerNeverCalledLogged, true, 'should detect and log the exit handler bug')
})
})
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,16 @@ class ExitHandler {
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)

// Handle signals that might bypass normal exit flow
// These signals can cause the process to exit without calling the exit handler
const signalsToHandle = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToHandle) {
this.#process.on(signal, () => {
// Call the exit handler to ensure proper cleanup
this.#handleExit(new Error(`Process received ${signal}`))
})
}
}

exit (err) {
Expand All@@ -57,6 +67,17 @@ class ExitHandler {
this.#process.off('exit', this.#handleProcesExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)

const signalsToCleanup = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToCleanup) {
try {
this.#process.off(signal, this.#handleExit)
} catch (err) {
// Ignore errors during cleanup - this is defensive programming for edge cases
// where the process object might be in an unexpected state during shutdown
}
}

if (this.#loaded) {
this.#npm.unload()
}
Expand Down
135 changes: 134 additions & 1 deletion test/lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ const EventEmitter = require('node:events')
const os = require('node:os')
const t = require('tap')
const fsMiniPass = require('fs-minipass')
const { output, time } = require('proc-log')
const { output, time, log } = require('proc-log')
const errorMessage = require('../../../lib/utils/error-message.js')
const ExecCommand = require('../../../lib/commands/exec.js')
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
Expand DownExpand Up@@ -707,3 +707,136 @@ t.test('do no fancy handling for shellouts', async t => {
})
})
})

t.test('container scenarios that trigger exit handler bug', async t => {
t.test('process.exit() called before exit handler cleanup', async (t) => {
// Simulates when npm process exits directly without going through proper cleanup

let exitHandlerNeverCalledLogged = false
let npmBugReportLogged = false

await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to capture the specific error messages
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
if (msg === 'This is an error with npm itself. Please report this error at:') {
npmBugReportLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// This happens when containers are stopped/killed before npm can clean up properly
process.emit('exit', 1)

// Verify the bug is detected and logged correctly
t.equal(exitHandlerNeverCalledLogged, true, 'should log "Exit handler never called!" error')
t.equal(npmBugReportLogged, true, 'should log npm bug report message')
})

t.test('SIGTERM signal is handled properly', (t) => {
// This test verifies that our fix handles SIGTERM signals

const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

const initialSigtermCount = process.listeners('SIGTERM').length
const initialSigintCount = process.listeners('SIGINT').length
const initialSighupCount = process.listeners('SIGHUP').length

// Register signal handlers
exitHandler.registerUncaughtHandlers()

const finalSigtermCount = process.listeners('SIGTERM').length
const finalSigintCount = process.listeners('SIGINT').length
const finalSighupCount = process.listeners('SIGHUP').length

// Verify the fix: signal handlers should be registered
t.ok(finalSigtermCount > initialSigtermCount, 'SIGTERM handler should be registered')
t.ok(finalSigintCount > initialSigintCount, 'SIGINT handler should be registered')
t.ok(finalSighupCount > initialSighupCount, 'SIGHUP handler should be registered')

// Clean up listeners to avoid affecting other tests
const sigtermListeners = process.listeners('SIGTERM')
const sigintListeners = process.listeners('SIGINT')
const sighupListeners = process.listeners('SIGHUP')

for (const listener of sigtermListeners) {
process.removeListener('SIGTERM', listener)
}
for (const listener of sigintListeners) {
process.removeListener('SIGINT', listener)
}
for (const listener of sighupListeners) {
process.removeListener('SIGHUP', listener)
}

t.end()
})

t.test('signal handler execution', async (t) => {
const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

// Register signal handlers
exitHandler.registerUncaughtHandlers()

process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')

// Clean up listeners
process.removeAllListeners('SIGTERM')
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGHUP')

t.pass('signal handlers executed successfully')
t.end()
})

t.test('hanging async operation interrupted by signal', async (t) => {
// This test simulates the scenario where npm hangs on a long operation and receives SIGTERM/SIGKILL before it can complete

let exitHandlerNeverCalledLogged = false

const { exitHandler } = await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to detect the bug message
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// Track if exit handler was called properly
let exitHandlerCalled = false
exitHandler.exit = () => {
exitHandlerCalled = true
}

// Simulate sending signal to the process without proper cleanup
// This mimics what happens when a container is terminated
process.emit('exit', 1)

// Verify the bug conditions
t.equal(exitHandlerCalled, false, 'exit handler should not be called in this scenario')
t.equal(exitHandlerNeverCalledLogged, true, 'should detect and log the exit handler bug')
})
})
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,16 @@ class ExitHandler {
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)

// Handle signals that might bypass normal exit flow
// These signals can cause the process to exit without calling the exit handler
const signalsToHandle = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToHandle) {
this.#process.on(signal, () => {
// Call the exit handler to ensure proper cleanup
this.#handleExit(new Error(`Process received ${signal}`))
})
}
}

exit (err) {
Expand All@@ -57,6 +67,17 @@ class ExitHandler {
this.#process.off('exit', this.#handleProcesExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)

const signalsToCleanup = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToCleanup) {
try {
this.#process.off(signal, this.#handleExit)
} catch (err) {
// Ignore errors during cleanup - this is defensive programming for edge cases
// where the process object might be in an unexpected state during shutdown
}
}

if (this.#loaded) {
this.#npm.unload()
}
Expand Down
135 changes: 134 additions & 1 deletion test/lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ const EventEmitter = require('node:events')
const os = require('node:os')
const t = require('tap')
const fsMiniPass = require('fs-minipass')
const { output, time } = require('proc-log')
const { output, time, log } = require('proc-log')
const errorMessage = require('../../../lib/utils/error-message.js')
const ExecCommand = require('../../../lib/commands/exec.js')
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
Expand DownExpand Up@@ -707,3 +707,136 @@ t.test('do no fancy handling for shellouts', async t => {
})
})
})

t.test('container scenarios that trigger exit handler bug', async t => {
t.test('process.exit() called before exit handler cleanup', async (t) => {
// Simulates when npm process exits directly without going through proper cleanup

let exitHandlerNeverCalledLogged = false
let npmBugReportLogged = false

await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to capture the specific error messages
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
if (msg === 'This is an error with npm itself. Please report this error at:') {
npmBugReportLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// This happens when containers are stopped/killed before npm can clean up properly
process.emit('exit', 1)

// Verify the bug is detected and logged correctly
t.equal(exitHandlerNeverCalledLogged, true, 'should log "Exit handler never called!" error')
t.equal(npmBugReportLogged, true, 'should log npm bug report message')
})

t.test('SIGTERM signal is handled properly', (t) => {
// This test verifies that our fix handles SIGTERM signals

const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

const initialSigtermCount = process.listeners('SIGTERM').length
const initialSigintCount = process.listeners('SIGINT').length
const initialSighupCount = process.listeners('SIGHUP').length

// Register signal handlers
exitHandler.registerUncaughtHandlers()

const finalSigtermCount = process.listeners('SIGTERM').length
const finalSigintCount = process.listeners('SIGINT').length
const finalSighupCount = process.listeners('SIGHUP').length

// Verify the fix: signal handlers should be registered
t.ok(finalSigtermCount > initialSigtermCount, 'SIGTERM handler should be registered')
t.ok(finalSigintCount > initialSigintCount, 'SIGINT handler should be registered')
t.ok(finalSighupCount > initialSighupCount, 'SIGHUP handler should be registered')

// Clean up listeners to avoid affecting other tests
const sigtermListeners = process.listeners('SIGTERM')
const sigintListeners = process.listeners('SIGINT')
const sighupListeners = process.listeners('SIGHUP')

for (const listener of sigtermListeners) {
process.removeListener('SIGTERM', listener)
}
for (const listener of sigintListeners) {
process.removeListener('SIGINT', listener)
}
for (const listener of sighupListeners) {
process.removeListener('SIGHUP', listener)
}

t.end()
})

t.test('signal handler execution', async (t) => {
const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

// Register signal handlers
exitHandler.registerUncaughtHandlers()

process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')

// Clean up listeners
process.removeAllListeners('SIGTERM')
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGHUP')

t.pass('signal handlers executed successfully')
t.end()
})

t.test('hanging async operation interrupted by signal', async (t) => {
// This test simulates the scenario where npm hangs on a long operation and receives SIGTERM/SIGKILL before it can complete

let exitHandlerNeverCalledLogged = false

const { exitHandler } = await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to detect the bug message
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// Track if exit handler was called properly
let exitHandlerCalled = false
exitHandler.exit = () => {
exitHandlerCalled = true
}

// Simulate sending signal to the process without proper cleanup
// This mimics what happens when a container is terminated
process.emit('exit', 1)

// Verify the bug conditions
t.equal(exitHandlerCalled, false, 'exit handler should not be called in this scenario')
t.equal(exitHandlerNeverCalledLogged, true, 'should detect and log the exit handler bug')
})
})
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,16 @@ class ExitHandler {
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)

// Handle signals that might bypass normal exit flow
// These signals can cause the process to exit without calling the exit handler
const signalsToHandle = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToHandle) {
this.#process.on(signal, () => {
// Call the exit handler to ensure proper cleanup
this.#handleExit(new Error(`Process received ${signal}`))
})
}
}

exit (err) {
Expand All@@ -57,6 +67,17 @@ class ExitHandler {
this.#process.off('exit', this.#handleProcesExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)

const signalsToCleanup = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToCleanup) {
try {
this.#process.off(signal, this.#handleExit)
} catch (err) {
// Ignore errors during cleanup - this is defensive programming for edge cases
// where the process object might be in an unexpected state during shutdown
}
}

if (this.#loaded) {
this.#npm.unload()
}
Expand Down
135 changes: 134 additions & 1 deletion test/lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ const EventEmitter = require('node:events')
const os = require('node:os')
const t = require('tap')
const fsMiniPass = require('fs-minipass')
const { output, time } = require('proc-log')
const { output, time, log } = require('proc-log')
const errorMessage = require('../../../lib/utils/error-message.js')
const ExecCommand = require('../../../lib/commands/exec.js')
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
Expand DownExpand Up@@ -707,3 +707,136 @@ t.test('do no fancy handling for shellouts', async t => {
})
})
})

t.test('container scenarios that trigger exit handler bug', async t => {
t.test('process.exit() called before exit handler cleanup', async (t) => {
// Simulates when npm process exits directly without going through proper cleanup

let exitHandlerNeverCalledLogged = false
let npmBugReportLogged = false

await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to capture the specific error messages
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
if (msg === 'This is an error with npm itself. Please report this error at:') {
npmBugReportLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// This happens when containers are stopped/killed before npm can clean up properly
process.emit('exit', 1)

// Verify the bug is detected and logged correctly
t.equal(exitHandlerNeverCalledLogged, true, 'should log "Exit handler never called!" error')
t.equal(npmBugReportLogged, true, 'should log npm bug report message')
})

t.test('SIGTERM signal is handled properly', (t) => {
// This test verifies that our fix handles SIGTERM signals

const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

const initialSigtermCount = process.listeners('SIGTERM').length
const initialSigintCount = process.listeners('SIGINT').length
const initialSighupCount = process.listeners('SIGHUP').length

// Register signal handlers
exitHandler.registerUncaughtHandlers()

const finalSigtermCount = process.listeners('SIGTERM').length
const finalSigintCount = process.listeners('SIGINT').length
const finalSighupCount = process.listeners('SIGHUP').length

// Verify the fix: signal handlers should be registered
t.ok(finalSigtermCount > initialSigtermCount, 'SIGTERM handler should be registered')
t.ok(finalSigintCount > initialSigintCount, 'SIGINT handler should be registered')
t.ok(finalSighupCount > initialSighupCount, 'SIGHUP handler should be registered')

// Clean up listeners to avoid affecting other tests
const sigtermListeners = process.listeners('SIGTERM')
const sigintListeners = process.listeners('SIGINT')
const sighupListeners = process.listeners('SIGHUP')

for (const listener of sigtermListeners) {
process.removeListener('SIGTERM', listener)
}
for (const listener of sigintListeners) {
process.removeListener('SIGINT', listener)
}
for (const listener of sighupListeners) {
process.removeListener('SIGHUP', listener)
}

t.end()
})

t.test('signal handler execution', async (t) => {
const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

// Register signal handlers
exitHandler.registerUncaughtHandlers()

process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')

// Clean up listeners
process.removeAllListeners('SIGTERM')
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGHUP')

t.pass('signal handlers executed successfully')
t.end()
})

t.test('hanging async operation interrupted by signal', async (t) => {
// This test simulates the scenario where npm hangs on a long operation and receives SIGTERM/SIGKILL before it can complete

let exitHandlerNeverCalledLogged = false

const { exitHandler } = await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to detect the bug message
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// Track if exit handler was called properly
let exitHandlerCalled = false
exitHandler.exit = () => {
exitHandlerCalled = true
}

// Simulate sending signal to the process without proper cleanup
// This mimics what happens when a container is terminated
process.emit('exit', 1)

// Verify the bug conditions
t.equal(exitHandlerCalled, false, 'exit handler should not be called in this scenario')
t.equal(exitHandlerNeverCalledLogged, true, 'should detect and log the exit handler bug')
})
})
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,16 @@ class ExitHandler {
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)

// Handle signals that might bypass normal exit flow
// These signals can cause the process to exit without calling the exit handler
const signalsToHandle = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToHandle) {
this.#process.on(signal, () => {
// Call the exit handler to ensure proper cleanup
this.#handleExit(new Error(`Process received ${signal}`))
})
}
}

exit (err) {
Expand All@@ -57,6 +67,17 @@ class ExitHandler {
this.#process.off('exit', this.#handleProcesExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)

const signalsToCleanup = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToCleanup) {
try {
this.#process.off(signal, this.#handleExit)
} catch (err) {
// Ignore errors during cleanup - this is defensive programming for edge cases
// where the process object might be in an unexpected state during shutdown
}
}

if (this.#loaded) {
this.#npm.unload()
}
Expand Down
135 changes: 134 additions & 1 deletion test/lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ const EventEmitter = require('node:events')
const os = require('node:os')
const t = require('tap')
const fsMiniPass = require('fs-minipass')
const { output, time } = require('proc-log')
const { output, time, log } = require('proc-log')
const errorMessage = require('../../../lib/utils/error-message.js')
const ExecCommand = require('../../../lib/commands/exec.js')
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
Expand DownExpand Up@@ -707,3 +707,136 @@ t.test('do no fancy handling for shellouts', async t => {
})
})
})

t.test('container scenarios that trigger exit handler bug', async t => {
t.test('process.exit() called before exit handler cleanup', async (t) => {
// Simulates when npm process exits directly without going through proper cleanup

let exitHandlerNeverCalledLogged = false
let npmBugReportLogged = false

await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to capture the specific error messages
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
if (msg === 'This is an error with npm itself. Please report this error at:') {
npmBugReportLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// This happens when containers are stopped/killed before npm can clean up properly
process.emit('exit', 1)

// Verify the bug is detected and logged correctly
t.equal(exitHandlerNeverCalledLogged, true, 'should log "Exit handler never called!" error')
t.equal(npmBugReportLogged, true, 'should log npm bug report message')
})

t.test('SIGTERM signal is handled properly', (t) => {
// This test verifies that our fix handles SIGTERM signals

const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

const initialSigtermCount = process.listeners('SIGTERM').length
const initialSigintCount = process.listeners('SIGINT').length
const initialSighupCount = process.listeners('SIGHUP').length

// Register signal handlers
exitHandler.registerUncaughtHandlers()

const finalSigtermCount = process.listeners('SIGTERM').length
const finalSigintCount = process.listeners('SIGINT').length
const finalSighupCount = process.listeners('SIGHUP').length

// Verify the fix: signal handlers should be registered
t.ok(finalSigtermCount > initialSigtermCount, 'SIGTERM handler should be registered')
t.ok(finalSigintCount > initialSigintCount, 'SIGINT handler should be registered')
t.ok(finalSighupCount > initialSighupCount, 'SIGHUP handler should be registered')

// Clean up listeners to avoid affecting other tests
const sigtermListeners = process.listeners('SIGTERM')
const sigintListeners = process.listeners('SIGINT')
const sighupListeners = process.listeners('SIGHUP')

for (const listener of sigtermListeners) {
process.removeListener('SIGTERM', listener)
}
for (const listener of sigintListeners) {
process.removeListener('SIGINT', listener)
}
for (const listener of sighupListeners) {
process.removeListener('SIGHUP', listener)
}

t.end()
})

t.test('signal handler execution', async (t) => {
const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

// Register signal handlers
exitHandler.registerUncaughtHandlers()

process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')

// Clean up listeners
process.removeAllListeners('SIGTERM')
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGHUP')

t.pass('signal handlers executed successfully')
t.end()
})

t.test('hanging async operation interrupted by signal', async (t) => {
// This test simulates the scenario where npm hangs on a long operation and receives SIGTERM/SIGKILL before it can complete

let exitHandlerNeverCalledLogged = false

const { exitHandler } = await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to detect the bug message
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// Track if exit handler was called properly
let exitHandlerCalled = false
exitHandler.exit = () => {
exitHandlerCalled = true
}

// Simulate sending signal to the process without proper cleanup
// This mimics what happens when a container is terminated
process.emit('exit', 1)

// Verify the bug conditions
t.equal(exitHandlerCalled, false, 'exit handler should not be called in this scenario')
t.equal(exitHandlerNeverCalledLogged, true, 'should detect and log the exit handler bug')
})
})
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,16 @@ class ExitHandler {
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)

// Handle signals that might bypass normal exit flow
// These signals can cause the process to exit without calling the exit handler
const signalsToHandle = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToHandle) {
this.#process.on(signal, () => {
// Call the exit handler to ensure proper cleanup
this.#handleExit(new Error(`Process received ${signal}`))
})
}
}

exit (err) {
Expand All@@ -57,6 +67,17 @@ class ExitHandler {
this.#process.off('exit', this.#handleProcesExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)

const signalsToCleanup = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToCleanup) {
try {
this.#process.off(signal, this.#handleExit)
} catch (err) {
// Ignore errors during cleanup - this is defensive programming for edge cases
// where the process object might be in an unexpected state during shutdown
}
}

if (this.#loaded) {
this.#npm.unload()
}
Expand Down
135 changes: 134 additions & 1 deletion test/lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ const EventEmitter = require('node:events')
const os = require('node:os')
const t = require('tap')
const fsMiniPass = require('fs-minipass')
const { output, time } = require('proc-log')
const { output, time, log } = require('proc-log')
const errorMessage = require('../../../lib/utils/error-message.js')
const ExecCommand = require('../../../lib/commands/exec.js')
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
Expand DownExpand Up@@ -707,3 +707,136 @@ t.test('do no fancy handling for shellouts', async t => {
})
})
})

t.test('container scenarios that trigger exit handler bug', async t => {
t.test('process.exit() called before exit handler cleanup', async (t) => {
// Simulates when npm process exits directly without going through proper cleanup

let exitHandlerNeverCalledLogged = false
let npmBugReportLogged = false

await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to capture the specific error messages
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
if (msg === 'This is an error with npm itself. Please report this error at:') {
npmBugReportLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// This happens when containers are stopped/killed before npm can clean up properly
process.emit('exit', 1)

// Verify the bug is detected and logged correctly
t.equal(exitHandlerNeverCalledLogged, true, 'should log "Exit handler never called!" error')
t.equal(npmBugReportLogged, true, 'should log npm bug report message')
})

t.test('SIGTERM signal is handled properly', (t) => {
// This test verifies that our fix handles SIGTERM signals

const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

const initialSigtermCount = process.listeners('SIGTERM').length
const initialSigintCount = process.listeners('SIGINT').length
const initialSighupCount = process.listeners('SIGHUP').length

// Register signal handlers
exitHandler.registerUncaughtHandlers()

const finalSigtermCount = process.listeners('SIGTERM').length
const finalSigintCount = process.listeners('SIGINT').length
const finalSighupCount = process.listeners('SIGHUP').length

// Verify the fix: signal handlers should be registered
t.ok(finalSigtermCount > initialSigtermCount, 'SIGTERM handler should be registered')
t.ok(finalSigintCount > initialSigintCount, 'SIGINT handler should be registered')
t.ok(finalSighupCount > initialSighupCount, 'SIGHUP handler should be registered')

// Clean up listeners to avoid affecting other tests
const sigtermListeners = process.listeners('SIGTERM')
const sigintListeners = process.listeners('SIGINT')
const sighupListeners = process.listeners('SIGHUP')

for (const listener of sigtermListeners) {
process.removeListener('SIGTERM', listener)
}
for (const listener of sigintListeners) {
process.removeListener('SIGINT', listener)
}
for (const listener of sighupListeners) {
process.removeListener('SIGHUP', listener)
}

t.end()
})

t.test('signal handler execution', async (t) => {
const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

// Register signal handlers
exitHandler.registerUncaughtHandlers()

process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')

// Clean up listeners
process.removeAllListeners('SIGTERM')
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGHUP')

t.pass('signal handlers executed successfully')
t.end()
})

t.test('hanging async operation interrupted by signal', async (t) => {
// This test simulates the scenario where npm hangs on a long operation and receives SIGTERM/SIGKILL before it can complete

let exitHandlerNeverCalledLogged = false

const { exitHandler } = await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to detect the bug message
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// Track if exit handler was called properly
let exitHandlerCalled = false
exitHandler.exit = () => {
exitHandlerCalled = true
}

// Simulate sending signal to the process without proper cleanup
// This mimics what happens when a container is terminated
process.emit('exit', 1)

// Verify the bug conditions
t.equal(exitHandlerCalled, false, 'exit handler should not be called in this scenario')
t.equal(exitHandlerNeverCalledLogged, true, 'should detect and log the exit handler bug')
})
})
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,16 @@ class ExitHandler {
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)

// Handle signals that might bypass normal exit flow
// These signals can cause the process to exit without calling the exit handler
const signalsToHandle = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToHandle) {
this.#process.on(signal, () => {
// Call the exit handler to ensure proper cleanup
this.#handleExit(new Error(`Process received ${signal}`))
})
}
}

exit (err) {
Expand All@@ -57,6 +67,17 @@ class ExitHandler {
this.#process.off('exit', this.#handleProcesExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)

const signalsToCleanup = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToCleanup) {
try {
this.#process.off(signal, this.#handleExit)
} catch (err) {
// Ignore errors during cleanup - this is defensive programming for edge cases
// where the process object might be in an unexpected state during shutdown
}
}

if (this.#loaded) {
this.#npm.unload()
}
Expand Down
135 changes: 134 additions & 1 deletion test/lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ const EventEmitter = require('node:events')
const os = require('node:os')
const t = require('tap')
const fsMiniPass = require('fs-minipass')
const { output, time } = require('proc-log')
const { output, time, log } = require('proc-log')
const errorMessage = require('../../../lib/utils/error-message.js')
const ExecCommand = require('../../../lib/commands/exec.js')
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
Expand DownExpand Up@@ -707,3 +707,136 @@ t.test('do no fancy handling for shellouts', async t => {
})
})
})

t.test('container scenarios that trigger exit handler bug', async t => {
t.test('process.exit() called before exit handler cleanup', async (t) => {
// Simulates when npm process exits directly without going through proper cleanup

let exitHandlerNeverCalledLogged = false
let npmBugReportLogged = false

await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to capture the specific error messages
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
if (msg === 'This is an error with npm itself. Please report this error at:') {
npmBugReportLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// This happens when containers are stopped/killed before npm can clean up properly
process.emit('exit', 1)

// Verify the bug is detected and logged correctly
t.equal(exitHandlerNeverCalledLogged, true, 'should log "Exit handler never called!" error')
t.equal(npmBugReportLogged, true, 'should log npm bug report message')
})

t.test('SIGTERM signal is handled properly', (t) => {
// This test verifies that our fix handles SIGTERM signals

const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

const initialSigtermCount = process.listeners('SIGTERM').length
const initialSigintCount = process.listeners('SIGINT').length
const initialSighupCount = process.listeners('SIGHUP').length

// Register signal handlers
exitHandler.registerUncaughtHandlers()

const finalSigtermCount = process.listeners('SIGTERM').length
const finalSigintCount = process.listeners('SIGINT').length
const finalSighupCount = process.listeners('SIGHUP').length

// Verify the fix: signal handlers should be registered
t.ok(finalSigtermCount > initialSigtermCount, 'SIGTERM handler should be registered')
t.ok(finalSigintCount > initialSigintCount, 'SIGINT handler should be registered')
t.ok(finalSighupCount > initialSighupCount, 'SIGHUP handler should be registered')

// Clean up listeners to avoid affecting other tests
const sigtermListeners = process.listeners('SIGTERM')
const sigintListeners = process.listeners('SIGINT')
const sighupListeners = process.listeners('SIGHUP')

for (const listener of sigtermListeners) {
process.removeListener('SIGTERM', listener)
}
for (const listener of sigintListeners) {
process.removeListener('SIGINT', listener)
}
for (const listener of sighupListeners) {
process.removeListener('SIGHUP', listener)
}

t.end()
})

t.test('signal handler execution', async (t) => {
const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

// Register signal handlers
exitHandler.registerUncaughtHandlers()

process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')

// Clean up listeners
process.removeAllListeners('SIGTERM')
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGHUP')

t.pass('signal handlers executed successfully')
t.end()
})

t.test('hanging async operation interrupted by signal', async (t) => {
// This test simulates the scenario where npm hangs on a long operation and receives SIGTERM/SIGKILL before it can complete

let exitHandlerNeverCalledLogged = false

const { exitHandler } = await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to detect the bug message
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// Track if exit handler was called properly
let exitHandlerCalled = false
exitHandler.exit = () => {
exitHandlerCalled = true
}

// Simulate sending signal to the process without proper cleanup
// This mimics what happens when a container is terminated
process.emit('exit', 1)

// Verify the bug conditions
t.equal(exitHandlerCalled, false, 'exit handler should not be called in this scenario')
t.equal(exitHandlerNeverCalledLogged, true, 'should detect and log the exit handler bug')
})
})
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,16 @@ class ExitHandler {
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)

// Handle signals that might bypass normal exit flow
// These signals can cause the process to exit without calling the exit handler
const signalsToHandle = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToHandle) {
this.#process.on(signal, () => {
// Call the exit handler to ensure proper cleanup
this.#handleExit(new Error(`Process received ${signal}`))
})
}
}

exit (err) {
Expand All@@ -57,6 +67,17 @@ class ExitHandler {
this.#process.off('exit', this.#handleProcesExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)

const signalsToCleanup = ['SIGTERM', 'SIGINT', 'SIGHUP']
for (const signal of signalsToCleanup) {
try {
this.#process.off(signal, this.#handleExit)
} catch (err) {
// Ignore errors during cleanup - this is defensive programming for edge cases
// where the process object might be in an unexpected state during shutdown
}
}

if (this.#loaded) {
this.#npm.unload()
}
Expand Down
135 changes: 134 additions & 1 deletion test/lib/cli/exit-handler.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ const EventEmitter = require('node:events')
const os = require('node:os')
const t = require('tap')
const fsMiniPass = require('fs-minipass')
const { output, time } = require('proc-log')
const { output, time, log } = require('proc-log')
const errorMessage = require('../../../lib/utils/error-message.js')
const ExecCommand = require('../../../lib/commands/exec.js')
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
Expand DownExpand Up@@ -707,3 +707,136 @@ t.test('do no fancy handling for shellouts', async t => {
})
})
})

t.test('container scenarios that trigger exit handler bug', async t => {
t.test('process.exit() called before exit handler cleanup', async (t) => {
// Simulates when npm process exits directly without going through proper cleanup

let exitHandlerNeverCalledLogged = false
let npmBugReportLogged = false

await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to capture the specific error messages
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
if (msg === 'This is an error with npm itself. Please report this error at:') {
npmBugReportLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// This happens when containers are stopped/killed before npm can clean up properly
process.emit('exit', 1)

// Verify the bug is detected and logged correctly
t.equal(exitHandlerNeverCalledLogged, true, 'should log "Exit handler never called!" error')
t.equal(npmBugReportLogged, true, 'should log npm bug report message')
})

t.test('SIGTERM signal is handled properly', (t) => {
// This test verifies that our fix handles SIGTERM signals

const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

const initialSigtermCount = process.listeners('SIGTERM').length
const initialSigintCount = process.listeners('SIGINT').length
const initialSighupCount = process.listeners('SIGHUP').length

// Register signal handlers
exitHandler.registerUncaughtHandlers()

const finalSigtermCount = process.listeners('SIGTERM').length
const finalSigintCount = process.listeners('SIGINT').length
const finalSighupCount = process.listeners('SIGHUP').length

// Verify the fix: signal handlers should be registered
t.ok(finalSigtermCount > initialSigtermCount, 'SIGTERM handler should be registered')
t.ok(finalSigintCount > initialSigintCount, 'SIGINT handler should be registered')
t.ok(finalSighupCount > initialSighupCount, 'SIGHUP handler should be registered')

// Clean up listeners to avoid affecting other tests
const sigtermListeners = process.listeners('SIGTERM')
const sigintListeners = process.listeners('SIGINT')
const sighupListeners = process.listeners('SIGHUP')

for (const listener of sigtermListeners) {
process.removeListener('SIGTERM', listener)
}
for (const listener of sigintListeners) {
process.removeListener('SIGINT', listener)
}
for (const listener of sighupListeners) {
process.removeListener('SIGHUP', listener)
}

t.end()
})

t.test('signal handler execution', async (t) => {
const ExitHandler = tmock(t, '{LIB}/cli/exit-handler.js')
const exitHandler = new ExitHandler({ process })

// Register signal handlers
exitHandler.registerUncaughtHandlers()

process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')

// Clean up listeners
process.removeAllListeners('SIGTERM')
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGHUP')

t.pass('signal handlers executed successfully')
t.end()
})

t.test('hanging async operation interrupted by signal', async (t) => {
// This test simulates the scenario where npm hangs on a long operation and receives SIGTERM/SIGKILL before it can complete

let exitHandlerNeverCalledLogged = false

const { exitHandler } = await mockExitHandler(t, {
config: { loglevel: 'notice' },
})

// Override log.error to detect the bug message
const originalLogError = log.error
log.error = (prefix, msg) => {
if (msg === 'Exit handler never called!') {
exitHandlerNeverCalledLogged = true
}
return originalLogError(prefix, msg)
}

t.teardown(() => {
log.error = originalLogError
})

// Track if exit handler was called properly
let exitHandlerCalled = false
exitHandler.exit = () => {
exitHandlerCalled = true
}

// Simulate sending signal to the process without proper cleanup
// This mimics what happens when a container is terminated
process.emit('exit', 1)

// Verify the bug conditions
t.equal(exitHandlerCalled, false, 'exit handler should not be called in this scenario')
t.equal(exitHandlerNeverCalledLogged, true, 'should detect and log the exit handler bug')
})
})