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 ') } 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 ') } 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) }) }