From d0c1848c9438078703d515c3ca6d13e4956e1243 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BE=AE=E8=8E=B9=C2=B7=E7=BA=A4=E7=BB=AB?=
 <kar@kasukusakura.com>
Date: Tue, 7 Jun 2022 00:10:51 +0800
Subject: [PATCH] Improve console shutdown (#2016)

* Try to improve console shutdown

* Resetting & Better java calling

* Dump crash report when timed out to shutdown

* Ensure `CoroutineScope.cancel()` working; rename to `shutdown`

* Signal handlers

* Force halt system to avoid some magic errors
---
 .../backend/mirai-console/src/MiraiConsole.kt |  19 +-
 .../src/MiraiConsoleImplementation.kt         |  24 +-
 .../src/command/BuiltInCommands.kt            |  13 +-
 .../MiraiConsoleImplementationBridge.kt       |   4 +
 .../src/internal/plugin/JvmPluginInternal.kt  |   9 +-
 .../src/internal/shutdown/ShutdownDaemon.kt   | 309 ++++++++++++++++++
 .../src/ConsoleThread.kt                      |   2 +-
 .../src/MiraiConsoleImplementationTerminal.kt |   2 +
 .../src/MiraiConsoleTerminalLoader.kt         |  97 +++++-
 9 files changed, 455 insertions(+), 24 deletions(-)
 create mode 100644 mirai-console/backend/mirai-console/src/internal/shutdown/ShutdownDaemon.kt

diff --git a/mirai-console/backend/mirai-console/src/MiraiConsole.kt b/mirai-console/backend/mirai-console/src/MiraiConsole.kt
index c20cf511f..ff4f3e11c 100644
--- a/mirai-console/backend/mirai-console/src/MiraiConsole.kt
+++ b/mirai-console/backend/mirai-console/src/MiraiConsole.kt
@@ -12,8 +12,7 @@
 
 package net.mamoe.mirai.console
 
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
+import kotlinx.coroutines.*
 import me.him188.kotlin.dynamic.delegation.dynamicDelegation
 import net.mamoe.mirai.Bot
 import net.mamoe.mirai.BotFactory
@@ -239,6 +238,22 @@ public interface MiraiConsole : CoroutineScope {
         @ConsoleExperimentalApi("This is a low-level API and might be removed in the future.")
         public val isActive: Boolean
             get() = job.isActive
+
+        /**
+         * 停止 Console 运行
+         *
+         * Console 会在一个合适的时间进行关闭, 并不是调用马上关闭 Console
+         */
+        @ConsoleExperimentalApi
+        @JvmStatic
+        public fun shutdown() {
+            val consoleJob = job
+            if (!consoleJob.isActive) return
+            @OptIn(DelicateCoroutinesApi::class)
+            GlobalScope.launch {
+                MiraiConsoleImplementation.shutdown()
+            }
+        }
     }
 
 
diff --git a/mirai-console/backend/mirai-console/src/MiraiConsoleImplementation.kt b/mirai-console/backend/mirai-console/src/MiraiConsoleImplementation.kt
index cc2115c25..2f547d246 100644
--- a/mirai-console/backend/mirai-console/src/MiraiConsoleImplementation.kt
+++ b/mirai-console/backend/mirai-console/src/MiraiConsoleImplementation.kt
@@ -28,6 +28,7 @@ import net.mamoe.mirai.console.internal.data.builtins.ConsoleDataScopeImpl
 import net.mamoe.mirai.console.internal.logging.LoggerControllerImpl
 import net.mamoe.mirai.console.internal.plugin.BuiltInJvmPluginLoaderImpl
 import net.mamoe.mirai.console.internal.pluginManagerImpl
+import net.mamoe.mirai.console.internal.shutdown.ShutdownDaemon
 import net.mamoe.mirai.console.logging.LoggerController
 import net.mamoe.mirai.console.plugin.Plugin
 import net.mamoe.mirai.console.plugin.jvm.JvmPluginLoader
@@ -396,10 +397,29 @@ public interface MiraiConsoleImplementation : CoroutineScope {
             override val resolvedPlugins: MutableList<Plugin> get() = MiraiConsole.pluginManagerImpl.resolvedPlugins
         }
 
+        internal suspend fun shutdown() {
+            val bridge = currentBridge ?: return
+            if (!bridge.isActive) return
+            bridge.shutdownDaemon.tryStart()
+
+            Bot.instances.forEach { bot ->
+                lateinit var logger: MiraiLogger
+                kotlin.runCatching {
+                    logger = bot.logger
+                    bot.closeAndJoin()
+                }.onFailure { t ->
+                    kotlin.runCatching { logger.error("Error in closing bot", t) }
+                }
+            }
+            MiraiConsole.job.cancelAndJoin()
+        }
+
         init {
-            Runtime.getRuntime().addShutdownHook(thread(false) {
+            Runtime.getRuntime().addShutdownHook(thread(false, name = "Mirai Console Shutdown Hook") {
                 if (instanceInitialized) {
-                    runBlocking { MiraiConsole.job.cancelAndJoin() }
+                    runBlocking {
+                        shutdown()
+                    }
                 }
             })
         }
diff --git a/mirai-console/backend/mirai-console/src/command/BuiltInCommands.kt b/mirai-console/backend/mirai-console/src/command/BuiltInCommands.kt
index bd5d30f6f..31c8f6e8e 100644
--- a/mirai-console/backend/mirai-console/src/command/BuiltInCommands.kt
+++ b/mirai-console/backend/mirai-console/src/command/BuiltInCommands.kt
@@ -11,7 +11,6 @@ package net.mamoe.mirai.console.command
 
 import kotlinx.coroutines.DelicateCoroutinesApi
 import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
@@ -54,7 +53,6 @@ import net.mamoe.mirai.console.util.ConsoleInternalApi
 import net.mamoe.mirai.console.util.sendAnsiMessage
 import net.mamoe.mirai.event.events.EventCancelledException
 import net.mamoe.mirai.utils.BotConfiguration
-import net.mamoe.mirai.utils.MiraiLogger
 import java.lang.management.ManagementFactory
 import java.lang.management.MemoryMXBean
 import java.lang.management.MemoryUsage
@@ -140,16 +138,7 @@ public object BuiltInCommands {
                         if (!MiraiConsole.isActive) return@withLock
                         sendMessage("Stopping mirai-console")
                         kotlin.runCatching {
-                            Bot.instances.forEach { bot ->
-                                lateinit var logger: MiraiLogger
-                                kotlin.runCatching {
-                                    logger = bot.logger
-                                    bot.closeAndJoin()
-                                }.onFailure { t ->
-                                    kotlin.runCatching { logger.error("Error in closing bot", t) }
-                                }
-                            }
-                            MiraiConsole.job.cancelAndJoin()
+                            MiraiConsoleImplementation.shutdown()
                         }.fold(
                             onSuccess = {
                                 runIgnoreException<EventCancelledException> { sendMessage("mirai-console stopped successfully.") }
diff --git a/mirai-console/backend/mirai-console/src/internal/MiraiConsoleImplementationBridge.kt b/mirai-console/backend/mirai-console/src/internal/MiraiConsoleImplementationBridge.kt
index 1fa833945..d2f6a0655 100644
--- a/mirai-console/backend/mirai-console/src/internal/MiraiConsoleImplementationBridge.kt
+++ b/mirai-console/backend/mirai-console/src/internal/MiraiConsoleImplementationBridge.kt
@@ -43,6 +43,7 @@ import net.mamoe.mirai.console.internal.logging.LoggerControllerImpl
 import net.mamoe.mirai.console.internal.logging.MiraiConsoleLogger
 import net.mamoe.mirai.console.internal.permission.BuiltInPermissionService
 import net.mamoe.mirai.console.internal.plugin.PluginManagerImpl
+import net.mamoe.mirai.console.internal.shutdown.ShutdownDaemon
 import net.mamoe.mirai.console.internal.util.runIgnoreException
 import net.mamoe.mirai.console.logging.LoggerController
 import net.mamoe.mirai.console.permission.PermissionService
@@ -58,6 +59,7 @@ import net.mamoe.mirai.utils.*
 import java.time.Instant
 import java.time.ZoneId
 import java.time.format.DateTimeFormatter
+import java.util.concurrent.atomic.AtomicBoolean
 import kotlin.contracts.InvocationKind
 import kotlin.contracts.contract
 import kotlin.reflect.KProperty
@@ -85,6 +87,7 @@ internal class MiraiConsoleImplementationBridge(
 
     // used internally
     val globalComponentStorage: GlobalComponentStorageImpl by lazy { GlobalComponentStorageImpl() }
+    val shutdownDaemon = ShutdownDaemon.DaemonStarter(this)
 
     // tentative workaround for https://github.com/mamoe/mirai/pull/1889#pullrequestreview-887903183
     @Volatile
@@ -147,6 +150,7 @@ internal class MiraiConsoleImplementationBridge(
             }
 
             MiraiConsole.job.invokeOnCompletion {
+                shutdownDaemon.tryStart()
                 Bot.instances.forEach { kotlin.runCatching { it.close() }.exceptionOrNull()?.let(mainLogger::error) }
             }
         }
diff --git a/mirai-console/backend/mirai-console/src/internal/plugin/JvmPluginInternal.kt b/mirai-console/backend/mirai-console/src/internal/plugin/JvmPluginInternal.kt
index 99354c03f..d1fe7fb92 100644
--- a/mirai-console/backend/mirai-console/src/internal/plugin/JvmPluginInternal.kt
+++ b/mirai-console/backend/mirai-console/src/internal/plugin/JvmPluginInternal.kt
@@ -18,6 +18,7 @@ import net.mamoe.mirai.console.data.runCatchingLog
 import net.mamoe.mirai.console.extension.PluginComponentStorage
 import net.mamoe.mirai.console.internal.data.mkdir
 import net.mamoe.mirai.console.internal.extension.GlobalComponentStorage
+import net.mamoe.mirai.console.internal.shutdown.ShutdownDaemon
 import net.mamoe.mirai.console.permission.Permission
 import net.mamoe.mirai.console.permission.PermissionService
 import net.mamoe.mirai.console.plugin.Plugin
@@ -89,7 +90,13 @@ internal abstract class JvmPluginInternal(
     internal fun internalOnDisable() {
         firstRun = false
         kotlin.runCatching {
-            onDisable()
+            val crtThread = Thread.currentThread()
+            ShutdownDaemon.pluginDisablingThreads.add(crtThread)
+            try {
+                onDisable()
+            } finally {
+                ShutdownDaemon.pluginDisablingThreads.remove(crtThread)
+            }
         }.fold(
             onSuccess = {
                 cancel(CancellationException("plugin disabled"))
diff --git a/mirai-console/backend/mirai-console/src/internal/shutdown/ShutdownDaemon.kt b/mirai-console/backend/mirai-console/src/internal/shutdown/ShutdownDaemon.kt
new file mode 100644
index 000000000..326fd2295
--- /dev/null
+++ b/mirai-console/backend/mirai-console/src/internal/shutdown/ShutdownDaemon.kt
@@ -0,0 +1,309 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.console.internal.shutdown
+
+import kotlinx.coroutines.*
+import net.mamoe.mirai.console.MiraiConsole
+import net.mamoe.mirai.console.internal.MiraiConsoleImplementationBridge
+import net.mamoe.mirai.console.internal.pluginManagerImpl
+import net.mamoe.mirai.console.plugin.PluginManager.INSTANCE.description
+import net.mamoe.mirai.utils.debug
+import java.io.File
+import java.io.FileDescriptor
+import java.io.FileOutputStream
+import java.io.PrintStream
+import java.lang.management.ManagementFactory
+import java.lang.reflect.Method
+import java.nio.file.Paths
+import java.time.Instant
+import java.time.ZoneOffset
+import java.util.*
+import java.util.concurrent.ConcurrentLinkedDeque
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.io.path.writeText
+
+internal object ShutdownDaemon {
+    @Suppress("RemoveRedundantQualifierName")
+    internal class DaemonStarter(
+        private val consoleImplementationBridge: MiraiConsoleImplementationBridge
+    ) {
+        private val started = AtomicBoolean(false)
+        fun tryStart() {
+            if (started.compareAndSet(false, true)) {
+                ShutdownDaemon.start(consoleImplementationBridge)
+            }
+        }
+    }
+
+    private object ThreadInfoJava9Access {
+        private val isDaemonM: Method?
+        private val getPriorityM: Method?
+
+        init {
+            var idm: Method? = null
+            var gpm: Method? = null
+            kotlin.runCatching {
+                val klass = Class.forName("java.lang.management.ThreadInfo")
+                val mts = klass.methods.asSequence()
+                idm = mts.firstOrNull { it.name == "isDaemon" }
+                gpm = mts.firstOrNull { it.name == "getPriority" }
+            }
+            isDaemonM = idm
+            getPriorityM = gpm
+        }
+
+        fun isDaemon(inf: Any): Boolean {
+            isDaemonM?.invoke(inf)?.let { return it as Boolean }
+            return false
+        }
+
+        fun getPriority(inf: Any): Int {
+            getPriorityM?.invoke(inf)?.let { return it as Int }
+            return -1
+        }
+
+        val canGetPri: Boolean get() = getPriorityM != null
+    }
+
+    val pluginDisablingThreads = ConcurrentLinkedDeque<Thread>()
+
+
+    private val Thread.State.isWaiting: Boolean
+        get() = this == Thread.State.WAITING || this == Thread.State.TIMED_WAITING
+
+    @OptIn(DelicateCoroutinesApi::class)
+    private fun start(bridge: MiraiConsoleImplementationBridge) {
+        val crtThread = Thread.currentThread()
+        val isConsoleRunning = AtomicBoolean(true)
+        // 1 thread to run main daemon
+        // 1 thread to listen console shutdown running
+        // 1 thread reserved
+        val executor = Executors.newFixedThreadPool(3, object : ThreadFactory {
+            private val counter = AtomicInteger(0)
+            override fun newThread(r: Runnable): Thread {
+                return Thread(r, "Mirai Console Shutdown Daemon #" + counter.getAndIncrement()).also {
+                    it.isDaemon = true
+                }
+            }
+        })
+        executor.execute {
+            listen(crtThread, isConsoleRunning)
+            executor.shutdown()
+        }
+        GlobalScope.launch(executor.asCoroutineDispatcher()) {
+            bridge.coroutineContext.job.join()
+            isConsoleRunning.set(false)
+        }
+        bridge.mainLogger.debug { "SHUTDOWN DAEMON STARTED........." }
+    }
+
+    @Suppress("MemberVisibilityCanBePrivate")
+    fun dumpCrashReport(saveError: Boolean) {
+        val isAndroidSystem = kotlin.runCatching { Class.forName("android.util.Log") }.isSuccess
+        val sb = StringBuilder(1024).append("\n\n")
+        val now = System.currentTimeMillis()
+        sb.append("=============================================================\n")
+        sb.append("MIRAI CONSOLE CRASH REPORT.\n")
+        sb.append("Console has take too long to shutdown.\n\n")
+        sb.append("TIME: ").append(now).append(" <")
+
+        fun msgAfterTimeDump() {
+            sb.append(">\nSYSTEM: ").append(System.getProperty("os.name")).append(" ")
+                .append(System.getProperty("os.arch")).append(" ").append(System.getProperty("os.version"))
+
+            sb.append("\nJRT:\n  ")
+            sb.append(System.getProperty("java.runtime.name"))
+            sb.append("  ").append(System.getProperty("java.version"))
+            sb.append("\n    by ").append(System.getProperty("java.vendor"))
+            sb.append("\nSPEC:\n  ").append(System.getProperty("java.specification.name")).append(" ")
+                .append(System.getProperty("java.specification.version"))
+            sb.append("\n    by ").append(System.getProperty("java.specification.vendor"))
+            sb.append("\nVM:\n  ").append(System.getProperty("java.vm.name")).append(" ")
+                .append(System.getProperty("java.vm.version"))
+            sb.append("\n    by ").append(System.getProperty("java.vm.vendor"))
+
+            sb.append("\n\n")
+            kotlin.runCatching {
+                sb.append("\nPROCESS Working dir: ").append(File("a").absoluteFile.parent ?: File(".").absoluteFile)
+                sb.append("\nConsole Working Dir: ").append(MiraiConsole.rootPath.toAbsolutePath())
+            }
+            sb.append("\nLoaded plugins:\n")
+            kotlin.runCatching {
+                MiraiConsole.pluginManagerImpl.resolvedPlugins.forEach { plugin ->
+                    val desc = plugin.description
+                    sb.append("|- ").append(desc.name).append(" v").append(desc.version).append('\n')
+                    sb.append("|   `- ID: ").append(desc.id).append('\n')
+                    desc.author.takeUnless { it.isBlank() }?.let {
+                        sb.append("|   `- AUTHOR: ").append(it).append('\n')
+                    }
+                    sb.append("|   `- MAIN: ").append(plugin.javaClass).append('\n')
+                    plugin.javaClass.protectionDomain?.codeSource?.location?.let { from ->
+                        @Suppress("IntroduceWhenSubject")
+                        val f: Any = when {
+                            from.protocol == "file" -> Paths.get(from.toURI())
+                            else -> from
+                        }
+                        sb.append("|   `- FROM: ").append(f).append('\n')
+                    }
+                }
+            }
+            sb.append("\n\n\n")
+        }
+
+        if (isAndroidSystem) {
+            sb.append(Date(now))
+            msgAfterTimeDump()
+            sb.append("\n\nTHREADS:\n\n")
+            val threads = Thread.getAllStackTraces()
+            threads.forEach { (thread, stackTrace) ->
+                sb.append("\n\n\n").append(thread).append('\n')
+                stackTrace.forEach { stack ->
+                    sb.append('\t').append(stack).append('\n')
+                }
+            }
+        } else {
+            object { // Android doesn't contain management system & classing boxing
+                fun a() {
+                    sb.append(Instant.ofEpochMilli(now).atOffset(ZoneOffset.UTC))
+                    msgAfterTimeDump()
+
+                    val rtMxBean = ManagementFactory.getRuntimeMXBean()
+                    sb.append("PROCESS: ").append(rtMxBean.name)
+                    sb.append("\nVM OPTIONS:\n")
+                    rtMxBean.inputArguments.forEach { cmd ->
+                        sb.append("  ").append(cmd).append("\n")
+                    }
+                    sb.append("\n\nTHREADS:\n\n")
+
+                    val threadMxBean = ManagementFactory.getThreadMXBean()
+                    val infs = threadMxBean.dumpAllThreads(true, true)
+                    infs.forEach { inf ->
+                        sb.append("\n\n").append('"')
+                        sb.append(inf.threadName)
+                        sb.append('"')
+                        if (ThreadInfoJava9Access.isDaemon(inf)) {
+                            sb.append(" daemon")
+                        }
+                        if (ThreadInfoJava9Access.canGetPri) {
+                            sb.append(" prio=").append(ThreadInfoJava9Access.getPriority(inf))
+                        }
+                        sb.append(" Id=").append(inf.threadId)
+                        inf.lockName?.let { sb.append(" on ").append(it) }
+                        inf.lockOwnerName?.let { lon ->
+                            sb.append(" owned by \"").append(lon)
+                            sb.append("\" Id=").append(inf.lockOwnerId)
+                        }
+                        if (inf.isSuspended) sb.append(" (suspended)")
+                        if (inf.isInNative) sb.append(" (in native)")
+                        sb.append('\n')
+                        val lockInf = inf.lockInfo
+                        val lockedMonitors = inf.lockedMonitors
+
+                        inf.stackTrace.forEachIndexed { index, stackTraceElement ->
+                            sb.append("\tat ").append(stackTraceElement).append('\n')
+                            if (index == 0 && lockInf != null) {
+                                when (inf.threadState!!) {
+                                    Thread.State.BLOCKED -> {
+                                        sb.append("\t-  blocked on ").append(lockInf).append('\n')
+                                    }
+                                    Thread.State.WAITING,
+                                    Thread.State.TIMED_WAITING -> {
+                                        sb.append("\t-  waiting on ").append(lockInf).append('\n')
+                                    }
+                                    else -> {}
+                                }
+                            }
+                            lockedMonitors.forEach { mi ->
+                                if (mi.lockedStackDepth == index) {
+                                    sb.append("\t-  locked ").append(mi).append('\n')
+                                }
+                            }
+                        }
+                        sb.append("\n\n")
+                    }
+                }
+            }.a()
+        }
+        sb.append("\n\n")
+        val report = sb.toString()
+        if (!isAndroidSystem && saveError) {
+            kotlin.runCatching {
+                PrintStream(FileOutputStream(FileDescriptor.err)).println(report)
+            }
+        }
+        if (saveError) {
+            val fileName = "CONSOLE_CRASH_REPORT_${now}.log"
+            kotlin.runCatching {
+                MiraiConsole.rootPath.resolve(fileName).writeText(report)
+            }.recoverCatching {
+                if (!isAndroidSystem) {
+                    File("CONSOLE_CRASH_REPORT_${now}.log").writeText(report)
+                }
+            }
+        }
+        kotlin.runCatching {
+            MiraiConsole.mainLogger.error(report)
+        }
+    }
+
+    private fun listen(thread: Thread, consoleRunning: AtomicBoolean) {
+        val startTime = System.currentTimeMillis()
+        val timeout = 1000L * 60
+        while (consoleRunning.get()) {
+            val crtTime = System.currentTimeMillis()
+            if (crtTime - startTime >= timeout) {
+                kotlin.runCatching {
+                    dumpCrashReport(saveError = true)
+                }
+                pluginDisablingThreads.forEach { threadKill(it) }
+                threadKill(thread)
+                pluginDisablingThreads.clear()
+                return
+            }
+            Thread.sleep(1000)
+
+            // Force intercept known death codes
+            pluginDisablingThreads.forEach { pluginCrtThread ->
+                val stackTraces by lazy { pluginCrtThread.stackTrace }
+
+                if (pluginCrtThread.state.isWaiting) {
+                    /// java.desktop:
+                    //      WToolkit 关闭后执行 java.awt.Window.dispose() 会堵死在 native code
+                    for (i in 0 until stackTraces.size.coerceAtMost(10)) {
+                        val stack = stackTraces[i]
+                        if (stack.className.startsWith("java.awt.") && stack.methodName.contains(
+                                "dispose",
+                                ignoreCase = true
+                            )
+                        ) {
+                            pluginCrtThread.interrupt()
+                            break
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun threadKill(thread: Thread) {
+        thread.interrupt()
+        Thread.sleep(10)
+        if (!thread.isAlive) return
+        Thread.sleep(100)
+        if (!thread.isAlive) return
+        Thread.sleep(500)
+
+        @Suppress("DEPRECATION")
+        if (thread.isAlive) thread.stop()
+    }
+}
\ No newline at end of file
diff --git a/mirai-console/frontend/mirai-console-terminal/src/ConsoleThread.kt b/mirai-console/frontend/mirai-console-terminal/src/ConsoleThread.kt
index 745afba0f..9ad0fe4ad 100644
--- a/mirai-console/frontend/mirai-console-terminal/src/ConsoleThread.kt
+++ b/mirai-console/frontend/mirai-console-terminal/src/ConsoleThread.kt
@@ -69,7 +69,7 @@ internal fun startupConsoleThread() {
             } catch (e: CancellationException) {
                 return@launch
             } catch (e: UserInterruptException) {
-                BuiltInCommands.StopCommand.run { ConsoleCommandSender.handle() }
+                signalHandler("INT")
                 return@launch
             } catch (eof: EndOfFileException) {
                 consoleLogger.warning("Closing input service...")
diff --git a/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleImplementationTerminal.kt b/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleImplementationTerminal.kt
index 7b33400af..48265f24b 100644
--- a/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleImplementationTerminal.kt
+++ b/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleImplementationTerminal.kt
@@ -120,6 +120,7 @@ open class MiraiConsoleImplementationTerminal
         get() = ConsoleTerminalSettings.launchOptions
 
     override fun preStart() {
+        registerSignalHandler()
         overrideSTD(this)
     }
 }
@@ -143,6 +144,7 @@ val terminal: Terminal = run {
         .jansi(true)
         .dumb(true)
         .paused(true)
+        .signalHandler { signalHandler(it.name) }
         .build()
         .let { terminal ->
             if (terminal is AbstractWindowsTerminal) {
diff --git a/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleTerminalLoader.kt b/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleTerminalLoader.kt
index a77b07dab..ccd8ea90d 100644
--- a/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleTerminalLoader.kt
+++ b/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleTerminalLoader.kt
@@ -19,9 +19,7 @@
 
 package net.mamoe.mirai.console.terminal
 
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.*
 import net.mamoe.mirai.console.MiraiConsole
 import net.mamoe.mirai.console.MiraiConsoleImplementation
 import net.mamoe.mirai.console.MiraiConsoleImplementation.Companion.start
@@ -31,9 +29,17 @@ import net.mamoe.mirai.console.util.ConsoleExperimentalApi
 import net.mamoe.mirai.console.util.ConsoleInternalApi
 import net.mamoe.mirai.message.data.Message
 import net.mamoe.mirai.utils.childScope
+import net.mamoe.mirai.utils.debug
+import net.mamoe.mirai.utils.verbose
+import org.jline.utils.Signals
 import java.io.FileDescriptor
 import java.io.FileOutputStream
 import java.io.PrintStream
+import java.lang.Runnable
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
 import kotlin.system.exitProcess
 
 /**
@@ -46,15 +52,18 @@ object MiraiConsoleTerminalLoader {
         startAsDaemon()
         try {
             runBlocking {
-                MiraiConsole.job.invokeOnCompletion {
-                    Thread.sleep(1000) // 保证错误信息打印完全
-                    exitProcess(0)
+                MiraiConsole.job.invokeOnCompletion { err ->
+                    if (err != null) {
+                        Thread.sleep(1000) // 保证错误信息打印完全
+                    }
                 }
                 MiraiConsole.job.join()
             }
         } catch (e: CancellationException) {
             // ignored
         }
+        // Avoid plugin started some non-daemon threads
+        exitProcessAndForceHalt(0)
     }
 
     @ConsoleTerminalExperimentalApi
@@ -171,6 +180,82 @@ internal object ConsoleDataHolder : AutoSavePluginDataHolder,
         get() = "Terminal"
 }
 
+private val shutdownSignals = arrayOf(
+    "INT", "TERM", "QUIT"
+)
+
+internal val signalHandler: (String) -> Unit = initSignalHandler()
+private fun initSignalHandler(): (String) -> Unit {
+    val shutdownMonitorLock = AtomicBoolean(false)
+    return handler@{ signalName ->
+        // JLine may process other signals
+        MiraiConsole.mainLogger.verbose { "Received signal $signalName" }
+        if (signalName !in shutdownSignals) return@handler
+
+        MiraiConsole.mainLogger.debug { "Handled  signal $signalName" }
+        MiraiConsole.shutdown()
+
+        // Shutdown by signal requires process be killed
+        if (shutdownMonitorLock.compareAndSet(false, true)) {
+            val pool = Executors.newFixedThreadPool(2, object : ThreadFactory {
+                private val counter = AtomicInteger()
+                override fun newThread(r: Runnable): Thread {
+                    return Thread(r, "Mirai Console Signal-Shutdown Daemon #" + counter.getAndIncrement()).also {
+                        it.isDaemon = true
+                    }
+                }
+            })
+            @OptIn(DelicateCoroutinesApi::class)
+            GlobalScope.launch(pool.asCoroutineDispatcher()) {
+                MiraiConsole.job.join()
+
+                delay(15000)
+                // Force kill process if plugins started non-daemon threads
+                exitProcessAndForceHalt(-5)
+            }
+        }
+    }
+}
+
+internal fun registerSignalHandler() {
+    fun reg(name: String) {
+        Signals.register(name) { signalHandler(name) }
+    }
+    shutdownSignals.forEach { reg(it) }
+}
+
+internal fun exitProcessAndForceHalt(code: Int): Nothing {
+    MiraiConsole.mainLogger.debug { "[exitProcessAndForceHalt] called with code $code" }
+
+    val exitFuncName = arrayOf("exit", "halt")
+    val shutdownClasses = arrayOf("java.lang.System", "java.lang.Runtime", "java.lang.Shutdown")
+    val isShutdowning = Thread.getAllStackTraces().asSequence().flatMap {
+        it.value.asSequence()
+    }.any { stackTrace ->
+        stackTrace.className in shutdownClasses && stackTrace.methodName in exitFuncName
+    }
+    MiraiConsole.mainLogger.debug { "[exitProcessAndForceHalt] isShutdowning = $isShutdowning" }
+
+    val task = Runnable {
+        Thread.sleep(15000L)
+        runCatching { net.mamoe.mirai.console.internal.shutdown.ShutdownDaemon.dumpCrashReport(true) }
+        val fc = when (code) {
+            0 -> 5784171
+            else -> code
+        }
+
+        MiraiConsole.mainLogger.debug { "[exitProcessAndForceHalt] timed out, force halt with code $fc" }
+        Runtime.getRuntime().halt(fc)
+    }
+    if (isShutdowning) {
+        task.run()
+        error("Runtime.halt returned normally, while it was supposed to halt JVM.")
+    } else {
+        Thread(task, "Mirai Console Force Halt Daemon").start()
+        exitProcess(code)
+    }
+}
+
 internal fun overrideSTD(terminal: MiraiConsoleImplementation) {
     if (ConsoleTerminalSettings.noConsole) {
         SystemOutputPrintStream // Avoid StackOverflowError when launch with no console mode