refactor(partnerctl): split control commands into dedicated classes and extract shared runtime utilities

This commit is contained in:
2026-05-06 13:26:46 +08:00
parent 8398c14794
commit ffc96bbb64
9 changed files with 493 additions and 412 deletions

View File

@@ -1,44 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>Partner</artifactId>
<groupId>work.slhaf</groupId>
<version>0.5.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>Partner-Main</artifactId>
<build>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer>
<mainClass>work.slhaf.partner.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
</plugins>
</build>
<properties>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.source>21</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>partner</artifactId>
<groupId>work.slhaf.partner</groupId>
<version>0.5.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>partner-core</artifactId>
<build>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer>
<mainClass>work.slhaf.partner.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<jvmTarget>${maven.compiler.target}</jvmTarget>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test</artifactId>
<version>2.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<properties>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.source>21</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

View File

@@ -1,14 +1,31 @@
<configuration>
<!-- 新增控制台输出 -->
<property name="PARTNER_HOME" value="${PARTNER_HOME:-${user.home}/.partner}"/>
<property name="PARTNER_LOG_DIR" value="${PARTNER_HOME}/state/trace/log"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 同时输出到文件和控制台 -->
<appender name="PARTNER_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${PARTNER_LOG_DIR}/partner-core.log</file>
<append>true</append>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${PARTNER_LOG_DIR}/partner-core.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>20MB</maxFileSize>
<maxHistory>14</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="ROLLING_FILE"/>
<appender-ref ref="CONSOLE"/> <!-- 关键:添加这一行 -->
<appender-ref ref="CONSOLE"/>
<appender-ref ref="PARTNER_FILE"/>
</root>
</configuration>
</configuration>

View File

@@ -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<ProcessHandle> {
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<ProcessHandle> {
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<String>()
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

View File

@@ -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<String>()
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

View File

@@ -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

View File

@@ -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"))
}
}
}

View File

@@ -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<ProcessHandle> {
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<ProcessHandle> {
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)
}

View File

@@ -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}

View File

@@ -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 进程