Files
wonfen ed0b801573 fix(service): unify installation and safe sidecar handoff
Delegate service and core installation to Service IPC so replacement uses
one elevation. Share fallback occupancy checks and retain the execution
guard until the Sidecar exits.

Track missing approved cores and start refusals as unavailable, and show
when no core is running. Build matching service tools from the local IPC
dependency and pass both core executables through the Dev installer.
2026-09-22 14:55:24 +08:00

571 lines
16 KiB
JavaScript

import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
createMacosShutdownController,
readPrivateInstanceRecord,
requestDevQuit,
waitForPortClosed,
} from './dev-control.mjs'
import {
developmentServiceDirectoryEnvironment,
developmentServiceWatchPaths,
ensureDevelopmentService,
prepareDevelopmentService,
} from './dev-service.mjs'
const FORCE_GRACE_MS = 2_000
const SHUTDOWN_TIMEOUT_MS = 10_000
const FORCE_POLL_MS = 50
const ANCHOR_EXIT_RESERVE_MS = 100
export function buildTauriInvocation(
mode,
environment = process.env,
platform = process.platform,
) {
const env = { ...environment, RUST_BACKTRACE: 'full' }
const features = mode === 'sidecar' ? 'verge-dev,dev-sidecar' : 'verge-dev'
const args = ['exec', 'tauri', 'dev', '-f', features]
if (mode === 'trace') {
env.RUSTFLAGS = '--cfg tokio_unstable'
args.push('tokio-trace')
}
for (const path of developmentServiceWatchPaths) {
args.push('--additional-watch-folders', path)
}
return {
command: 'pnpm',
args,
env,
detached: platform === 'darwin',
}
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function childLifecycle(child) {
const spawned = new Promise((resolve, reject) => {
child.once('spawn', resolve)
child.once('error', reject)
})
spawned.catch(() => {})
const result = new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve({ code, signal }))
})
result.catch(() => {})
return { result, spawned }
}
function waitForPromise(promise, timeoutMs, message) {
if (timeoutMs <= 0) return Promise.reject(new Error(message))
let timeout
return Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
}),
]).finally(() => clearTimeout(timeout))
}
function signalDetachedGroup(childPid, signal, signalGroup) {
if (!Number.isInteger(childPid) || childPid <= 0) {
throw new Error('dev command did not provide a valid child PID')
}
try {
signalGroup(-childPid, signal)
return true
} catch (error) {
if (error?.code === 'ESRCH') return false
throw error
}
}
export async function terminateDetachedGroup(
childPid,
{
signalGroup = process.kill.bind(process),
sleep = wait,
now = Date.now,
graceMs = FORCE_GRACE_MS,
pollMs = FORCE_POLL_MS,
} = {},
) {
if (!signalDetachedGroup(childPid, 'SIGTERM', signalGroup)) return
const deadline = now() + graceMs
while (now() < deadline) {
await sleep(Math.min(pollMs, deadline - now()))
if (now() < deadline && !signalDetachedGroup(childPid, 0, signalGroup)) {
return
}
}
signalDetachedGroup(childPid, 'SIGKILL', signalGroup)
}
export function mirrorChildExit(
{ code, signal },
{
exit = (exitCode) => process.exit(exitCode),
signalSelf = (childSignal) => {
process.removeAllListeners(childSignal)
process.kill(process.pid, childSignal)
},
} = {},
) {
if (code !== null) {
exit(code)
return
}
if (signal) {
signalSelf(signal)
return
}
exit(1)
}
function defaultInstallSigint(handler) {
process.on('SIGINT', handler)
return () => process.off('SIGINT', handler)
}
function defaultInstallSigterm(handler) {
process.on('SIGTERM', handler)
return () => process.off('SIGTERM', handler)
}
function defaultInstallMessage(handler) {
process.on('message', handler)
return () => process.off('message', handler)
}
function defaultInstallDisconnect(handler) {
process.on('disconnect', handler)
return () => process.off('disconnect', handler)
}
function sendToParent(message) {
if (process.connected && process.send) process.send(message, () => {})
}
export async function runDevAnchor(mode, dependencies = {}) {
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
throw new Error('usage: node scripts/dev.mjs --anchor <dev|trace|sidecar>')
}
const environment = dependencies.environment ?? process.env
const spawnChild = dependencies.spawnChild ?? spawn
const send = dependencies.send ?? sendToParent
const exit = dependencies.exit ?? ((code) => process.exit(code))
const report = dependencies.report ?? ((message) => console.error(message))
const ownPid = dependencies.ownPid ?? process.pid
const terminateGroup =
dependencies.terminateGroup ??
((pid) => terminateDetachedGroup(pid, { graceMs: FORCE_GRACE_MS }))
let released = false
let disconnectCleanup
const installSigterm = dependencies.installSigterm ?? defaultInstallSigterm
installSigterm(() => {})
const installMessage = dependencies.installMessage ?? defaultInstallMessage
installMessage((message) => {
if (message?.type === 'release' && !disconnectCleanup) {
released = true
exit(0)
}
})
const installDisconnect =
dependencies.installDisconnect ?? defaultInstallDisconnect
installDisconnect(() => {
if (released || disconnectCleanup) return
disconnectCleanup = Promise.resolve()
.then(() => terminateGroup(ownPid))
.then(() => exit(1))
.catch((error) => {
report(`Dev anchor cleanup failed: ${error.message}`)
exit(1)
})
})
const invocation = buildTauriInvocation(mode, environment, 'darwin')
let inner
try {
inner = spawnChild(invocation.command, invocation.args, {
env: invocation.env,
detached: false,
stdio: 'inherit',
})
} catch {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
if (!inner || typeof inner.once !== 'function') {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
const innerLifecycle = childLifecycle(inner)
try {
await innerLifecycle.spawned
} catch {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
send({ type: 'ready' })
innerLifecycle.result.then(
({ code, signal }) => send({ type: 'inner-result', code, signal }),
() => send({ type: 'inner-error', code: 'INNER_FAILED' }),
)
}
function createAnchorChannel(anchor, anchorResult) {
let innerSettled = false
let readySettled = false
let resolveResult
let rejectResult
let resolveReady
let rejectReady
const innerResult = new Promise((resolve, reject) => {
resolveResult = resolve
rejectResult = reject
})
const ready = new Promise((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
const settleInner = (callback, value) => {
if (innerSettled) return
innerSettled = true
callback(value)
}
const settleReady = (callback, value) => {
if (readySettled) return
readySettled = true
callback(value)
}
anchor.on('message', (message) => {
if (message?.type === 'ready') {
settleReady(resolveReady)
} else if (message?.type === 'inner-result') {
settleInner(resolveResult, {
code: message.code,
signal: message.signal,
})
} else if (message?.type === 'inner-error') {
const error = new Error(
`inner dev command failed (${message.code ?? 'UNKNOWN'})`,
)
settleReady(rejectReady, error)
settleInner(rejectResult, error)
}
})
anchorResult.then(
() => {
const error = new Error(
'dev group anchor exited before the inner command completed',
)
settleReady(rejectReady, error)
settleInner(rejectResult, error)
},
() => {
const error = new Error('dev group anchor failed')
settleReady(rejectReady, error)
settleInner(rejectResult, error)
},
)
ready.catch(() => {})
innerResult.catch(() => {})
return { innerResult, ready }
}
function sendRelease(anchor) {
return new Promise((resolve, reject) => {
if (typeof anchor.send !== 'function') {
reject(new Error('dev group anchor IPC is unavailable'))
return
}
anchor.send({ type: 'release' }, (error) => {
if (error) reject(new Error('dev group anchor release failed'))
else resolve()
})
})
}
function unsafeAnchorError() {
return new Error('dev group anchor identity is unsafe')
}
export async function runDevCommand(mode, dependencies = {}) {
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
throw new Error('usage: node scripts/dev.mjs <dev|trace|sidecar>')
}
const platform = dependencies.platform ?? process.platform
const environment = dependencies.environment ?? process.env
const spawnChild = dependencies.spawnChild ?? spawn
const exit = dependencies.exit ?? ((code) => process.exit(code))
const report = dependencies.report ?? ((message) => console.error(message))
const mirrorResult = dependencies.mirrorResult ?? mirrorChildExit
let recordPath
let pendingSigints = 0
let dispatchSigint = () => {
pendingSigints += 1
}
let removeSigint = () => {}
if (platform === 'darwin') {
if (!environment.HOME) {
throw new Error('HOME is required to locate the dev instance record')
}
recordPath = join(
environment.HOME,
'Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/singleton-instance.json',
)
const installSigint = dependencies.installSigint ?? defaultInstallSigint
removeSigint = installSigint(() => dispatchSigint()) ?? (() => {})
}
const tauriInvocation = buildTauriInvocation(mode, environment, platform)
const anchorInvocation = {
command: dependencies.execPath ?? process.execPath,
args: [fileURLToPath(import.meta.url), '--anchor', mode],
env: tauriInvocation.env,
detached: true,
}
const invocation = platform === 'darwin' ? anchorInvocation : tauriInvocation
let child
try {
child = spawnChild(invocation.command, invocation.args, {
shell: platform === 'win32',
env: invocation.env,
detached: invocation.detached,
stdio:
platform === 'darwin'
? ['inherit', 'inherit', 'inherit', 'ipc']
: 'inherit',
})
} catch (error) {
removeSigint()
throw error
}
if (!child || typeof child.once !== 'function') {
removeSigint()
throw new Error('dev command failed to return a child process')
}
const outerLifecycle = childLifecycle(child)
if (platform !== 'darwin') {
mirrorResult(await outerLifecycle.result)
return
}
let groupState = 'safe'
let finalKillSent = false
const anchorChannel = createAnchorChannel(child, outerLifecycle.result)
outerLifecycle.result.then(
() => {
if (groupState !== 'released' && !finalKillSent) groupState = 'unsafe'
},
() => {
groupState = 'unsafe'
},
)
try {
await outerLifecycle.spawned
if (!Number.isInteger(child.pid) || child.pid <= 0) {
throw new Error('dev command did not provide a valid child PID')
}
await waitForPromise(
anchorChannel.ready,
SHUTDOWN_TIMEOUT_MS,
'timed out waiting for the dev group anchor to initialize',
)
} catch (error) {
removeSigint()
throw error
}
const anchorPid = child.pid
const innerResult = anchorChannel.innerResult
let intercepted = false
let observedShutdownOperation
let anchorFinalization
const signalGroup = dependencies.signalGroup ?? process.kill.bind(process)
const guardedSignalGroup = (pid, signal) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
if (signal === 'SIGKILL') finalKillSent = true
try {
signalGroup(pid, signal)
} catch (error) {
if (error?.code === 'ESRCH') {
groupState = 'unsafe'
throw unsafeAnchorError()
}
throw error
}
}
const assertGroupAbsent = () => {
try {
signalGroup(-anchorPid, 0)
} catch (error) {
if (error?.code === 'ESRCH') return
throw new Error('unable to verify that the dev process group exited', {
cause: error,
})
}
throw new Error('dev process group still exists after anchor exit')
}
const forceAnchor = async (budget) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
groupState = 'terminating'
if (dependencies.terminateGroup) {
await dependencies.terminateGroup(anchorPid, budget)
return
}
const deadline = Date.now() + budget
await terminateDetachedGroup(anchorPid, {
graceMs: Math.max(0, budget - ANCHOR_EXIT_RESERVE_MS),
signalGroup: guardedSignalGroup,
})
await waitForPromise(
outerLifecycle.result,
Math.max(1, deadline - Date.now()),
'timed out waiting for the dev group anchor to exit',
)
}
const releaseAnchor = async (budget) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
groupState = 'released'
const deadline = Date.now() + budget
await waitForPromise(
sendRelease(child),
Math.max(1, deadline - Date.now()),
'timed out releasing the dev group anchor',
)
await waitForPromise(
outerLifecycle.result,
Math.max(1, deadline - Date.now()),
'timed out waiting for the released dev group anchor',
)
assertGroupAbsent()
}
const finalizeAnchorOnce = (kind, budget) => {
anchorFinalization ??=
kind === 'release' ? releaseAnchor(budget) : forceAnchor(budget)
return anchorFinalization
}
const getUid = dependencies.getUid ?? (() => process.getuid())
const requestQuit =
dependencies.requestQuit ??
((budget) =>
requestDevQuit(() => readPrivateInstanceRecord(recordPath, getUid()), {
timeoutMs: budget,
}))
const controller = createMacosShutdownController({
requestQuit,
waitForPortClosed: (port, budget) =>
(dependencies.waitForPortClosed ?? waitForPortClosed)(port, {
timeoutMs: budget,
}),
waitForChildExit: (budget) =>
waitForPromise(
innerResult,
budget,
'timed out waiting for the inner dev command to exit',
),
finalizeGroup: (budget) => finalizeAnchorOnce('release', budget),
terminateGroup: (budget) => finalizeAnchorOnce('force', budget),
exit,
report,
timeoutMs: SHUTDOWN_TIMEOUT_MS,
})
dispatchSigint = () => {
intercepted = true
const operation = controller.handleSigint()
if (operation !== observedShutdownOperation) {
observedShutdownOperation = operation
void operation.catch((error) => {
report(`Forced dev shutdown failed: ${error.message}`)
exit(1)
})
}
}
const queuedSigints = pendingSigints
pendingSigints = 0
for (let index = 0; index < queuedSigints; index += 1) {
dispatchSigint()
}
innerResult.then(
async (result) => {
if (intercepted) return
try {
await finalizeAnchorOnce('release', SHUTDOWN_TIMEOUT_MS)
if (!intercepted) mirrorResult(result)
} catch (error) {
if (!intercepted) {
report(`Dev command failed: ${error.message}`)
exit(1)
}
}
},
(error) => {
if (!intercepted) {
report(`Dev command failed: ${error.message}`)
exit(1)
}
},
)
}
export async function prepareDevRuntime(mode, dependencies = {}) {
const ensureService = dependencies.ensureService ?? ensureDevelopmentService
const prepareService =
dependencies.prepareService ?? prepareDevelopmentService
let serviceDirectory
if (mode === 'service') {
serviceDirectory = await ensureService()
} else if (mode === 'dev' || mode === 'trace' || mode === 'sidecar') {
serviceDirectory = await prepareService()
}
return {
mode: mode === 'service' ? 'dev' : mode,
serviceDirectory,
}
}
async function run() {
if (process.argv[2] === '--anchor') {
await runDevAnchor(process.argv[3])
} else {
const { mode, serviceDirectory } = await prepareDevRuntime(process.argv[2])
if (serviceDirectory) {
process.env[developmentServiceDirectoryEnvironment] = serviceDirectory
}
await runDevCommand(mode)
}
}
if (
process.argv[1] &&
pathToFileURL(process.argv[1]).href === import.meta.url
) {
run().catch((error) => {
console.error(`Unable to start dev command: ${error.message}`)
process.exit(1)
})
}