fix(openclaw): harden reply listener process lifecycle

This commit is contained in:
GeonWoo Jeon (Jay)
2026-04-07 22:49:37 +09:00
parent 6c2bed2f58
commit 92b59b1afd
3 changed files with 158 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import {
appendFileSync,
chmodSync,
existsSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "fs"
import {
ensureReplyListenerStateDir,
REPLY_LISTENER_SECURE_FILE_MODE,
getReplyListenerLogFilePath,
} from "./reply-listener-paths"
const MAX_REPLY_LISTENER_LOG_SIZE_BYTES = 1024 * 1024
export function writeSecureReplyListenerFile(filePath: string, content: string): void {
ensureReplyListenerStateDir()
writeFileSync(filePath, content, { mode: REPLY_LISTENER_SECURE_FILE_MODE })
try {
chmodSync(filePath, REPLY_LISTENER_SECURE_FILE_MODE)
} catch {
}
}
function rotateReplyListenerLogIfNeeded(logPath: string): void {
try {
if (!existsSync(logPath)) return
const stats = statSync(logPath)
if (stats.size <= MAX_REPLY_LISTENER_LOG_SIZE_BYTES) return
const backupPath = `${logPath}.old`
if (existsSync(backupPath)) {
unlinkSync(backupPath)
}
renameSync(logPath, backupPath)
} catch {
}
}
export function logReplyListenerMessage(message: string): void {
try {
ensureReplyListenerStateDir()
const logFilePath = getReplyListenerLogFilePath()
rotateReplyListenerLogIfNeeded(logFilePath)
const timestamp = new Date().toISOString()
appendFileSync(logFilePath, `[${timestamp}] ${message}\n`, {
mode: REPLY_LISTENER_SECURE_FILE_MODE,
})
} catch {
}
}
+78
View File
@@ -0,0 +1,78 @@
import { readFileSync } from "fs"
import { spawn } from "bun"
export const REPLY_LISTENER_DAEMON_IDENTITY_MARKER = "--openclaw-reply-listener-daemon"
const REPLY_LISTENER_DAEMON_ENV_ALLOWLIST = [
"PATH",
"HOME",
"USERPROFILE",
"USER",
"USERNAME",
"LOGNAME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TMUX",
"TMUX_PANE",
"TMPDIR",
"TMP",
"TEMP",
"XDG_RUNTIME_DIR",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
"SHELL",
"NODE_ENV",
"HTTP_PROXY",
"HTTPS_PROXY",
"http_proxy",
"https_proxy",
"NO_PROXY",
"no_proxy",
"SystemRoot",
"SYSTEMROOT",
"windir",
"COMSPEC",
] as const
export function createReplyListenerDaemonEnv(extraEnv: Record<string, string>): Record<string, string> {
const env: Record<string, string> = {}
for (const key of REPLY_LISTENER_DAEMON_ENV_ALLOWLIST) {
const value = process.env[key]
if (value !== undefined) {
env[key] = value
}
}
return { ...env, ...extraEnv }
}
export function isReplyListenerProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
export async function isReplyListenerDaemonProcess(pid: number): Promise<boolean> {
try {
if (process.platform === "linux") {
const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf-8")
return cmdline.includes(REPLY_LISTENER_DAEMON_IDENTITY_MARKER)
}
const processInfo = spawn(["ps", "-p", String(pid), "-o", "args="], {
stdout: "pipe",
stderr: "ignore",
})
const stdout = await new Response(processInfo.stdout).text()
if (processInfo.exitCode !== 0) return false
return stdout.includes(REPLY_LISTENER_DAEMON_IDENTITY_MARKER)
} catch {
return false
}
}
+25
View File
@@ -0,0 +1,25 @@
import { spawn } from "bun"
import {
createReplyListenerDaemonEnv,
REPLY_LISTENER_DAEMON_IDENTITY_MARKER,
} from "./reply-listener-process"
import { REPLY_LISTENER_STARTUP_TOKEN_ENV } from "./reply-listener-state"
export interface ReplyListenerSpawnProcess {
pid: number | undefined
unref(): void
}
export function spawnReplyListenerDaemon(
daemonScript: string,
startupToken: string,
): ReplyListenerSpawnProcess {
return spawn(["bun", "run", daemonScript, REPLY_LISTENER_DAEMON_IDENTITY_MARKER], {
detached: true,
stdio: ["ignore", "ignore", "ignore"],
cwd: process.cwd(),
env: createReplyListenerDaemonEnv({
[REPLY_LISTENER_STARTUP_TOKEN_ENV]: startupToken,
}),
})
}