gongxiangp2/lib/image-processing-limiter.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

277 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void>) & {
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<ImageProcessingLockRelease> {
if (activeImageJobs >= maxConcurrentImageJobs) {
await new Promise<void>((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<string> {
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<ImageProcessingLockRelease> {
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<ImageProcessingLockRelease> {
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<void> {
if (child.exitCode !== null || child.signalCode !== null) return
const closed = new Promise<void>((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<void>, timeoutMs: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | null = null
const timeout = new Promise<false>((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<ImageProcessingLockRelease> {
if (process.platform === 'linux' && process.env.NODE_ENV === 'production') {
// 生产环境固定整机并发 1;不得用环境变量把蓝绿总并发放大。
return acquireLinuxImageProcessingLock()
}
return acquireInProcessImageProcessingSlot()
}
/**
* 所有 Sharp 重处理共用的门闩。生产 Linux 会在共享 uploads 上持有独立 OS
* flock;进程崩溃后内核自动释放。任务完成前后都确认锁仍在,锁状态不明时
* 失败关闭,调用方不得提交本次派生结果。
*/
export async function withImageProcessingSlot<T>(task: () => Promise<T>): Promise<T> {
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,
}