diff --git a/macos/CHANGELOG.md b/macos/CHANGELOG.md index 349b5101..75adec94 100644 --- a/macos/CHANGELOG.md +++ b/macos/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 未发布 + +### 修复 + +- 修复关闭 ChatGPT 或 CDP 断开后 injector watcher 仍常驻并高频重试的问题(#218):watcher 现在绑定启动时确认的 ChatGPT 主进程,主进程退出后立即停止;CDP 失败重试采用最长 30 秒的可中断指数退避,并将重复错误日志节流到 30 秒。后台任务改为 `KeepAlive=false` 的一次性 LaunchAgent,同时清理旧版 `launchctl submit` 作业,避免 watcher 正常退出后被 launchd 反复拉起。 + ## 1.5.5 — 2026-07-25 ### 修复 diff --git a/macos/scripts/common-macos.sh b/macos/scripts/common-macos.sh index 32a58da2..7a6c6ce2 100755 --- a/macos/scripts/common-macos.sh +++ b/macos/scripts/common-macos.sh @@ -350,6 +350,21 @@ codex_is_running() { [ -n "$(codex_main_pids)" ] } +wait_for_codex_main_pid() { + local timeout_seconds="${1:-10}" + local deadline=$((SECONDS + timeout_seconds)) + local pid="" + while [ "$SECONDS" -lt "$deadline" ]; do + pid="$(codex_main_pids 2>/dev/null | /usr/bin/head -n 1)" + if [ -n "$pid" ]; then + printf '%s\n' "$pid" + return 0 + fi + /bin/sleep 0.1 + done + return 1 +} + active_theme_appearance() { "$NODE" -e ' const fs = require("node:fs"); @@ -636,8 +651,66 @@ mark_state_stale() { ' "$STATE_PATH" } +release_injector_launchd_job() { + local service_target="gui/$(/usr/bin/id -u)/$INJECTOR_JOB_LABEL" + /bin/launchctl bootout "$service_target" >/dev/null 2>&1 || true + # Remove jobs left behind by older builds that used launchctl submit. + /bin/launchctl remove "$INJECTOR_JOB_LABEL" >/dev/null 2>&1 || true +} + +write_injector_launch_agent() { + local port="$1" + local host_pid="$2" + local job_plist="$STATE_ROOT/$INJECTOR_JOB_LABEL.plist" + local temporary="" + local argument="" + local arguments=( + "$NODE" + "$INJECTOR" + "--watch" + "--port" + "$port" + "--theme-dir" + "$THEME_DIR" + "--operation-state" + "$OPERATION_STATE_PATH" + "--operation-ack" + "$OPERATION_ACK_PATH" + "--host-pid" + "$host_pid" + ) + + temporary="$(/usr/bin/mktemp "$STATE_ROOT/.injector-launch-agent.XXXXXX")" || return 1 + if ! /usr/bin/plutil -create xml1 "$temporary" \ + || ! /usr/bin/plutil -insert Label -string "$INJECTOR_JOB_LABEL" "$temporary" \ + || ! /usr/bin/plutil -insert ProgramArguments -array "$temporary"; then + /bin/rm -f "$temporary" + return 1 + fi + for argument in "${arguments[@]}"; do + if ! /usr/bin/plutil -insert ProgramArguments -string "$argument" -append "$temporary"; then + /bin/rm -f "$temporary" + return 1 + fi + done + if ! /usr/bin/plutil -insert RunAtLoad -bool true "$temporary" \ + || ! /usr/bin/plutil -insert KeepAlive -bool false "$temporary" \ + || ! /usr/bin/plutil -insert ProcessType -string Background "$temporary" \ + || ! /usr/bin/plutil -insert StandardOutPath -string "$INJECTOR_LOG" "$temporary" \ + || ! /usr/bin/plutil -insert StandardErrorPath -string "$INJECTOR_ERROR_LOG" "$temporary" \ + || ! /bin/chmod 600 "$temporary" \ + || ! /bin/mv -f "$temporary" "$job_plist"; then + /bin/rm -f "$temporary" + return 1 + fi + printf '%s\n' "$job_plist" +} + stop_recorded_injector() { - [ -f "$STATE_PATH" ] || return 0 + if [ ! -f "$STATE_PATH" ]; then + release_injector_launchd_job + return 0 + fi local pid local saved_port local saved_start @@ -649,7 +722,7 @@ stop_recorded_injector() { fi # Already paused / no daemon if [ "$pid" = "0" ]; then - /bin/launchctl remove "$INJECTOR_JOB_LABEL" >/dev/null 2>&1 || true + release_injector_launchd_job return 0 fi case "$pid" in @@ -660,7 +733,7 @@ stop_recorded_injector() { esac while [ "${pid#0}" != "$pid" ]; do pid="${pid#0}"; done if [ -z "$pid" ]; then - /bin/launchctl remove "$INJECTOR_JOB_LABEL" >/dev/null 2>&1 || true + release_injector_launchd_job return 0 fi @@ -687,7 +760,7 @@ stop_recorded_injector() { return 1 fi /bin/kill -0 "$pid" 2>/dev/null || { - /bin/launchctl remove "$INJECTOR_JOB_LABEL" >/dev/null 2>&1 || true + release_injector_launchd_job return 0 } if ! recorded_injector_process_matches "$pid" "$saved_start" "$saved_node" "$saved_injector" "$saved_port"; then @@ -695,13 +768,13 @@ stop_recorded_injector() { # identity check. A dead (or already reaped) recorded PID is safe to # forget; a live PID with mismatched identity is never signalled. if ! /bin/kill -0 "$pid" 2>/dev/null || [ -z "$(/bin/ps -p "$pid" -o command= 2>/dev/null || true)" ]; then - /bin/launchctl remove "$INJECTOR_JOB_LABEL" >/dev/null 2>&1 || true + release_injector_launchd_job return 0 fi printf 'Recorded injector PID %s is live but its identity does not match; refusing to signal it.\n' "$pid" >&2 return 1 fi - /bin/launchctl remove "$INJECTOR_JOB_LABEL" >/dev/null 2>&1 || true + release_injector_launchd_job /bin/kill -TERM "$pid" 2>/dev/null || true local deadline=$((SECONDS + 6)) while recorded_injector_process_matches "$pid" "$saved_start" "$saved_node" "$saved_injector" "$saved_port" \ @@ -725,20 +798,25 @@ stop_recorded_injector() { launch_injector_daemon() { local port="$1" + local host_pid="$2" local pid="" + local job_plist="" + local launchd_domain="gui/$(/usr/bin/id -u)" local deadline=$((SECONDS + 10)) + case "$host_pid" in ''|*[!0-9]*) fail "Invalid Codex host PID: $host_pid" ;; esac + [ "$host_pid" -gt 1 ] 2>/dev/null && /bin/kill -0 "$host_pid" 2>/dev/null \ + || fail "Codex host process is not running: $host_pid" : > "$INJECTOR_LOG" : > "$INJECTOR_ERROR_LOG" - /bin/launchctl remove "$INJECTOR_JOB_LABEL" >/dev/null 2>&1 || true + release_injector_launchd_job # SwiftBar may terminate background children when a click action finishes. - # A submitted user job owns the watcher independently of that action. - if /bin/launchctl submit -l "$INJECTOR_JOB_LABEL" -o "$INJECTOR_LOG" -e "$INJECTOR_ERROR_LOG" -- \ - "$NODE" "$INJECTOR" --watch --port "$port" --theme-dir "$THEME_DIR" \ - --operation-state "$OPERATION_STATE_PATH" --operation-ack "$OPERATION_ACK_PATH" \ - >/dev/null 2>&1; then + # A one-shot LaunchAgent owns the watcher without launchctl submit's inferred + # KeepAlive behavior, so a clean host-bound exit is not relaunched forever. + if job_plist="$(write_injector_launch_agent "$port" "$host_pid")" \ + && /bin/launchctl bootstrap "$launchd_domain" "$job_plist" >/dev/null 2>&1; then while [ "$SECONDS" -lt "$deadline" ]; do - pid="$(/bin/launchctl print "gui/$(/usr/bin/id -u)/$INJECTOR_JOB_LABEL" 2>/dev/null \ + pid="$(/bin/launchctl print "$launchd_domain/$INJECTOR_JOB_LABEL" 2>/dev/null \ | /usr/bin/awk '/^[[:space:]]*pid = [0-9]+/{print $3; exit}')" if [ -n "$pid" ] && /bin/kill -0 "$pid" 2>/dev/null; then printf '%s\n' "$pid" @@ -746,12 +824,13 @@ launch_injector_daemon() { fi /bin/sleep 0.2 done - /bin/launchctl remove "$INJECTOR_JOB_LABEL" >/dev/null 2>&1 || true + release_injector_launchd_job fi - # Fallback for systems where launchctl submit is unavailable. + # Fallback when the one-shot LaunchAgent cannot be created or bootstrapped. /usr/bin/nohup "$NODE" "$INJECTOR" --watch --port "$port" --theme-dir "$THEME_DIR" \ --operation-state "$OPERATION_STATE_PATH" --operation-ack "$OPERATION_ACK_PATH" \ + --host-pid "$host_pid" \ >>"$INJECTOR_LOG" 2>>"$INJECTOR_ERROR_LOG" & pid="$!" /bin/sleep 0.15 @@ -792,6 +871,8 @@ hot_reapply_theme() { # endpoint already verified as belonging to the official Codex process. ensure_node_runtime || return 1 verified_cdp_endpoint "$port" || return 1 + codex_pid="$(codex_main_pids 2>/dev/null | /usr/bin/head -n 1)" + [ -n "$codex_pid" ] || return 1 [ -n "$operation_token" ] || operation_token="$(new_operation_token)" write_operation_state applying "正在应用已选主题" "$operation_token" || return 1 operation_args=(--operation-token "$operation_token") @@ -799,8 +880,13 @@ hot_reapply_theme() { injector_protocol="$(state_field injectorProtocol 2>/dev/null || true)" injector_mode="$(state_field injectorMode 2>/dev/null || true)" if [ "$injector_protocol" = "2" ] || [ "$injector_protocol" = "3" ]; then - inj_pid="$(/bin/ps -axo pid=,command= | /usr/bin/awk -v inj="$INJECTOR" -v port="$port" ' - index($0, inj) && index($0, "--watch") && index($0, "--port " port " --theme-dir ") { print $1; exit } + inj_pid="$(/bin/ps -axo pid=,command= | /usr/bin/awk \ + -v inj="$INJECTOR" -v port="$port" -v host="$codex_pid" ' + index($0, inj) && index($0, "--watch") && index($0, "--port " port " --theme-dir ") { + for (i = 2; i < NF; i += 1) { + if ($i == "--host-pid" && $(i + 1) == host) { print $1; exit } + } + } ')" fi if ! "$NODE" "$INJECTOR" --once --port "$port" --theme-dir "$THEME_DIR" \ @@ -816,10 +902,9 @@ hot_reapply_theme() { return 0 fi stop_recorded_injector 2>/dev/null || return 1 - inj_pid="$(launch_injector_daemon "$port")" + inj_pid="$(launch_injector_daemon "$port" "$codex_pid")" /bin/kill -0 "$inj_pid" 2>/dev/null || return 1 started_at="$(process_started_at "$inj_pid")" - codex_pid="$(codex_main_pids 2>/dev/null | /usr/bin/head -n 1)" [ -n "$started_at" ] || started_at="$(/bin/date)" write_state "$port" "$inj_pid" "$started_at" "${codex_pid:-0}" active write_operation_state success "皮肤已应用" "$operation_token" || return 1 diff --git a/macos/scripts/injector.mjs b/macos/scripts/injector.mjs index 924fe814..6ce71a45 100644 --- a/macos/scripts/injector.mjs +++ b/macos/scripts/injector.mjs @@ -51,6 +51,9 @@ const OPERATION_UI_HOST_ID = "chatgpt-dream-skin-operation"; const OPERATION_UI_REGISTRY_KEY = "__CHATGPT_DREAM_SKIN_OPERATION_UI__"; const OPERATION_KINDS = new Set(["apply", "pause", "switch"]); const OPERATION_UI_STATES = new Set(["success", "error", "cancelled"]); +const DISCOVERY_RETRY_INITIAL_MS = 250; +const DISCOVERY_RETRY_MAX_MS = 30000; +const DISCOVERY_ERROR_LOG_INTERVAL_MS = 30000; const MIN_RENDERER_WIDTH = 320; const MIN_RENDERER_HEIGHT = 240; const MAX_RENDERER_DIMENSION = 65536; @@ -164,6 +167,73 @@ const OPERATION_UI_CSS = ` let staticPayloadAssets = null; let operationSequence = 0; +export function processIsAlive(pid) { + if (!Number.isSafeInteger(pid) || pid <= 1 || pid > 2147483647) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +export function nextDiscoveryDelay(currentDelayMs) { + return Math.min( + DISCOVERY_RETRY_MAX_MS, + Math.max(DISCOVERY_RETRY_INITIAL_MS, Math.round(currentDelayMs * 2)), + ); +} + +export function createLogThrottle(intervalMs, now = Date.now) { + const lastLogAt = new Map(); + return (key) => { + const currentTime = now(); + const previousTime = lastLogAt.get(key); + if (previousTime !== undefined && currentTime - previousTime < intervalMs) return false; + lastLogAt.set(key, currentTime); + return true; + }; +} + +function abortableSleep(delayMs, signal) { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(false); + return; + } + let timer; + const finish = (completed) => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolve(completed); + }; + const onAbort = () => finish(false); + timer = setTimeout(() => finish(true), delayMs); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function waitForWatchRetry(delayMs, { + hostPid, + signal = null, + isAlive = processIsAlive, + pollIntervalMs = 500, +} = {}) { + const deadline = Date.now() + delayMs; + while (Date.now() < deadline) { + if (signal?.aborted) return "stopped"; + if (!isAlive(hostPid)) return "host-exited"; + const remainingMs = deadline - Date.now(); + const completed = await abortableSleep( + Math.min(remainingMs, Math.max(1, pollIntervalMs)), + signal, + ); + if (!completed) return "stopped"; + } + if (!isAlive(hostPid)) return "host-exited"; + return signal?.aborted ? "stopped" : "elapsed"; +} + function hasReasonableDimensions(width, height) { return Number.isFinite(width) && Number.isFinite(height) && width >= MIN_RENDERER_WIDTH && height >= MIN_RENDERER_HEIGHT @@ -272,7 +342,7 @@ export function assessRendererVerification(renderer, nativeWindow, expected) { return result; } -function parseArgs(argv) { +export function parseArgs(argv) { const options = { port: 9341, mode: "watch", @@ -286,6 +356,7 @@ function parseArgs(argv) { operationUiState: null, operationMessage: null, operationToken: null, + hostPid: null, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; @@ -306,6 +377,7 @@ function parseArgs(argv) { else if (arg === "--operation-ui-state") options.operationUiState = argv[++i]; else if (arg === "--operation-message") options.operationMessage = argv[++i]; else if (arg === "--operation-token") options.operationToken = argv[++i]; + else if (arg === "--host-pid") options.hostPid = Number(argv[++i]); else if (arg === "--reload") options.reload = true; else throw new Error(`Unknown argument: ${arg}`); } @@ -315,6 +387,10 @@ function parseArgs(argv) { if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 250 || options.timeoutMs > 120000) { throw new Error(`Invalid timeout: ${options.timeoutMs}`); } + if (options.mode === "watch" + && (!Number.isSafeInteger(options.hostPid) || options.hostPid <= 1 || options.hostPid > 2147483647)) { + throw new Error(`Invalid or missing watcher host PID: ${options.hostPid}`); + } if (options.operationToken !== null && !/^\d{1,12}:\d{13}:\d{1,8}$/.test(options.operationToken)) { throw new Error("Invalid operation token"); } @@ -1505,8 +1581,9 @@ async function runWatch(options) { let stopping = false; let reloadTimer = null; let reloadChain = Promise.resolve(); - let discoveryDelayMs = 100; - let lastListErrorAt = 0; + let discoveryDelayMs = DISCOVERY_RETRY_INITIAL_MS; + const shouldLogListError = createLogThrottle(DISCOVERY_ERROR_LOG_INTERVAL_MS); + const retryController = new AbortController(); let operationSignalChain = Promise.resolve(); let activeOperation = null; let pauseRecovery = null; @@ -1534,6 +1611,7 @@ async function runWatch(options) { }); const stop = () => { stopping = true; + retryController.abort(); wakeControlLoop(); }; process.on("SIGINT", stop); @@ -1796,6 +1874,10 @@ async function runWatch(options) { try { while (!stopping) { + if (!processIsAlive(options.hostPid)) { + console.log(`[dream-skin] host process ${options.hostPid} exited; stopping watcher`); + break; + } if (activeOperation && !isFreshBusyOperation(activeOperation)) { const expiredOperation = activeOperation; activeOperation = null; @@ -1822,14 +1904,21 @@ async function runWatch(options) { let targets = []; try { targets = await listAppTargets(options.port); - discoveryDelayMs = 100; + discoveryDelayMs = DISCOVERY_RETRY_INITIAL_MS; } catch (error) { - if (Date.now() - lastListErrorAt >= 2000) { + if (shouldLogListError(error.message)) { console.error(`[dream-skin] ${new Date().toISOString()} ${error.message}`); - lastListErrorAt = Date.now(); } - await new Promise((resolve) => setTimeout(resolve, discoveryDelayMs)); - discoveryDelayMs = Math.min(500, Math.round(discoveryDelayMs * 1.6)); + const retryResult = await waitForWatchRetry(discoveryDelayMs, { + hostPid: options.hostPid, + signal: retryController.signal, + }); + if (retryResult === "stopped") break; + if (retryResult === "host-exited") { + console.log(`[dream-skin] host process ${options.hostPid} exited; stopping watcher`); + break; + } + discoveryDelayMs = nextDiscoveryDelay(discoveryDelayMs); continue; } @@ -2011,6 +2100,8 @@ async function runWatch(options) { await new Promise((resolve) => setTimeout(resolve, pollDelay)); } } finally { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); if (reloadTimer) clearTimeout(reloadTimer); closePayloadWatchers(); closeOperationWatcher(); diff --git a/macos/scripts/start-dream-skin-macos.sh b/macos/scripts/start-dream-skin-macos.sh index 35f76142..0d8a8e8d 100755 --- a/macos/scripts/start-dream-skin-macos.sh +++ b/macos/scripts/start-dream-skin-macos.sh @@ -99,6 +99,7 @@ if [ -f "$STATE_PATH" ]; then fi INJECTOR_PID="" +CODEX_PID="" if [ "$DEBUG_READY" = "false" ]; then # Codex is closed on this path (never started, or stopped just above), so it # is safe to sync the appearanceTheme pin to the staged theme before launch. @@ -108,11 +109,18 @@ if [ "$DEBUG_READY" = "false" ]; then PORT="$(select_available_port "$PORT")" printf 'Launching ChatGPT with skin debug port %s…\n' "$PORT" >&2 launch_codex_with_cdp "$PORT" +fi + +CODEX_PID="$(wait_for_codex_main_pid 10)" \ + || fail "Could not identify the Codex host process for the injector watcher." + +if [ "$DEBUG_READY" = "false" ]; then # Start probing immediately instead of waiting for the native window to finish loading. if [ "$FOREGROUND_INJECTOR" != "true" ]; then - INJECTOR_PID="$(launch_injector_daemon "$PORT")" + INJECTOR_PID="$(launch_injector_daemon "$PORT" "$CODEX_PID")" fi if ! wait_for_cdp "$PORT"; then + release_injector_launchd_job [ -z "$INJECTOR_PID" ] || /bin/kill -TERM "$INJECTOR_PID" 2>/dev/null || true fail "ChatGPT did not expose a verified loopback CDP endpoint on port $PORT within 45 seconds. See $APP_LOG and $APP_ERROR_LOG" fi @@ -125,17 +133,17 @@ activate_codex_window if [ "$FOREGROUND_INJECTOR" = "true" ]; then exec "$NODE" "$INJECTOR" --watch --port "$PORT" --theme-dir "$THEME_DIR" \ - --operation-state "$OPERATION_STATE_PATH" --operation-ack "$OPERATION_ACK_PATH" + --operation-state "$OPERATION_STATE_PATH" --operation-ack "$OPERATION_ACK_PATH" \ + --host-pid "$CODEX_PID" fi if [ -z "$INJECTOR_PID" ]; then - INJECTOR_PID="$(launch_injector_daemon "$PORT")" + INJECTOR_PID="$(launch_injector_daemon "$PORT" "$CODEX_PID")" fi /bin/sleep 0.15 /bin/kill -0 "$INJECTOR_PID" 2>/dev/null || fail "The injector exited during startup. See $INJECTOR_ERROR_LOG" INJECTOR_STARTED_AT="$(process_started_at "$INJECTOR_PID")" [ -n "$INJECTOR_STARTED_AT" ] || fail "Could not record the injector process start time." -CODEX_PID="$(codex_main_pids | /usr/bin/head -n 1)" write_state "$PORT" "$INJECTOR_PID" "$INJECTOR_STARTED_AT" "$CODEX_PID" # Commit active only after the renderer, exact theme, and payload revision verify. diff --git a/macos/scripts/status-dream-skin-macos.sh b/macos/scripts/status-dream-skin-macos.sh index 3db90174..0f3d0a7a 100755 --- a/macos/scripts/status-dream-skin-macos.sh +++ b/macos/scripts/status-dream-skin-macos.sh @@ -87,9 +87,10 @@ injector_identity_matches() { [ -n "$actual_start" ] && [ "$actual_start" = "$expected_start" ] } -# Codex process: cheap name match only. 26.707 renamed Codex.app to -# ChatGPT.app, while older installs still expose the former process name. -if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || /usr/bin/pgrep -x Codex >/dev/null 2>&1; then +# Query LaunchServices by the stable bundle ID. Current macOS builds can expose +# "ChatGPT" through ps while remaining invisible to pgrep's process-name lookup. +if /usr/bin/lsappinfo find bundleID=com.openai.codex 2>/dev/null \ + | /usr/bin/grep -q '^ASN:'; then CODEX_RUNNING="true" fi diff --git a/macos/tests/injector-watcher-lifecycle.test.mjs b/macos/tests/injector-watcher-lifecycle.test.mjs new file mode 100644 index 00000000..cc9c8119 --- /dev/null +++ b/macos/tests/injector-watcher-lifecycle.test.mjs @@ -0,0 +1,269 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + createLogThrottle, + nextDiscoveryDelay, + parseArgs, + processIsAlive, + waitForWatchRetry, +} from "../scripts/injector.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const injectorPath = path.resolve(here, "../scripts/injector.mjs"); +const themeDir = path.resolve(here, "../presets/preset-gothic-void-crusade"); +const commonSource = await fs.readFile(path.resolve(here, "../scripts/common-macos.sh"), "utf8"); +const startSource = await fs.readFile(path.resolve(here, "../scripts/start-dream-skin-macos.sh"), "utf8"); +const statusSource = await fs.readFile(path.resolve(here, "../scripts/status-dream-skin-macos.sh"), "utf8"); + +function waitForChildExit(child, timeoutMs, timeoutMessage) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(timeoutMessage)); + }, timeoutMs); + child.once("exit", (code, signal) => { + clearTimeout(timeout); + resolve({ code, signal }); + }); + }); +} + +assert.match( + commonSource, + /launch_injector_daemon\(\)[\s\S]*local host_pid="\$2"[\s\S]*--host-pid "\$host_pid"/, + "Background watchers must receive the exact Codex host PID.", +); +assert.match( + startSource, + /--foreground-injector[\s\S]*exec "\$NODE" "\$INJECTOR" --watch[\s\S]{0,300}--host-pid "\$CODEX_PID"/, + "Foreground watchers must receive the exact Codex host PID.", +); +assert.doesNotMatch( + `${commonSource}\n${startSource}`, + /launch_injector_daemon "\$(?:port|PORT)"\)/, + "Every launch_injector_daemon call must pass a Codex host PID.", +); +assert.match( + commonSource, + /hot_reapply_theme\(\)[\s\S]*--host-pid[\s\S]*\$\(\s*i \+ 1\s*\)\s*==\s*host/, + "Hot reapply must not reuse a legacy watcher that is missing the current Codex host PID.", +); +assert.doesNotMatch( + commonSource, + /\/bin\/launchctl submit[\s\S]{0,300}INJECTOR_JOB_LABEL/, + "A submitted launchd job infers KeepAlive and relaunches a watcher after its host exits.", +); +assert.match( + commonSource, + /write_injector_launch_agent\(\)[\s\S]*KeepAlive -bool false[\s\S]*launchctl bootstrap/, + "The background watcher must use a one-shot LaunchAgent that does not restart after a clean exit.", +); +assert.match( + commonSource, + /stop_recorded_injector\(\)[\s\S]{0,220}if \[ ! -f "\$STATE_PATH" \]; then[\s\S]{0,120}release_injector_launchd_job/, + "Pause and restore cleanup must remove an orphaned launchd job even when state.json is missing.", +); +assert.match( + statusSource, + /lsappinfo find bundleID=com\.openai\.codex[\s\S]{0,160}grep -q '\^ASN:'/, + "Fast status must use LaunchServices because pgrep does not see the current ChatGPT main process.", +); +assert.doesNotMatch( + statusSource, + /pgrep -x (?:ChatGPT|Codex)/, + "Fast status must not rely on the stale process-name contract.", +); + +const launchAgentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "dream-skin-launch-agent-")); +try { + const generated = spawnSync("/bin/bash", [ + "-c", + ` + . "$1" + STATE_ROOT="$2" + NODE="$3" + INJECTOR="$4" + THEME_DIR="$5" + OPERATION_STATE_PATH="$STATE_ROOT/operation-state.plist" + OPERATION_ACK_PATH="$STATE_ROOT/operation-control-ack.json" + INJECTOR_LOG="$STATE_ROOT/injector.log" + INJECTOR_ERROR_LOG="$STATE_ROOT/injector-error.log" + ensure_state_root + write_injector_launch_agent 19341 "$6" + `, + "_", + path.resolve(here, "../scripts/common-macos.sh"), + launchAgentRoot, + process.execPath, + injectorPath, + themeDir, + String(process.pid), + ], { encoding: "utf8" }); + assert.equal(generated.status, 0, generated.stderr); + const launchAgentPath = generated.stdout.trim(); + const keepAlive = spawnSync( + "/usr/bin/plutil", + ["-extract", "KeepAlive", "raw", "-o", "-", launchAgentPath], + { encoding: "utf8" }, + ); + assert.equal(keepAlive.status, 0, keepAlive.stderr); + assert.equal(keepAlive.stdout.trim(), "false"); + const programArguments = spawnSync( + "/usr/bin/plutil", + ["-extract", "ProgramArguments", "json", "-o", "-", launchAgentPath], + { encoding: "utf8" }, + ); + assert.equal(programArguments.status, 0, programArguments.stderr); + assert.deepEqual(JSON.parse(programArguments.stdout), [ + process.execPath, + injectorPath, + "--watch", + "--port", + "19341", + "--theme-dir", + themeDir, + "--operation-state", + path.join(launchAgentRoot, "operation-state.plist"), + "--operation-ack", + path.join(launchAgentRoot, "operation-control-ack.json"), + "--host-pid", + String(process.pid), + ]); +} finally { + await fs.rm(launchAgentRoot, { recursive: true, force: true }); +} + +assert.throws( + () => parseArgs(["--watch", "--port", "19341", "--theme-dir", themeDir]), + /host pid/i, + "A watcher must be tied to the Codex process that owns its CDP endpoint.", +); +assert.throws( + () => parseArgs([ + "--watch", "--port", "19341", "--theme-dir", themeDir, "--host-pid", "0", + ]), + /host pid/i, + "The watcher must reject an invalid host PID.", +); + +const options = parseArgs([ + "--watch", + "--port", + "19341", + "--theme-dir", + themeDir, + "--host-pid", + String(process.pid), +]); +assert.equal(options.hostPid, process.pid); +assert.equal(processIsAlive(process.pid), true); +assert.equal(processIsAlive(2147483647), false); + +let delay = 250; +const observedDelays = []; +for (let attempt = 0; attempt < 12; attempt += 1) { + observedDelays.push(delay); + delay = nextDiscoveryDelay(delay); +} +assert.deepEqual( + observedDelays.slice(0, 8), + [250, 500, 1000, 2000, 4000, 8000, 16000, 30000], +); +assert.equal(delay, 30000, "Disconnected CDP polling must remain capped at 30 seconds."); + +let now = 0; +const shouldLog = createLogThrottle(30000, () => now); +assert.equal(shouldLog("fetch failed"), true); +now = 29999; +assert.equal(shouldLog("fetch failed"), false); +assert.equal(shouldLog("connection refused"), true); +now = 30000; +assert.equal(shouldLog("fetch failed"), true); + +let aliveChecks = 0; +const hostExitResult = await waitForWatchRetry(30000, { + hostPid: process.pid, + isAlive: () => { + aliveChecks += 1; + return aliveChecks < 3; + }, + pollIntervalMs: 5, +}); +assert.equal(hostExitResult, "host-exited"); +assert.ok(aliveChecks >= 3); + +const abortController = new AbortController(); +const abortStartedAt = Date.now(); +const abortedRetry = waitForWatchRetry(30000, { + hostPid: process.pid, + signal: abortController.signal, +}); +setTimeout(() => abortController.abort(), 20); +assert.equal(await abortedRetry, "stopped"); +assert.ok(Date.now() - abortStartedAt < 500, "SIGTERM-equivalent abort must interrupt backoff."); + +const child = spawn(process.execPath, [ + injectorPath, + "--watch", + "--port", + "19341", + "--theme-dir", + themeDir, + "--host-pid", + "2147483647", +], { + stdio: ["ignore", "pipe", "pipe"], +}); +let stdout = ""; +let stderr = ""; +child.stdout.setEncoding("utf8"); +child.stderr.setEncoding("utf8"); +child.stdout.on("data", (chunk) => { stdout += chunk; }); +child.stderr.on("data", (chunk) => { stderr += chunk; }); + +const exit = await waitForChildExit( + child, + 2000, + "Watcher did not exit after its host process disappeared.", +); + +assert.deepEqual(exit, { code: 0, signal: null }); +assert.match(stdout, /host process .* exited; stopping watcher/i); +assert.equal(stderr, ""); + +const signalChild = spawn(process.execPath, [ + injectorPath, + "--watch", + "--port", + "19341", + "--theme-dir", + themeDir, + "--host-pid", + String(process.pid), +], { + stdio: ["ignore", "pipe", "pipe"], +}); +let signalStderr = ""; +signalChild.stderr.setEncoding("utf8"); +signalChild.stderr.on("data", (chunk) => { signalStderr += chunk; }); +const signalExitPromise = waitForChildExit( + signalChild, + 1000, + "SIGTERM did not interrupt the watcher retry promptly.", +); +await Promise.race([ + new Promise((resolve) => signalChild.stderr.once("data", resolve)), + new Promise((resolve) => setTimeout(resolve, 500)), +]); +const signalStartedAt = Date.now(); +signalChild.kill("SIGTERM"); +const signalExit = await signalExitPromise; +assert.deepEqual(signalExit, { code: 0, signal: null }); +assert.ok(Date.now() - signalStartedAt < 1000); +assert.ok(signalStderr.split("\n").filter(Boolean).length <= 1); + +console.log("PASS: watcher lifecycle follows its Codex host and backs off after CDP loss."); diff --git a/macos/tests/run-tests.sh b/macos/tests/run-tests.sh index ca068e6d..c141b943 100755 --- a/macos/tests/run-tests.sh +++ b/macos/tests/run-tests.sh @@ -165,6 +165,7 @@ fi "$NODE" "$ROOT/scripts/injector.mjs" --check-payload >/dev/null "$NODE" "$ROOT/tests/image-metadata.test.mjs" "$NODE" "$ROOT/tests/injector-bootstrap.test.mjs" +"$NODE" "$ROOT/tests/injector-watcher-lifecycle.test.mjs" "$NODE" "$ROOT/tests/window-readiness.test.mjs" "$NODE" "$ROOT/tests/renderer-inject.test.mjs" "$NODE" "$ROOT/tests/safe-css-validator.test.mjs" @@ -662,7 +663,8 @@ STATUS_PID="" # The common stop path must reject a real watcher running on 19341 when the # saved state claims 1934, even though nodePath/injectorPath/start-time all # match. This exercises the signal gate directly (status has its own matcher). -"$NODE" "$ROOT/scripts/injector.mjs" --watch --port 19341 --theme-dir "$ROOT/presets/preset-gothic-void-crusade" \ +"$NODE" "$ROOT/scripts/injector.mjs" --watch --port 19341 \ + --theme-dir "$ROOT/presets/preset-gothic-void-crusade" --host-pid "$$" \ >"$TMP/near-prefix-injector.out" 2>&1 & WATCH_PID="$!" /bin/sleep 0.2 diff --git a/macos/tests/window-readiness.test.mjs b/macos/tests/window-readiness.test.mjs index bc11a6d1..7419cef1 100644 --- a/macos/tests/window-readiness.test.mjs +++ b/macos/tests/window-readiness.test.mjs @@ -248,6 +248,7 @@ launch_injector_daemon() { wait_for_cdp() { return 0; } process_started_at() { /usr/bin/printf 'test-start-time\\n'; } codex_main_pids() { /usr/bin/printf '4242\\n'; } +wait_for_codex_main_pid() { codex_main_pids | /usr/bin/head -n 1; } write_state() { /usr/bin/printf '{}\\n' > "$STATE_PATH"; } mark_state_stale() { /usr/bin/printf 'stale\\n' > "$STALE_MARKER"; } mark_state_active() { /usr/bin/printf 'active\\n' > "$ACTIVE_MARKER"; }