mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
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.
571 lines
16 KiB
JavaScript
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)
|
|
})
|
|
}
|