Files
claude-code/src/utils/pipeRegistry.ts
claude-code-best 0ad6349434 chore: 清理 18 处未使用导入、变量和函数
移除未使用的导入(getSubscriptionType、isEnvDefinedFalsy、
getClaudeConfigHomeDir 等)、未使用的常量(ACCENT_COLOR、
NAME_MATCH_BONUS、CLIPBOARD_THRESHOLD)和死函数
(getOpus41Option、pasteViaClipboard),
为未使用参数添加 _ 前缀。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:15:51 +08:00

521 lines
14 KiB
TypeScript

/**
* Pipe Registry — central registry for multi-instance pipe coordination.
*
* Manages a shared registry.json that tracks all CLI instances (main + subs).
* Main role is bound to machineId (OS-level stable fingerprint), not to
* instance startup order.
*
* File locking prevents race conditions when multiple instances start
* simultaneously.
*/
import { readFile, writeFile, unlink, mkdir } from 'fs/promises'
import { join } from 'path'
import { createHash } from 'crypto'
import { isPipeAlive, getPipesDir } from './pipeTransport.js'
import type { TcpEndpoint } from './pipeTransport.js'
import type { LanAnnounce } from './lanBeacon.js'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PipeRegistryEntry {
id: string
pid: number
machineId: string
startedAt: number
ip: string
mac: string
hostname: string
pipeName: string
tcpPort?: number
lanVisible?: boolean
}
export interface PipeRegistrySub extends PipeRegistryEntry {
subIndex: number
boundToMain: string | null
}
export interface PipeRegistry {
version: number
mainMachineId: string | null
main: PipeRegistryEntry | null
subs: PipeRegistrySub[]
}
export type DetermineRoleResult =
| { role: 'main' }
| { role: 'main-recover' }
| { role: 'sub'; subIndex: number }
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
function getRegistryPath(): string {
return join(getPipesDir(), 'registry.json')
}
function getLockPath(): string {
return join(getPipesDir(), 'registry.lock')
}
// ---------------------------------------------------------------------------
// Machine ID — stable OS-level fingerprint
// ---------------------------------------------------------------------------
let _cachedMachineId: string | null = null
export async function getMachineId(): Promise<string> {
if (_cachedMachineId) return _cachedMachineId
let raw: string | null = null
if (process.platform === 'win32') {
// Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid (async)
try {
const { execFile } =
require('child_process') as typeof import('child_process')
raw = await new Promise<string>((resolve, reject) => {
execFile(
'reg',
[
'query',
'HKLM\\SOFTWARE\\Microsoft\\Cryptography',
'/v',
'MachineGuid',
],
{ timeout: 3000 },
(err, stdout) => (err ? reject(err) : resolve(stdout)),
)
})
const match = raw.match(/MachineGuid\s+REG_SZ\s+(\S+)/)
if (match) {
_cachedMachineId = match[1]!
return _cachedMachineId
}
} catch {}
} else if (process.platform === 'linux') {
// Linux: /etc/machine-id (already async)
try {
raw = await readFile('/etc/machine-id', 'utf8')
raw = raw.trim()
if (raw) {
_cachedMachineId = raw
return _cachedMachineId
}
} catch {}
} else if (process.platform === 'darwin') {
// macOS: IOPlatformSerialNumber (async)
try {
const { execFile } =
require('child_process') as typeof import('child_process')
raw = await new Promise<string>((resolve, reject) => {
execFile(
'bash',
[
'-c',
'ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformSerialNumber',
],
{ timeout: 3000 },
(err, stdout) => (err ? reject(err) : resolve(stdout)),
)
})
const match = raw.match(/"IOPlatformSerialNumber"\s*=\s*"(\S+)"/)
if (match) {
_cachedMachineId = match[1]!
return _cachedMachineId
}
} catch {}
}
// Fallback: hash hostname + MAC addresses
_cachedMachineId = generateFallbackId()
return _cachedMachineId
}
function generateFallbackId(): string {
const os = require('os') as typeof import('os')
const nets = os.networkInterfaces()
const macs: string[] = []
for (const name of Object.keys(nets)) {
for (const net of nets[name] ?? []) {
if (net.mac && net.mac !== '00:00:00:00:00:00') {
macs.push(net.mac)
}
}
}
macs.sort()
const raw = `${os.hostname()}:${macs.join(',')}`
return createHash('sha256').update(raw).digest('hex').slice(0, 32)
}
export function getMacAddress(): string {
const os = require('os') as typeof import('os')
const nets = os.networkInterfaces()
for (const name of Object.keys(nets)) {
for (const net of nets[name] ?? []) {
if (
net.family === 'IPv4' &&
!net.internal &&
net.mac &&
net.mac !== '00:00:00:00:00:00'
) {
return net.mac
}
}
}
return '00:00:00:00:00:00'
}
// ---------------------------------------------------------------------------
// File lock — simple .lock file with timeout
// ---------------------------------------------------------------------------
const LOCK_TIMEOUT_MS = 2000
const LOCK_RETRY_MS = 50
async function acquireLock(): Promise<void> {
await mkdir(getPipesDir(), { recursive: true })
const lockPath = getLockPath()
const deadline = Date.now() + LOCK_TIMEOUT_MS
while (Date.now() < deadline) {
try {
// O_CREAT | O_EXCL — fails if file exists
await writeFile(lockPath, String(process.pid), { flag: 'wx' })
return // Lock acquired
} catch (err: any) {
if (err.code === 'EEXIST') {
// Check if lock is stale (older than LOCK_TIMEOUT_MS)
try {
const content = await readFile(lockPath, 'utf8')
const lockPid = parseInt(content, 10)
if (lockPid && lockPid !== process.pid) {
try {
process.kill(lockPid, 0) // Check if process alive
} catch {
// Process dead — remove stale lock
await unlink(lockPath).catch(() => {})
continue
}
}
} catch {
// Can't read lock file — try to remove
await unlink(lockPath).catch(() => {})
continue
}
await new Promise(r => setTimeout(r, LOCK_RETRY_MS))
} else {
throw err
}
}
}
// Timeout — force remove and retry once
await unlink(getLockPath()).catch(() => {})
await writeFile(lockPath, String(process.pid), { flag: 'wx' }).catch(() => {})
}
async function releaseLock(): Promise<void> {
await unlink(getLockPath()).catch(() => {})
}
// ---------------------------------------------------------------------------
// Registry CRUD
// ---------------------------------------------------------------------------
const EMPTY_REGISTRY: PipeRegistry = {
version: 1,
mainMachineId: null,
main: null,
subs: [],
}
export async function readRegistry(): Promise<PipeRegistry> {
try {
const content = await readFile(getRegistryPath(), 'utf8')
const parsed = JSON.parse(content) as PipeRegistry
if (parsed.version !== 1) return { ...EMPTY_REGISTRY }
return parsed
} catch {
return { ...EMPTY_REGISTRY }
}
}
export async function writeRegistry(registry: PipeRegistry): Promise<void> {
await mkdir(getPipesDir(), { recursive: true })
await writeFile(getRegistryPath(), JSON.stringify(registry, null, 2))
}
// ---------------------------------------------------------------------------
// Role management (all operations are lock-protected)
// ---------------------------------------------------------------------------
export async function determineRole(
machineId: string,
): Promise<DetermineRoleResult> {
await acquireLock()
try {
const registry = await readRegistry()
// Case A: no main registered
if (!registry.mainMachineId || !registry.main) {
return { role: 'main' }
}
// Case B: this machine is the main machine
if (registry.mainMachineId === machineId) {
if (registry.main && (await isPipeAlive(registry.main.pipeName, 1000))) {
// Main instance is alive → this is a same-machine sub
const subIndex = registry.subs.length + 1
return { role: 'sub', subIndex }
}
// Main instance is dead → recover main on same machine
return { role: 'main-recover' }
}
// Case C: different machine
const subIndex = registry.subs.length + 1
return { role: 'sub', subIndex }
} finally {
await releaseLock()
}
}
export async function registerAsMain(entry: PipeRegistryEntry): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
registry.mainMachineId = entry.machineId
registry.main = entry
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
export async function registerAsSub(
entry: PipeRegistryEntry,
subIndex: number,
): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
// Remove existing entry with same id (re-registration)
registry.subs = registry.subs.filter(s => s.id !== entry.id)
registry.subs.push({
...entry,
subIndex,
boundToMain: registry.main?.id ?? null,
})
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
export async function unregister(id: string): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
if (registry.main?.id === id) {
registry.main = null
// Don't clear mainMachineId — same machine can recover
}
registry.subs = registry.subs.filter(s => s.id !== id)
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
export async function revertToIndependent(id: string): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
const sub = registry.subs.find(s => s.id === id)
if (sub) {
sub.boundToMain = null
}
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
export async function claimMain(
newMachineId: string,
entry: PipeRegistryEntry,
): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
registry.mainMachineId = newMachineId
registry.main = entry
// All existing subs become bound to new main
for (const sub of registry.subs) {
sub.boundToMain = entry.id
}
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export async function isMainAlive(): Promise<boolean> {
const registry = await readRegistry()
if (!registry.main) return false
return isPipeAlive(registry.main.pipeName, 1000)
}
export function isMainMachine(
machineId: string,
registry: PipeRegistry,
): boolean {
return registry.mainMachineId === machineId
}
export async function getAliveSubs(): Promise<PipeRegistrySub[]> {
const registry = await readRegistry()
const results = await Promise.all(
registry.subs.map(sub =>
isPipeAlive(sub.pipeName, 1000).then(alive => (alive ? sub : null)),
),
)
return results.filter((s): s is PipeRegistrySub => s !== null)
}
export async function cleanupStaleEntries(): Promise<void> {
// Phase 1: Probe all entries in parallel WITHOUT holding the lock
const registry = await readRegistry()
const [mainAlive, subResults] = await Promise.all([
registry.main
? isPipeAlive(registry.main.pipeName, 1000)
: Promise.resolve(true),
Promise.all(
registry.subs.map(sub =>
isPipeAlive(sub.pipeName, 1000).then(alive => ({ sub, alive })),
),
),
])
const needsWrite = !mainAlive || subResults.some(r => !r.alive)
if (!needsWrite) return
// Phase 2: Briefly hold lock to apply changes
await acquireLock()
try {
const fresh = await readRegistry()
let changed = false
if (!mainAlive && fresh.main?.pipeName === registry.main?.pipeName) {
fresh.main = null
changed = true
}
const deadNames = new Set(
subResults.filter(r => !r.alive).map(r => r.sub.pipeName),
)
const aliveSubs = fresh.subs.filter(s => !deadNames.has(s.pipeName))
if (aliveSubs.length !== fresh.subs.length) {
fresh.subs = aliveSubs
changed = true
}
if (changed) {
await writeRegistry(fresh)
}
} finally {
await releaseLock()
}
}
// ---------------------------------------------------------------------------
// LAN peer merging
// ---------------------------------------------------------------------------
export type MergedPipeEntry = {
id: string
pipeName: string
role: string
machineId: string
ip: string
hostname: string
alive: boolean
source: 'local' | 'lan'
tcpEndpoint?: TcpEndpoint
}
/**
* Merge local registry entries with LAN beacon-discovered peers.
* Local entries take precedence — LAN peers are only added if not
* already present in the local registry.
*/
export function mergeWithLanPeers(
registry: PipeRegistry,
lanPeers: Map<string, LanAnnounce>,
): MergedPipeEntry[] {
const result: MergedPipeEntry[] = []
const knownPipes = new Set<string>()
// Add main from local registry
if (registry.main) {
knownPipes.add(registry.main.pipeName)
result.push({
id: registry.main.id,
pipeName: registry.main.pipeName,
role: 'main',
machineId: registry.main.machineId,
ip: registry.main.ip,
hostname: registry.main.hostname,
alive: true, // caller should verify
source: 'local',
tcpEndpoint: registry.main.tcpPort
? { host: registry.main.ip, port: registry.main.tcpPort }
: undefined,
})
}
// Add subs from local registry
for (const sub of registry.subs) {
knownPipes.add(sub.pipeName)
result.push({
id: sub.id,
pipeName: sub.pipeName,
role: `sub-${sub.subIndex}`,
machineId: sub.machineId,
ip: sub.ip,
hostname: sub.hostname,
alive: true,
source: 'local',
tcpEndpoint: sub.tcpPort
? { host: sub.ip, port: sub.tcpPort }
: undefined,
})
}
// Add LAN peers not already in local registry
for (const [pipeName, peer] of lanPeers) {
if (knownPipes.has(pipeName)) continue
result.push({
id: `lan-${pipeName}`,
pipeName,
role: peer.role,
machineId: peer.machineId,
ip: peer.ip,
hostname: peer.hostname,
alive: true,
source: 'lan',
tcpEndpoint: { host: peer.ip, port: peer.tcpPort },
})
}
return result
}