mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
61
src/daemon/__tests__/daemonMain.test.ts
Normal file
61
src/daemon/__tests__/daemonMain.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Tests for daemon/main.ts subcommand routing.
|
||||
*
|
||||
* The `status` and `bg` subcommands trigger dynamic imports of `cli/bg.ts`
|
||||
* which depends on `envUtils.ts` → `lodash-es/memoize.js` (unavailable in
|
||||
* raw test context without `bun run dev`'s define flags). We test only the
|
||||
* self-contained subcommands: help and unknown.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
|
||||
describe('daemonMain subcommand routing', () => {
|
||||
const origLog = console.log
|
||||
const origError = console.error
|
||||
let logLines: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
logLines = []
|
||||
console.log = (...a: unknown[]) => logLines.push(a.map(String).join(' '))
|
||||
console.error = (...a: unknown[]) => logLines.push(a.map(String).join(' '))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.log = origLog
|
||||
console.error = origError
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
test('unknown subcommand sets exitCode to 1', async () => {
|
||||
const { daemonMain } = await import('../main.js')
|
||||
await daemonMain(['unknown-command-xyz'])
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
test('help subcommand prints usage', async () => {
|
||||
const { daemonMain } = await import('../main.js')
|
||||
await daemonMain(['help'])
|
||||
const output = logLines.join('\n')
|
||||
expect(output).toContain('SUBCOMMANDS')
|
||||
expect(output).toContain('status')
|
||||
expect(output).toContain('start')
|
||||
expect(output).toContain('stop')
|
||||
expect(output).toContain('bg')
|
||||
expect(output).toContain('attach')
|
||||
expect(output).toContain('logs')
|
||||
expect(output).toContain('kill')
|
||||
})
|
||||
|
||||
test('--help is alias for help', async () => {
|
||||
const { daemonMain } = await import('../main.js')
|
||||
await daemonMain(['--help'])
|
||||
const output = logLines.join('\n')
|
||||
expect(output).toContain('SUBCOMMANDS')
|
||||
})
|
||||
|
||||
test('-h is alias for help', async () => {
|
||||
const { daemonMain } = await import('../main.js')
|
||||
await daemonMain(['-h'])
|
||||
const output = logLines.join('\n')
|
||||
expect(output).toContain('SUBCOMMANDS')
|
||||
})
|
||||
})
|
||||
185
src/daemon/__tests__/state.test.ts
Normal file
185
src/daemon/__tests__/state.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Tests for src/daemon/state.ts
|
||||
*
|
||||
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
|
||||
* instead of mocking fs/envUtils, to avoid cross-test mock pollution.
|
||||
*/
|
||||
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
|
||||
// ─── setup: real temp dir via env var ──────────────────────────────────────
|
||||
|
||||
const tempBase = mkdtempSync(join(tmpdir(), 'daemon-state-test-'))
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear lodash memoize cache so CLAUDE_CONFIG_DIR env var takes effect
|
||||
if (
|
||||
typeof getClaudeConfigHomeDir === 'function' &&
|
||||
'cache' in getClaudeConfigHomeDir
|
||||
) {
|
||||
;(getClaudeConfigHomeDir as any).cache.clear?.()
|
||||
}
|
||||
const tempHome = mkdtempSync(join(tempBase, 'home-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tempHome
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
// Clear memoize cache after all tests so other files see fresh state
|
||||
if (
|
||||
typeof getClaudeConfigHomeDir === 'function' &&
|
||||
'cache' in getClaudeConfigHomeDir
|
||||
) {
|
||||
;(getClaudeConfigHomeDir as any).cache.clear?.()
|
||||
}
|
||||
try {
|
||||
rmSync(tempBase, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
})
|
||||
|
||||
// ─── import ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
getDaemonStateFilePath,
|
||||
writeDaemonState,
|
||||
readDaemonState,
|
||||
removeDaemonState,
|
||||
queryDaemonStatus,
|
||||
} = await import('../state.js')
|
||||
|
||||
// ─── tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getDaemonStateFilePath', () => {
|
||||
test('returns default path with remote-control name', () => {
|
||||
const p = getDaemonStateFilePath()
|
||||
expect(p).toContain('daemon')
|
||||
expect(p).toContain('remote-control.json')
|
||||
})
|
||||
|
||||
test('returns path with custom name', () => {
|
||||
const p = getDaemonStateFilePath('my-daemon')
|
||||
expect(p).toContain('my-daemon.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeDaemonState', () => {
|
||||
test('writes state JSON to disk', () => {
|
||||
const state = {
|
||||
pid: 1234,
|
||||
cwd: '/test',
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
workerKinds: ['rcs'],
|
||||
lastStatus: 'running' as const,
|
||||
}
|
||||
writeDaemonState(state, 'test')
|
||||
const filePath = getDaemonStateFilePath('test')
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))
|
||||
expect(parsed.pid).toBe(1234)
|
||||
expect(parsed.cwd).toBe('/test')
|
||||
})
|
||||
|
||||
test('creates directory recursively', () => {
|
||||
writeDaemonState(
|
||||
{
|
||||
pid: 1,
|
||||
cwd: '/',
|
||||
startedAt: '',
|
||||
workerKinds: [],
|
||||
lastStatus: 'running',
|
||||
},
|
||||
'dir-test',
|
||||
)
|
||||
const filePath = getDaemonStateFilePath('dir-test')
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDaemonState', () => {
|
||||
test('returns null when no state file', () => {
|
||||
expect(readDaemonState('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
test('returns parsed state when file exists', () => {
|
||||
const state = {
|
||||
pid: 42,
|
||||
cwd: '/x',
|
||||
startedAt: '',
|
||||
workerKinds: [],
|
||||
lastStatus: 'running' as const,
|
||||
}
|
||||
writeDaemonState(state, 'read-test')
|
||||
const result = readDaemonState('read-test')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.pid).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeDaemonState', () => {
|
||||
test('removes existing state file', () => {
|
||||
writeDaemonState(
|
||||
{
|
||||
pid: 1,
|
||||
cwd: '/',
|
||||
startedAt: '',
|
||||
workerKinds: [],
|
||||
lastStatus: 'running',
|
||||
},
|
||||
'rm-test',
|
||||
)
|
||||
const filePath = getDaemonStateFilePath('rm-test')
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
removeDaemonState('rm-test')
|
||||
expect(existsSync(filePath)).toBe(false)
|
||||
})
|
||||
|
||||
test('does not throw when file does not exist', () => {
|
||||
expect(() => removeDaemonState('no-file')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryDaemonStatus', () => {
|
||||
test('returns stopped when no state file', () => {
|
||||
const result = queryDaemonStatus('empty')
|
||||
expect(result.status).toBe('stopped')
|
||||
expect(result.state).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns running when PID is alive (current process)', () => {
|
||||
writeDaemonState(
|
||||
{
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
startedAt: new Date().toISOString(),
|
||||
workerKinds: ['test'],
|
||||
lastStatus: 'running',
|
||||
},
|
||||
'alive-test',
|
||||
)
|
||||
const result = queryDaemonStatus('alive-test')
|
||||
expect(result.status).toBe('running')
|
||||
expect(result.state).toBeDefined()
|
||||
expect(result.state!.pid).toBe(process.pid)
|
||||
})
|
||||
|
||||
test('returns stale when PID is dead and cleans up', () => {
|
||||
writeDaemonState(
|
||||
{
|
||||
pid: 999999,
|
||||
cwd: '/',
|
||||
startedAt: '',
|
||||
workerKinds: [],
|
||||
lastStatus: 'running',
|
||||
},
|
||||
'stale-test',
|
||||
)
|
||||
const result = queryDaemonStatus('stale-test')
|
||||
expect(result.status).toBe('stale')
|
||||
expect(existsSync(getDaemonStateFilePath('stale-test'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,13 @@
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import { type ChildProcess } from 'child_process'
|
||||
import { resolve } from 'path'
|
||||
import { buildCliLaunch, spawnCli } from '../utils/cliLaunch.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import {
|
||||
writeDaemonState,
|
||||
removeDaemonState,
|
||||
queryDaemonStatus,
|
||||
stopDaemonByPid,
|
||||
} from './state.js'
|
||||
|
||||
/**
|
||||
* Exit code used by workers for permanent (non-retryable) failures.
|
||||
@@ -29,30 +36,62 @@ interface WorkerState {
|
||||
* Daemon supervisor entry point. Called from `cli.tsx` via:
|
||||
* `claude daemon [subcommand]`
|
||||
*
|
||||
* Starts and supervises long-running workers. Currently spawns one
|
||||
* `remoteControl` worker that runs the headless bridge server.
|
||||
* Manages the daemon supervisor AND background sessions under one namespace.
|
||||
*
|
||||
* Subcommands:
|
||||
* (none) — start the supervisor with default workers
|
||||
* start — same as no subcommand
|
||||
* status — print worker status (TODO: IPC)
|
||||
* stop — send SIGTERM to supervisor (TODO: PID file)
|
||||
* (none) — unified status (supervisor + sessions)
|
||||
* start — start the supervisor with default workers
|
||||
* stop — send SIGTERM to supervisor
|
||||
* status — unified status (supervisor + sessions)
|
||||
* ps — alias for status
|
||||
* bg — start a background session
|
||||
* attach — attach to a background session
|
||||
* logs — show session logs
|
||||
* kill — kill a session
|
||||
*/
|
||||
export async function daemonMain(args: string[]): Promise<void> {
|
||||
const subcommand = args[0] || 'start'
|
||||
const subcommand = args[0] || 'status'
|
||||
|
||||
switch (subcommand) {
|
||||
// --- Supervisor management ---
|
||||
case 'start':
|
||||
await runSupervisor(args.slice(1))
|
||||
break
|
||||
case 'status':
|
||||
console.log('daemon status: not yet implemented (requires IPC)')
|
||||
break
|
||||
case 'stop':
|
||||
console.log('daemon stop: not yet implemented (requires PID file)')
|
||||
await handleDaemonStop()
|
||||
break
|
||||
|
||||
// --- Unified status ---
|
||||
case 'status':
|
||||
case 'ps':
|
||||
await showUnifiedStatus()
|
||||
break
|
||||
|
||||
// --- Session management (delegates to bg.ts) ---
|
||||
case 'bg': {
|
||||
const bg = await import('../cli/bg.js')
|
||||
await bg.handleBgStart(args.slice(1))
|
||||
break
|
||||
}
|
||||
case 'attach': {
|
||||
const bg = await import('../cli/bg.js')
|
||||
await bg.attachHandler(args[1])
|
||||
break
|
||||
}
|
||||
case 'logs': {
|
||||
const bg = await import('../cli/bg.js')
|
||||
await bg.logsHandler(args[1])
|
||||
break
|
||||
}
|
||||
case 'kill': {
|
||||
const bg = await import('../cli/bg.js')
|
||||
await bg.killHandler(args[1])
|
||||
break
|
||||
}
|
||||
|
||||
case '--help':
|
||||
case '-h':
|
||||
case 'help':
|
||||
printHelp()
|
||||
break
|
||||
default:
|
||||
@@ -64,17 +103,25 @@ export async function daemonMain(args: string[]): Promise<void> {
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
Claude Code Daemon — persistent background supervisor
|
||||
Claude Code Daemon — background process management
|
||||
|
||||
USAGE
|
||||
claude daemon [subcommand] [options]
|
||||
claude daemon [subcommand]
|
||||
|
||||
SUBCOMMANDS
|
||||
start Start the daemon supervisor (default)
|
||||
status Show worker status
|
||||
status Show daemon and session status (default)
|
||||
start Start the daemon supervisor
|
||||
stop Stop the daemon
|
||||
bg Start a background session
|
||||
attach Attach to a background session
|
||||
logs Show session logs
|
||||
kill Kill a session
|
||||
help Show this help
|
||||
|
||||
OPTIONS
|
||||
REPL
|
||||
/daemon [subcommand] Same commands available in interactive mode
|
||||
|
||||
OPTIONS (for start)
|
||||
--dir <path> Working directory (default: current)
|
||||
--spawn-mode <mode> Worker spawn mode: same-dir | worktree (default: same-dir)
|
||||
--capacity <N> Max concurrent sessions per worker (default: 4)
|
||||
@@ -85,6 +132,63 @@ OPTIONS
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show unified status: daemon supervisor + background sessions.
|
||||
*/
|
||||
async function showUnifiedStatus(): Promise<void> {
|
||||
// 1. Daemon supervisor status
|
||||
const result = queryDaemonStatus()
|
||||
console.log('=== Daemon Supervisor ===')
|
||||
switch (result.status) {
|
||||
case 'running': {
|
||||
const s = result.state!
|
||||
console.log(` Status: running`)
|
||||
console.log(` PID: ${s.pid}`)
|
||||
console.log(` CWD: ${s.cwd}`)
|
||||
console.log(` Started: ${s.startedAt}`)
|
||||
console.log(` Workers: ${s.workerKinds.join(', ')}`)
|
||||
break
|
||||
}
|
||||
case 'stopped':
|
||||
console.log(' Status: stopped')
|
||||
break
|
||||
case 'stale':
|
||||
console.log(' Status: stale (cleaned up)')
|
||||
break
|
||||
}
|
||||
|
||||
// 2. Background sessions
|
||||
console.log('\n=== Background Sessions ===')
|
||||
const bg = await import('../cli/bg.js')
|
||||
await bg.psHandler([])
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running daemon from another CLI process.
|
||||
*/
|
||||
async function handleDaemonStop(): Promise<void> {
|
||||
const result = queryDaemonStatus()
|
||||
|
||||
if (result.status === 'stopped') {
|
||||
console.log('daemon is not running')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'stale') {
|
||||
console.log('daemon was stale (cleaned up)')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`stopping daemon (PID: ${result.state!.pid})...`)
|
||||
const stopped = await stopDaemonByPid()
|
||||
|
||||
if (stopped) {
|
||||
console.log('daemon stopped')
|
||||
} else {
|
||||
console.log('daemon could not be stopped (may have already exited)')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse supervisor arguments from CLI.
|
||||
*/
|
||||
@@ -140,12 +244,22 @@ async function runSupervisor(args: string[]): Promise<void> {
|
||||
},
|
||||
]
|
||||
|
||||
// Write daemon state file so other CLI processes can query/stop us
|
||||
writeDaemonState({
|
||||
pid: process.pid,
|
||||
cwd: dir,
|
||||
startedAt: new Date().toISOString(),
|
||||
workerKinds: workers.map(w => w.kind),
|
||||
lastStatus: 'running',
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
console.log('[daemon] supervisor shutting down...')
|
||||
controller.abort()
|
||||
removeDaemonState()
|
||||
for (const w of workers) {
|
||||
if (w.process && !w.process.killed) {
|
||||
w.process.kill('SIGTERM')
|
||||
@@ -222,17 +336,11 @@ function spawnWorker(
|
||||
CLAUDE_CODE_SESSION_KIND: 'daemon-worker',
|
||||
}
|
||||
|
||||
// Build the worker command: reuse the same entrypoint with --daemon-worker flag
|
||||
const execArgs = [
|
||||
...process.execArgv,
|
||||
process.argv[1]!,
|
||||
`--daemon-worker=${worker.kind}`,
|
||||
]
|
||||
|
||||
console.log(`[daemon] spawning worker '${worker.kind}'`)
|
||||
|
||||
const child = spawn(process.execPath, execArgs, {
|
||||
env,
|
||||
const launch = buildCliLaunch([`--daemon-worker=${worker.kind}`], { env })
|
||||
|
||||
const child = spawnCli(launch, {
|
||||
cwd: dir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
157
src/daemon/state.ts
Normal file
157
src/daemon/state.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
|
||||
/**
|
||||
* Daemon state persisted to disk so that `status` / `stop` can work
|
||||
* from a different CLI process than the one that started the daemon.
|
||||
*/
|
||||
export interface DaemonStateData {
|
||||
pid: number
|
||||
cwd: string
|
||||
startedAt: string
|
||||
workerKinds: string[]
|
||||
lastStatus: 'running' | 'stopped' | 'error'
|
||||
}
|
||||
|
||||
export type DaemonStatus = 'running' | 'stopped' | 'stale'
|
||||
|
||||
/**
|
||||
* Returns the path to the daemon state file for a given daemon name.
|
||||
*/
|
||||
export function getDaemonStateFilePath(name = 'remote-control'): string {
|
||||
return join(getClaudeConfigHomeDir(), 'daemon', `${name}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write daemon state to disk. Called by the supervisor on startup.
|
||||
*/
|
||||
export function writeDaemonState(
|
||||
state: DaemonStateData,
|
||||
name = 'remote-control',
|
||||
): void {
|
||||
const filePath = getDaemonStateFilePath(name)
|
||||
mkdirSync(dirname(filePath), { recursive: true })
|
||||
writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read daemon state from disk. Returns null if no state file exists.
|
||||
*/
|
||||
export function readDaemonState(
|
||||
name = 'remote-control',
|
||||
): DaemonStateData | null {
|
||||
const filePath = getDaemonStateFilePath(name)
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf-8')
|
||||
return JSON.parse(raw) as DaemonStateData
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the daemon state file.
|
||||
*/
|
||||
export function removeDaemonState(name = 'remote-control'): void {
|
||||
const filePath = getDaemonStateFilePath(name)
|
||||
try {
|
||||
unlinkSync(filePath)
|
||||
} catch {
|
||||
// File may not exist — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process with the given PID is alive.
|
||||
*/
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the daemon status by reading the state file and probing the PID.
|
||||
*
|
||||
* Returns:
|
||||
* - { status: 'running', state } — PID is alive
|
||||
* - { status: 'stopped' } — no state file
|
||||
* - { status: 'stale' } — state file exists but PID is dead (auto-cleaned)
|
||||
*/
|
||||
export function queryDaemonStatus(name = 'remote-control'): {
|
||||
status: DaemonStatus
|
||||
state?: DaemonStateData
|
||||
} {
|
||||
const state = readDaemonState(name)
|
||||
if (!state) {
|
||||
return { status: 'stopped' }
|
||||
}
|
||||
|
||||
if (isProcessAlive(state.pid)) {
|
||||
return { status: 'running', state }
|
||||
}
|
||||
|
||||
// Stale — process is dead but state file remains
|
||||
removeDaemonState(name)
|
||||
return { status: 'stale' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running daemon by sending SIGTERM, waiting, then SIGKILL if needed.
|
||||
* Cleans up the state file afterward.
|
||||
*
|
||||
* @returns true if the daemon was stopped, false if it wasn't running
|
||||
*/
|
||||
export async function stopDaemonByPid(
|
||||
name = 'remote-control',
|
||||
timeoutMs = 10_000,
|
||||
): Promise<boolean> {
|
||||
const state = readDaemonState(name)
|
||||
if (!state) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { pid } = state
|
||||
|
||||
if (!isProcessAlive(pid)) {
|
||||
removeDaemonState(name)
|
||||
return false
|
||||
}
|
||||
|
||||
// Send SIGTERM
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch {
|
||||
removeDaemonState(name)
|
||||
return false
|
||||
}
|
||||
|
||||
// Wait for exit with timeout
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const pollInterval = 200
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
removeDaemonState(name)
|
||||
return true
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
}
|
||||
|
||||
// Force kill
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
|
||||
// Brief wait for SIGKILL to take effect
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
removeDaemonState(name)
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user