diff --git a/Partner-Core/dependency-reduced-pom.xml b/Partner-Core/dependency-reduced-pom.xml index e71fa3a4..5e42d6c4 100644 --- a/Partner-Core/dependency-reduced-pom.xml +++ b/Partner-Core/dependency-reduced-pom.xml @@ -1,44 +1,79 @@ - - - Partner - work.slhaf - 0.5.0 - - 4.0.0 - Partner-Main - - - - maven-shade-plugin - - - package - - shade - - - - - work.slhaf.partner.Main - - - - - - - - maven-surefire-plugin - - true - - - - - - 21 - 21 - UTF-8 - + + + partner + work.slhaf.partner + 0.5.0 + + 4.0.0 + partner-core + + + + maven-shade-plugin + + + package + + shade + + + + + work.slhaf.partner.Main + + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + process-sources + + compile + + + + ${project.basedir}/src/main/java + + + + + test-compile + test-compile + + test-compile + + + + ${project.basedir}/src/test/java + + + + + + ${maven.compiler.target} + + + + + + + org.jetbrains.kotlin + kotlin-test + 2.2.0 + test + + + + 21 + 21 + UTF-8 + diff --git a/Partner-Core/src/main/resources/logback.xml b/Partner-Core/src/main/resources/logback.xml index 4d09c395..b27d71d3 100644 --- a/Partner-Core/src/main/resources/logback.xml +++ b/Partner-Core/src/main/resources/logback.xml @@ -1,14 +1,31 @@ - + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - + + ${PARTNER_LOG_DIR}/partner-core.log + true + + + ${PARTNER_LOG_DIR}/partner-core.%d{yyyy-MM-dd}.%i.log.gz + 20MB + 14 + 1GB + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + - - + + - \ No newline at end of file + diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ControlCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ControlCommand.kt deleted file mode 100644 index eb7ff46b..00000000 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ControlCommand.kt +++ /dev/null @@ -1,365 +0,0 @@ -package work.slhaf.partner.ctl.commands - -import picocli.CommandLine -import work.slhaf.partner.ctl.i18n.I18n.text -import work.slhaf.partner.ctl.support.CommandInterrupted -import java.io.InputStream -import java.io.OutputStream -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.nio.file.StandardOpenOption -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlin.concurrent.thread - -@CommandLine.Command(name = "run", description = ["Start Partner agent."]) -class RunCommand : Runnable { - - @CommandLine.Option(names = ["-d", "--background"], description = ["Run Partner in background."]) - var background: Boolean = false - - override fun run() { - val home = resolvePartnerHome() - val partnerJar = resolvePartnerJar(home) - - if (!Files.isRegularFile(partnerJar)) { - throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar)) - } - - if (background) { - runInBackground(home, partnerJar) - } else { - runInForeground(home, partnerJar) - } - } -} - -@CommandLine.Command(name = "shutdown", description = ["Shutdown Partner agent."]) -class ShutdownCommand : Runnable { - - @CommandLine.Option( - names = ["--timeout"], - description = ["Seconds to wait after graceful termination before failing or forcing shutdown."] - ) - var timeoutSeconds: Long = 10 - - @CommandLine.Option( - names = ["-f", "--force"], - description = ["Forcefully kill matching Partner process if it does not exit before timeout."] - ) - var force: Boolean = false - - override fun run() { - val home = resolvePartnerHome() - val partnerJar = resolvePartnerJar(home) - val pidFile = resolvePidFile(home) - val processes = findPartnerProcesses(home, partnerJar) - - if (processes.isEmpty()) { - cleanupStalePidFile(pidFile) - println(text("control.shutdown.info.notRunning", partnerJar)) - return - } - - var failed = false - processes.forEach { process -> - val pid = process.pid() - println(text("control.shutdown.info.stopping", pid)) - process.destroy() - - val stopped = waitForExit(process, timeoutSeconds) - if (stopped) { - println(text("control.shutdown.success.stopped", pid)) - deletePidFileIfMatches(pidFile, pid) - return@forEach - } - - if (force) { - println(text("control.shutdown.warn.force", pid)) - process.destroyForcibly() - if (waitForExit(process, timeoutSeconds)) { - println(text("control.shutdown.success.stopped", pid)) - deletePidFileIfMatches(pidFile, pid) - } else { - failed = true - println(text("control.shutdown.error.notStopped", pid)) - } - } else { - failed = true - println(text("control.shutdown.error.notStoppedUseForce", pid)) - } - } - - if (failed) { - throw CommandInterrupted(text("control.shutdown.error.failed")) - } - } -} - -@CommandLine.Command(name = "log", description = ["Show Partner logs."]) -class LogCommand : Runnable { - - @CommandLine.Option(names = ["--tail"], description = ["Number of log lines to show before exiting or following."]) - var tailLines: Int = 200 - - @CommandLine.Option(names = ["-f", "--follow"], description = ["Follow appended log output."]) - var follow: Boolean = false - - override fun run() { - val home = resolvePartnerHome() - val logFile = resolveLogFile(home) - - if (!Files.exists(logFile)) { - if (!follow) { - println(text("control.log.info.notFound", logFile)) - return - } - println(text("control.log.info.waiting", logFile)) - waitForLogFile(logFile) - } - - printLastLines(logFile, tailLines) - - if (follow) { - followLog(logFile) - } - } -} - -private fun runInForeground(home: Path, partnerJar: Path) { - val logFile = resolveLogFile(home) - Files.createDirectories(logFile.parent) - - val process = createPartnerProcessBuilder(home, partnerJar) - .redirectInput(ProcessBuilder.Redirect.INHERIT) - .redirectErrorStream(true) - .start() - - val shutdownHook = Thread { - if (process.isAlive) { - process.destroy() - } - } - Runtime.getRuntime().addShutdownHook(shutdownHook) - - val logOutput = Files.newOutputStream( - logFile, - StandardOpenOption.CREATE, - StandardOpenOption.APPEND, - ) - - val copier = thread(name = "partnerctl-run-log-tee") { - logOutput.use { output -> - tee(process.inputStream, output) - } - } - - val exitCode = process.waitFor() - copier.join() - runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) } - - if (exitCode != 0) { - throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode) - } -} - -private fun runInBackground(home: Path, partnerJar: Path) { - val pidFile = resolvePidFile(home) - val logFile = resolveLogFile(home) - val existingProcess = findPartnerProcesses(home, partnerJar).firstOrNull() - - if (existingProcess != null) { - throw CommandInterrupted(text("control.run.error.alreadyRunning", existingProcess.pid())) - } - - Files.createDirectories(pidFile.parent) - Files.createDirectories(logFile.parent) - - val process = createPartnerProcessBuilder(home, partnerJar) - .redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile())) - .redirectErrorStream(true) - .start() - - Files.writeString( - pidFile, - process.pid().toString(), - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - ) - - println(text("control.run.success.backgroundStarted", process.pid())) - println(text("control.run.info.logFile", logFile)) -} - -private fun createPartnerProcessBuilder(home: Path, partnerJar: Path): ProcessBuilder { - return ProcessBuilder("java", "-jar", partnerJar.toString()) - .apply { - environment()["PARTNER_HOME"] = home.toString() - } -} - -private fun resolvePartnerHome(): Path { - val home = System.getenv("PARTNER_HOME") - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.let { Paths.get(it) } - ?: Paths.get(System.getProperty("user.home"), ".partner") - - return home.toAbsolutePath().normalize() -} - -private fun resolvePartnerJar(home: Path): Path { - return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize() -} - -private fun resolvePidFile(home: Path): Path { - return home.resolve("state").resolve("run").resolve("partner.pid").toAbsolutePath().normalize() -} - -private fun resolveLogFile(home: Path): Path { - return home.resolve("state").resolve("trace").resolve("log").resolve("partner-core.log").toAbsolutePath().normalize() -} - -private fun findPartnerProcesses(home: Path, partnerJar: Path): List { - val pidFile = resolvePidFile(home) - val pidProcess = readPidProcess(pidFile, partnerJar) - if (pidProcess != null) { - return listOf(pidProcess) - } - - cleanupStalePidFile(pidFile) - return findPartnerProcessesByCommandLine(partnerJar) -} - -private fun readPidProcess(pidFile: Path, partnerJar: Path): ProcessHandle? { - if (!Files.isRegularFile(pidFile)) return null - - val pid = Files.readString(pidFile).trim().toLongOrNull() ?: return null - val process = ProcessHandle.of(pid).orElse(null) ?: return null - - return if (process.isAlive && isPartnerProcess(process, partnerJar)) { - process - } else { - null - } -} - -private fun cleanupStalePidFile(pidFile: Path) { - if (!Files.isRegularFile(pidFile)) return - val pid = Files.readString(pidFile).trim().toLongOrNull() - val process = pid?.let { ProcessHandle.of(it).orElse(null) } - if (process == null || !process.isAlive) { - Files.deleteIfExists(pidFile) - } -} - -private fun deletePidFileIfMatches(pidFile: Path, pid: Long) { - if (!Files.isRegularFile(pidFile)) return - val recordedPid = Files.readString(pidFile).trim().toLongOrNull() - if (recordedPid == pid) { - Files.deleteIfExists(pidFile) - } -} - -private fun findPartnerProcessesByCommandLine(partnerJar: Path): List { - val currentPid = ProcessHandle.current().pid() - return ProcessHandle.allProcesses() - .filter { it.pid() != currentPid } - .filter { it.isAlive } - .filter { isPartnerProcess(it, partnerJar) } - .toList() -} - -private fun isPartnerProcess(process: ProcessHandle, partnerJar: Path): Boolean { - val jarPath = partnerJar.toString() - val info = process.info() - val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray() - val commandLine = info.commandLine().orElse("") ?: "" - - return arguments.any { it == jarPath } || commandLine.contains(jarPath) -} - -private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean { - return try { - process.onExit().get(timeoutSeconds, TimeUnit.SECONDS) - true - } catch (_: TimeoutException) { - false - } -} - -private fun tee(input: InputStream, logOutput: OutputStream) { - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read < 0) break - System.out.write(buffer, 0, read) - System.out.flush() - logOutput.write(buffer, 0, read) - logOutput.flush() - } -} - -private fun printLastLines(logFile: Path, lineCount: Int) { - if (lineCount <= 0) return - - val lines = ArrayDeque() - Files.newBufferedReader(logFile).use { reader -> - while (true) { - val line = reader.readLine() ?: break - if (lines.size == lineCount) { - lines.removeFirst() - } - lines.addLast(line) - } - } - - lines.forEach(::println) -} - -private fun waitForLogFile(logFile: Path) { - while (!Files.exists(logFile)) { - Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) - } -} - -private fun followLog(logFile: Path) { - var position = Files.size(logFile) - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - - while (true) { - if (!Files.exists(logFile)) { - Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) - continue - } - - val size = Files.size(logFile) - if (size < position) { - position = 0 - } - - if (size > position) { - Files.newInputStream(logFile).use { input -> - var skipped = input.skip(position) - while (skipped < position) { - val current = input.skip(position - skipped) - if (current <= 0) break - skipped += current - } - - while (true) { - val read = input.read(buffer) - if (read < 0) break - System.out.write(buffer, 0, read) - System.out.flush() - position += read - } - } - } - - Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) - } -} - -private const val LOG_FOLLOW_INTERVAL_MILLIS = 500L diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/LogCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/LogCommand.kt new file mode 100644 index 00000000..51bc78a3 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/LogCommand.kt @@ -0,0 +1,101 @@ +package work.slhaf.partner.ctl.commands + +import picocli.CommandLine +import work.slhaf.partner.ctl.commands.control.resolveLogFile +import work.slhaf.partner.ctl.commands.control.resolvePartnerHome +import work.slhaf.partner.ctl.i18n.I18n.text +import java.nio.file.Files +import java.nio.file.Path + +@CommandLine.Command(name = "log", description = ["Show Partner logs."]) +class LogCommand : Runnable { + + @CommandLine.Option(names = ["--tail"], description = ["Number of log lines to show before exiting or following."]) + var tailLines: Int = 200 + + @CommandLine.Option(names = ["-f", "--follow"], description = ["Follow appended log output."]) + var follow: Boolean = false + + override fun run() { + val home = resolvePartnerHome() + val logFile = resolveLogFile(home) + + if (!Files.exists(logFile)) { + if (!follow) { + println(text("control.log.info.notFound", logFile)) + return + } + println(text("control.log.info.waiting", logFile)) + waitForLogFile(logFile) + } + + printLastLines(logFile, tailLines) + + if (follow) { + followLog(logFile) + } + } +} + +private fun printLastLines(logFile: Path, lineCount: Int) { + if (lineCount <= 0) return + + val lines = ArrayDeque() + Files.newBufferedReader(logFile).use { reader -> + while (true) { + val line = reader.readLine() ?: break + if (lines.size == lineCount) { + lines.removeFirst() + } + lines.addLast(line) + } + } + + lines.forEach(::println) +} + +private fun waitForLogFile(logFile: Path) { + while (!Files.exists(logFile)) { + Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) + } +} + +private fun followLog(logFile: Path) { + var position = Files.size(logFile) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (true) { + if (!Files.exists(logFile)) { + Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) + continue + } + + val size = Files.size(logFile) + if (size < position) { + position = 0 + } + + if (size > position) { + Files.newInputStream(logFile).use { input -> + var skipped = input.skip(position) + while (skipped < position) { + val current = input.skip(position - skipped) + if (current <= 0) break + skipped += current + } + + while (true) { + val read = input.read(buffer) + if (read < 0) break + System.out.write(buffer, 0, read) + System.out.flush() + position += read + } + } + } + + Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) + } +} + +private const val LOG_FOLLOW_INTERVAL_MILLIS = 500L diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/RunCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/RunCommand.kt new file mode 100644 index 00000000..e215b7d5 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/RunCommand.kt @@ -0,0 +1,122 @@ +package work.slhaf.partner.ctl.commands + +import picocli.CommandLine +import work.slhaf.partner.ctl.commands.control.* +import work.slhaf.partner.ctl.i18n.I18n.text +import work.slhaf.partner.ctl.support.CommandInterrupted +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.time.LocalDateTime + +@CommandLine.Command(name = "run", description = ["Start Partner agent."]) +class RunCommand : Runnable { + + @CommandLine.Option(names = ["-d", "--background"], description = ["Run Partner in background."]) + var background: Boolean = false + + override fun run() { + val home = resolvePartnerHome() + val partnerJar = resolvePartnerJar(home) + + if (!Files.isRegularFile(partnerJar)) { + throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar)) + } + + if (background) { + runInBackground(home, partnerJar) + } else { + runInForeground(home, partnerJar) + } + } +} + +private fun runInForeground(home: Path, partnerJar: Path) { + val logFile = resolveLogFile(home) + Files.createDirectories(logFile.parent) + + val process = createPartnerProcessBuilder(home, partnerJar) + .inheritIO() + .start() + + appendControlLog(logFile, text("control.run.log.foregroundStarting", process.pid(), partnerJar)) + + val shutdownHook = Thread { + if (process.isAlive) { + process.destroy() + } + } + Runtime.getRuntime().addShutdownHook(shutdownHook) + + val exitCode = process.waitFor() + runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) } + appendControlLog(logFile, text("control.run.log.exited", exitCode)) + + if (exitCode != 0) { + throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode) + } +} + +private fun runInBackground(home: Path, partnerJar: Path) { + val pidFile = resolvePidFile(home) + val logFile = resolveLogFile(home) + val existingProcess = findPartnerProcesses(home, partnerJar).firstOrNull() + + if (existingProcess != null) { + throw CommandInterrupted(text("control.run.error.alreadyRunning", existingProcess.pid())) + } + + Files.createDirectories(pidFile.parent) + Files.createDirectories(logFile.parent) + + val process = createPartnerProcessBuilder(home, partnerJar) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + + appendControlLog(logFile, text("control.run.log.backgroundStarting", process.pid(), partnerJar)) + + Thread.sleep(BACKGROUND_START_CHECK_MILLIS) + if (!process.isAlive) { + val exitCode = process.exitValue() + appendControlLog(logFile, text("control.run.log.exited", exitCode)) + Files.deleteIfExists(pidFile) + throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode) + } + + Files.writeString( + pidFile, + process.pid().toString(), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + ) + + println(text("control.run.success.backgroundStarted", process.pid())) + println(text("control.run.info.logFile", logFile)) +} + +private fun createPartnerProcessBuilder(home: Path, partnerJar: Path): ProcessBuilder { + return ProcessBuilder("java", "-DPARTNER_HOME=$home", "-jar", partnerJar.toString()) + .apply { + environment()["PARTNER_HOME"] = home.toString() + } +} + +private fun appendControlLog(logFile: Path, message: String) { + Files.createDirectories(logFile.parent) + Files.newOutputStream( + logFile, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND, + ).use { output -> + writeControlLog(output, message) + } +} + +private fun writeControlLog(output: OutputStream, message: String) { + output.write("[partnerctl ${LocalDateTime.now()}] $message\n".toByteArray()) + output.flush() +} + +private const val BACKGROUND_START_CHECK_MILLIS = 500L diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ShutdownCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ShutdownCommand.kt new file mode 100644 index 00000000..78a45c31 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ShutdownCommand.kt @@ -0,0 +1,68 @@ +package work.slhaf.partner.ctl.commands + +import picocli.CommandLine +import work.slhaf.partner.ctl.commands.control.* +import work.slhaf.partner.ctl.i18n.I18n +import work.slhaf.partner.ctl.support.CommandInterrupted + +@CommandLine.Command(name = "shutdown", description = ["Shutdown Partner agent."]) +class ShutdownCommand : Runnable { + + @CommandLine.Option( + names = ["--timeout"], + description = ["Seconds to wait after graceful termination before failing or forcing shutdown."] + ) + var timeoutSeconds: Long = 10 + + @CommandLine.Option( + names = ["-f", "--force"], + description = ["Forcefully kill matching Partner process if it does not exit before timeout."] + ) + var force: Boolean = false + + override fun run() { + val home = resolvePartnerHome() + val partnerJar = resolvePartnerJar(home) + val pidFile = resolvePidFile(home) + val processes = findPartnerProcesses(home, partnerJar) + + if (processes.isEmpty()) { + cleanupStalePidFile(pidFile) + println(I18n.text("control.shutdown.info.notRunning", partnerJar)) + return + } + + var failed = false + processes.forEach { process -> + val pid = process.pid() + println(I18n.text("control.shutdown.info.stopping", pid)) + process.destroy() + + val stopped = waitForExit(process, timeoutSeconds) + if (stopped) { + println(I18n.text("control.shutdown.success.stopped", pid)) + deletePidFileIfMatches(pidFile, pid) + return@forEach + } + + if (force) { + println(I18n.text("control.shutdown.warn.force", pid)) + process.destroyForcibly() + if (waitForExit(process, timeoutSeconds)) { + println(I18n.text("control.shutdown.success.stopped", pid)) + deletePidFileIfMatches(pidFile, pid) + } else { + failed = true + println(I18n.text("control.shutdown.error.notStopped", pid)) + } + } else { + failed = true + println(I18n.text("control.shutdown.error.notStoppedUseForce", pid)) + } + } + + if (failed) { + throw CommandInterrupted(I18n.text("control.shutdown.error.failed")) + } + } +} \ No newline at end of file diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/control/PartnerRuntime.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/control/PartnerRuntime.kt new file mode 100644 index 00000000..e6351862 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/control/PartnerRuntime.kt @@ -0,0 +1,97 @@ +package work.slhaf.partner.ctl.commands.control + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +fun resolvePartnerHome(): Path { + val home = System.getenv("PARTNER_HOME") + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { Paths.get(it) } + ?: Paths.get(System.getProperty("user.home"), ".partner") + + return home.toAbsolutePath().normalize() +} + +fun resolvePartnerJar(home: Path): Path { + return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize() +} + +fun resolvePidFile(home: Path): Path { + return home.resolve("state").resolve("run").resolve("partner.pid").toAbsolutePath().normalize() +} + +fun resolveLogFile(home: Path): Path { + return home.resolve("state").resolve("trace").resolve("log").resolve("partner-core.log").toAbsolutePath().normalize() +} + +fun findPartnerProcesses(home: Path, partnerJar: Path): List { + val pidFile = resolvePidFile(home) + val pidProcess = readPidProcess(pidFile, partnerJar) + if (pidProcess != null) { + return listOf(pidProcess) + } + + cleanupStalePidFile(pidFile) + return findPartnerProcessesByCommandLine(partnerJar) +} + +fun cleanupStalePidFile(pidFile: Path) { + if (!Files.isRegularFile(pidFile)) return + val pid = Files.readString(pidFile).trim().toLongOrNull() + val process = pid?.let { ProcessHandle.of(it).orElse(null) } + if (process == null || !process.isAlive) { + Files.deleteIfExists(pidFile) + } +} + +fun deletePidFileIfMatches(pidFile: Path, pid: Long) { + if (!Files.isRegularFile(pidFile)) return + val recordedPid = Files.readString(pidFile).trim().toLongOrNull() + if (recordedPid == pid) { + Files.deleteIfExists(pidFile) + } +} + +fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean { + return try { + process.onExit().get(timeoutSeconds, TimeUnit.SECONDS) + true + } catch (_: TimeoutException) { + false + } +} + +private fun readPidProcess(pidFile: Path, partnerJar: Path): ProcessHandle? { + if (!Files.isRegularFile(pidFile)) return null + + val pid = Files.readString(pidFile).trim().toLongOrNull() ?: return null + val process = ProcessHandle.of(pid).orElse(null) ?: return null + + return if (process.isAlive && isPartnerProcess(process, partnerJar)) { + process + } else { + null + } +} + +private fun findPartnerProcessesByCommandLine(partnerJar: Path): List { + val currentPid = ProcessHandle.current().pid() + return ProcessHandle.allProcesses() + .filter { it.pid() != currentPid } + .filter { it.isAlive } + .filter { isPartnerProcess(it, partnerJar) } + .toList() +} + +private fun isPartnerProcess(process: ProcessHandle, partnerJar: Path): Boolean { + val jarPath = partnerJar.toString() + val info = process.info() + val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray() + val commandLine = info.commandLine().orElse("") ?: "" + + return arguments.any { it == jarPath } || commandLine.contains(jarPath) +} diff --git a/PartnerCtl/src/main/resources/i18n/messages.properties b/PartnerCtl/src/main/resources/i18n/messages.properties index 98b51daf..dc09c643 100644 --- a/PartnerCtl/src/main/resources/i18n/messages.properties +++ b/PartnerCtl/src/main/resources/i18n/messages.properties @@ -95,6 +95,9 @@ control.run.error.exited=Partner exited with code {0} control.run.error.alreadyRunning=Partner is already running. pid={0} control.run.success.backgroundStarted=Partner started in background. pid={0} control.run.info.logFile=Log file: {0} +control.run.log.foregroundStarting=Starting Partner in foreground. pid={0}, jar={1} +control.run.log.backgroundStarting=Starting Partner in background. pid={0}, jar={1} +control.run.log.exited=Partner process exited with code {0} control.log.info.notFound=Partner log file does not exist: {0} control.log.info.waiting=Waiting for Partner log file: {0} control.shutdown.info.notRunning=No running Partner process found for {0} diff --git a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties index 7adce33b..771f084f 100644 --- a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties +++ b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties @@ -95,6 +95,9 @@ control.run.error.exited=Partner 退出,退出码:{0} control.run.error.alreadyRunning=Partner 已经在运行。pid={0} control.run.success.backgroundStarted=Partner 已在后台启动。pid={0} control.run.info.logFile=日志文件:{0} +control.run.log.foregroundStarting=正在前台启动 Partner。pid={0},jar={1} +control.run.log.backgroundStarting=正在后台启动 Partner。pid={0},jar={1} +control.run.log.exited=Partner 进程已退出,退出码:{0} control.log.info.notFound=Partner 日志文件不存在:{0} control.log.info.waiting=正在等待 Partner 日志文件:{0} control.shutdown.info.notRunning=没有找到对应 {0} 的运行中 Partner 进程