import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' import { lstat, mkdir, open } from 'fs/promises' import path from 'path' import { AsyncLocalStorage } from 'async_hooks' import { getUploadRootDir } from '@/lib/upload-paths' const DEFAULT_MAX_CONCURRENT_IMAGE_JOBS = 1 const MAX_ALLOWED_IMAGE_JOBS = 2 const DEFAULT_IMAGE_LOCK_TIMEOUT_MS = 60_000 const IMAGE_LOCK_EXIT_TIMEOUT_MS = 5_000 const IMAGE_LOCK_READY_MARKER = 'IMAGE_PROCESSING_LOCK_READY\n' const IMAGE_LOCK_FILE_NAME = 'image-processing-global.lock' export class ImageProcessingLockUnavailableError extends Error { constructor(message = '图片处理服务正忙,请稍后重试') { super(message) this.name = 'ImageProcessingLockUnavailableError' } } export class ImageProcessingLockLostError extends Error { constructor() { super('图片处理锁意外中断,本次结果已安全拒绝') this.name = 'ImageProcessingLockLostError' } } type ImageProcessingLockRelease = (() => Promise) & { assertHeld: () => void } const imageProcessingLockContext = new AsyncLocalStorage<{ assertHeld: () => void }>() function readMaxConcurrentImageJobs(): number { const configured = Number.parseInt(process.env.IMAGE_PROCESSING_MAX_CONCURRENT || '', 10) if (!Number.isFinite(configured)) { return DEFAULT_MAX_CONCURRENT_IMAGE_JOBS } return Math.max(1, Math.min(MAX_ALLOWED_IMAGE_JOBS, configured)) } const maxConcurrentImageJobs = readMaxConcurrentImageJobs() let activeImageJobs = 0 const waitingImageJobs: Array<() => void> = [] async function acquireInProcessImageProcessingSlot(): Promise { if (activeImageJobs >= maxConcurrentImageJobs) { await new Promise((resolve) => waitingImageJobs.push(resolve)) } else { activeImageJobs += 1 } let released = false const release = async () => { if (released) return released = true const next = waitingImageJobs.shift() if (next) { // 当前名额直接交给队首等待者,避免释放与重新获取之间被后来请求插队。 next() return } activeImageJobs = Math.max(0, activeImageJobs - 1) } release.assertHeld = () => { if (released) throw new ImageProcessingLockLostError() } return release } function imageProcessingLockPath(): string { // UPLOAD_DIR 是蓝绿容器共同挂载的目录,不能使用各容器自己的 /tmp。 return path.join(getUploadRootDir(), '.locks', IMAGE_LOCK_FILE_NAME) } async function prepareImageProcessingLockFile(): Promise { const lockPath = imageProcessingLockPath() const lockDirectory = path.dirname(lockPath) await mkdir(lockDirectory, { recursive: true, mode: 0o750 }) const directoryStats = await lstat(lockDirectory) if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) { throw new ImageProcessingLockUnavailableError('图片处理锁目录异常,请联系管理员') } try { const handle = await open(lockPath, 'wx', 0o600) await handle.close() } catch (error) { if ((error as NodeJS.ErrnoException | undefined)?.code !== 'EEXIST') throw error } const lockStats = await lstat(lockPath) if (!lockStats.isFile() || lockStats.isSymbolicLink()) { throw new ImageProcessingLockUnavailableError('图片处理锁文件异常,请联系管理员') } return lockPath } async function acquireLinuxImageProcessingLock(): Promise { const lockPath = await prepareImageProcessingLockFile() const child = spawn( 'flock', [ '--exclusive', '--timeout', String(Math.ceil(DEFAULT_IMAGE_LOCK_TIMEOUT_MS / 1_000)), '--conflict-exit-code', '75', '--no-fork', lockPath, 'sh', '-c', `printf '${IMAGE_LOCK_READY_MARKER}'; cat >/dev/null`, ], { shell: false, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }, ) return waitForImageProcessingFlock(child) } function waitForImageProcessingFlock( child: ChildProcessWithoutNullStreams, ): Promise { return new Promise((resolve, reject) => { let stdout = '' let stderr = '' let settled = false let released = false let lostError: Error | null = null const timer = setTimeout(() => { if (settled) return settled = true void terminateImageLockProcess(child).finally(() => { reject(new ImageProcessingLockUnavailableError()) }) }, DEFAULT_IMAGE_LOCK_TIMEOUT_MS + 1_000) timer.unref?.() const fail = (error: unknown) => { if (settled) return settled = true clearTimeout(timer) void terminateImageLockProcess(child).finally(() => { console.error('[ImageProcessingLimiter] flock 获取失败:', { error, stderr: stderr.slice(0, 1_000), }) reject(new ImageProcessingLockUnavailableError()) }) } const recordLost = (source: string, error?: unknown) => { if (!settled || released || lostError) return lostError = new ImageProcessingLockLostError() console.error('[ImageProcessingLimiter] 已获取的 flock 意外丢失:', { source, error, stderr: stderr.slice(0, 1_000), }) } child.stdin.on('error', (error) => recordLost('stdin', error)) child.stdout.on('error', (error) => recordLost('stdout', error)) child.stderr.on('error', (error) => recordLost('stderr', error)) child.stderr.on('data', (chunk) => { stderr += String(chunk) if (stderr.length > 2_000) stderr = stderr.slice(-2_000) }) child.on('error', (error) => settled ? recordLost('process', error) : fail(error)) child.on('close', (code, signal) => { if (!settled) { fail(code === 75 ? new ImageProcessingLockUnavailableError() : new Error(`flock 提前退出: code=${code}, signal=${signal}`)) } else if (!released) { recordLost('close', new Error(`code=${code}, signal=${signal}`)) } }) child.stdout.on('data', (chunk) => { if (settled) return stdout += String(chunk) if (!stdout.includes(IMAGE_LOCK_READY_MARKER)) { if (stdout.length > IMAGE_LOCK_READY_MARKER.length * 4) { fail(new Error('flock 未返回预期就绪标记')) } return } settled = true clearTimeout(timer) const release = async () => { if (released) return released = true await terminateImageLockProcess(child) } release.assertHeld = () => { if (released || lostError || child.exitCode !== null || child.signalCode !== null) { throw lostError || new ImageProcessingLockLostError() } } resolve(release) }) }) } async function terminateImageLockProcess(child: ChildProcessWithoutNullStreams): Promise { if (child.exitCode !== null || child.signalCode !== null) return const closed = new Promise((resolve) => child.once('close', () => resolve())) child.stdin.end() if (await waitForImageLockPromise(closed, IMAGE_LOCK_EXIT_TIMEOUT_MS)) return child.kill('SIGTERM') if (await waitForImageLockPromise(closed, IMAGE_LOCK_EXIT_TIMEOUT_MS)) return child.kill('SIGKILL') await waitForImageLockPromise(closed, IMAGE_LOCK_EXIT_TIMEOUT_MS) } async function waitForImageLockPromise(promise: Promise, timeoutMs: number): Promise { let timer: ReturnType | null = null const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs) timer.unref?.() }) const result = await Promise.race([promise.then(() => true), timeout]) if (timer) clearTimeout(timer) return result } async function acquireImageProcessingSlot(): Promise { if (process.platform === 'linux' && process.env.NODE_ENV === 'production') { // 生产环境固定整机并发 1;不得用环境变量把蓝绿总并发放大。 return acquireLinuxImageProcessingLock() } return acquireInProcessImageProcessingSlot() } /** * 所有 Sharp 重处理共用的门闩。生产 Linux 会在共享 uploads 上持有独立 OS * flock;进程崩溃后内核自动释放。任务完成前后都确认锁仍在,锁状态不明时 * 失败关闭,调用方不得提交本次派生结果。 */ export async function withImageProcessingSlot(task: () => Promise): Promise { const inheritedLock = imageProcessingLockContext.getStore() if (inheritedLock) { // image-processor 的高层函数可能在已持锁的安全流程内调用另一个受限函数。 // 同一异步调用链复用锁,既避免自锁,也不允许脱离父任务后继续使用旧锁。 inheritedLock.assertHeld() const result = await task() inheritedLock.assertHeld() return result } const release = await acquireImageProcessingSlot() try { release.assertHeld() const result = await imageProcessingLockContext.run( { assertHeld: release.assertHeld }, task, ) release.assertHeld() return result } finally { await release() } } /** @internal 仅供定向测试确认共享锁位置。 */ export const __imageProcessingLimiterTesting = { imageProcessingLockPath, }