fix: 修复 Alpine 视频转码空间检查

This commit is contained in:
chenpei 2026-08-13 10:55:22 +08:00
parent 7968ee07c2
commit 98068a4209
2 changed files with 138 additions and 226 deletions

View File

@ -3,31 +3,114 @@
*
* 测试内容:
* - formatBytes 函数
* - getDiskUsageLevel 函数(逻辑验证)
* - hasEnoughDiskSpace 函数(逻辑验证)
* - ensureEnoughDiskSpace 函数(逻辑验证)
* - Node statfs 跨平台磁盘空间读取
* - getDiskUsageLevel / hasEnoughDiskSpace / ensureEnoughDiskSpace 逻辑
*/
import { exec } from 'child_process'
import { promisify } from 'util'
import { formatBytes } from '../disk-space'
import { statfs } from 'fs/promises'
import {
checkDiskSpace,
ensureEnoughDiskSpace,
formatBytes,
getDiskUsageLevel,
hasEnoughDiskSpace,
} from '../disk-space'
const execAsync = promisify(exec)
// Mock child_process.exec at module level
jest.mock('child_process', () => {
const originalModule = jest.requireActual('child_process')
jest.mock('fs/promises', () => {
const originalModule = jest.requireActual('fs/promises')
return {
...originalModule,
exec: jest.fn(),
statfs: jest.fn(),
}
})
const mockedStatfs = statfs as jest.MockedFunction<typeof statfs>
const GIBIBYTE = BigInt(1024) * BigInt(1024) * BigInt(1024)
function mockSpace(total: bigint, free: bigint, available = free, blockSize = BigInt(1)) {
mockedStatfs.mockResolvedValueOnce({
type: BigInt(0),
bsize: blockSize,
blocks: total / blockSize,
bfree: free / blockSize,
bavail: available / blockSize,
files: BigInt(0),
ffree: BigInt(0),
})
}
describe('R38: disk-space', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('checkDiskSpace', () => {
it('使用 Node statfs 读取字节数,不依赖系统 df 命令', async () => {
mockSpace(BigInt(100_000), BigInt(80_000), BigInt(75_000))
await expect(checkDiskSpace('/app/uploads/transcoded')).resolves.toEqual({
total: 100_000,
used: 20_000,
available: 75_000,
usePercent: 22,
mountPoint: '/app/uploads/transcoded',
})
expect(mockedStatfs).toHaveBeenCalledWith('/app/uploads/transcoded', { bigint: true })
})
it('按块大小和非特权可用块计算并向上取整使用率', async () => {
mockSpace(BigInt(12_288), BigInt(8_192), BigInt(4_096), BigInt(4_096))
const result = await checkDiskSpace('/app/uploads')
expect(result).toMatchObject({
total: 12_288,
used: 4_096,
available: 4_096,
usePercent: 50,
})
})
it('不把文件系统保留块算作 nextjs 可用空间', async () => {
mockSpace(BigInt(100), BigInt(20), BigInt(10))
await expect(checkDiskSpace('/app/uploads')).resolves.toMatchObject({
total: 100,
used: 80,
available: 10,
usePercent: 89,
})
})
it('超出安全整数范围时钳制数值,避免精度溢出绕过空间门禁', async () => {
const huge = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(10_000)
mockSpace(huge, huge, huge)
const result = await checkDiskSpace('/app/uploads')
expect(result?.total).toBe(Number.MAX_SAFE_INTEGER)
expect(result?.available).toBe(Number.MAX_SAFE_INTEGER)
expect(result?.used).toBe(0)
})
it('空文件系统统计不会产生除零或非有限使用率', async () => {
mockSpace(BigInt(0), BigInt(0), BigInt(0))
await expect(checkDiskSpace('/app/uploads')).resolves.toMatchObject({
total: 0,
used: 0,
available: 0,
usePercent: 0,
})
})
it('statfs 失败时返回 null,让严格调用方保持 fail-closed', async () => {
mockedStatfs.mockRejectedValueOnce(Object.assign(new Error('stale mount'), { code: 'ESTALE' }))
await expect(checkDiskSpace('/app/uploads')).resolves.toBeNull()
})
})
describe('formatBytes', () => {
it('应该正确格式化字节数', () => {
expect(formatBytes(0)).toBe('0.00 B')
@ -41,68 +124,29 @@ describe('R38: disk-space', () => {
describe('getDiskUsageLevel - 逻辑验证', () => {
it('使用率低于 70% 应该返回 ok', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟 PowerShell JSON 输出(60% 使用率)
const stdout = JSON.stringify({ Used: 60000000000, Free: 40000000000 })
;(callback as Function)(null, { stdout, stderr: '' })
return {} as any
})
mockSpace(BigInt(100_000), BigInt(40_000))
const { getDiskUsageLevel } = require('../disk-space')
const result = await getDiskUsageLevel()
expect(result).toBe('ok')
})
it('使用率在 70%-90% 之间应该返回 warning', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟 PowerShell JSON 输出(75% 使用率)
const stdout = JSON.stringify({ Used: 75000000000, Free: 25000000000 })
;(callback as Function)(null, { stdout, stderr: '' })
return {} as any
})
mockSpace(BigInt(100_000), BigInt(25_000))
const { getDiskUsageLevel } = require('../disk-space')
const result = await getDiskUsageLevel()
expect(result).toBe('warning')
})
it('使用率大于 90% 应该返回 critical', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟 PowerShell JSON 输出(95% 使用率)
const stdout = JSON.stringify({ Used: 95000000000, Free: 5000000000 })
;(callback as Function)(null, { stdout, stderr: '' })
return {} as any
})
mockSpace(BigInt(100_000), BigInt(5_000))
const { getDiskUsageLevel } = require('../disk-space')
const result = await getDiskUsageLevel()
expect(result).toBe('critical')
})
it('无法检查时应该返回 ok', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟命令失败
;(callback as Function)(new Error('Command failed'), null)
return {} as any
})
mockedStatfs.mockRejectedValueOnce(new Error('statfs failed'))
const { getDiskUsageLevel } = require('../disk-space')
const result = await getDiskUsageLevel()
expect(result).toBe('ok')
})
@ -110,68 +154,29 @@ describe('R38: disk-space', () => {
describe('hasEnoughDiskSpace - 逻辑验证', () => {
it('当空间足够时应该返回 true', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟 PowerShell JSON 输出(20% 使用率,80GB 可用)
const stdout = JSON.stringify({ Used: 20000000000, Free: 80000000000 })
;(callback as Function)(null, { stdout, stderr: '' })
return {} as any
})
mockSpace(BigInt(100) * GIBIBYTE, BigInt(80) * GIBIBYTE)
const { hasEnoughDiskSpace } = require('../disk-space')
const result = await hasEnoughDiskSpace(10 * 1024 * 1024 * 1024)
expect(result).toBe(true)
})
it('当可用空间不足时应该返回 false', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟 PowerShell JSON 输出(90% 使用率,10GB 可用)
const stdout = JSON.stringify({ Used: 90000000000, Free: 10000000000 })
;(callback as Function)(null, { stdout, stderr: '' })
return {} as any
})
mockSpace(BigInt(100) * GIBIBYTE, BigInt(10) * GIBIBYTE)
const { hasEnoughDiskSpace } = require('../disk-space')
const result = await hasEnoughDiskSpace(20 * 1024 * 1024 * 1024)
expect(result).toBe(false)
})
it('当磁盘使用率过高时应该返回 false', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟 PowerShell JSON 输出(95% 使用率,5GB 可用)
const stdout = JSON.stringify({ Used: 95000000000, Free: 5000000000 })
;(callback as Function)(null, { stdout, stderr: '' })
return {} as any
})
mockSpace(BigInt(100) * GIBIBYTE, BigInt(5) * GIBIBYTE)
const { hasEnoughDiskSpace } = require('../disk-space')
const result = await hasEnoughDiskSpace(100 * 1024 * 1024)
expect(result).toBe(false)
})
it('当无法检查时应该返回 true(保守处理)', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟命令失败
;(callback as Function)(new Error('Command failed'), null)
return {} as any
})
mockedStatfs.mockRejectedValueOnce(new Error('statfs failed'))
const { hasEnoughDiskSpace } = require('../disk-space')
const result = await hasEnoughDiskSpace(10 * 1024 * 1024 * 1024)
expect(result).toBe(true)
})
@ -179,49 +184,20 @@ describe('R38: disk-space', () => {
describe('ensureEnoughDiskSpace - 逻辑验证', () => {
it('当空间足够时不应该抛出错误', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟 PowerShell JSON 输出(20% 使用率,80GB 可用)
const stdout = JSON.stringify({ Used: 20000000000, Free: 80000000000 })
;(callback as Function)(null, { stdout, stderr: '' })
return {} as any
})
mockSpace(BigInt(100) * GIBIBYTE, BigInt(80) * GIBIBYTE)
const { ensureEnoughDiskSpace } = require('../disk-space')
await expect(ensureEnoughDiskSpace(10 * 1024 * 1024 * 1024, '测试操作')).resolves.not.toThrow()
})
it('当空间不足时应该抛出错误', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟 PowerShell JSON 输出(90% 使用率,10GB 可用)
const stdout = JSON.stringify({ Used: 90000000000, Free: 10000000000 })
;(callback as Function)(null, { stdout, stderr: '' })
return {} as any
})
mockSpace(BigInt(100) * GIBIBYTE, BigInt(10) * GIBIBYTE)
const { ensureEnoughDiskSpace } = require('../disk-space')
await expect(ensureEnoughDiskSpace(20 * 1024 * 1024 * 1024, '测试操作')).rejects.toThrow('磁盘空间不足')
})
it('当无法检查时不应该抛出错误', async () => {
const mockExec = exec as jest.MockedFunction<typeof exec>
mockExec.mockImplementationOnce((command, options, callback) => {
if (typeof options === 'function') {
callback = options
}
// 模拟命令失败
;(callback as Function)(new Error('Command failed'), null)
return {} as any
})
mockedStatfs.mockRejectedValueOnce(new Error('statfs failed'))
const { ensureEnoughDiskSpace } = require('../disk-space')
await expect(ensureEnoughDiskSpace(10 * 1024 * 1024 * 1024, '测试操作')).resolves.not.toThrow()
})
})

View File

@ -3,16 +3,10 @@
*
* 用途:在大文件操作前检查磁盘剩余空间,防止因磁盘写满导致服务崩溃
*
* 支持平台:
* - Linux: 使用 `df` 命令
* - Windows (WSL): 使用 `df` 命令
* - Windows (原生): 使用 PowerShell 的 Get-PSDrive
* 使用 Node.js 原生 statfs,不依赖 GNU/BusyBox 命令参数或文本输出格式。
*/
import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
import { statfs } from 'fs/promises'
interface DiskSpaceInfo {
/** 磁盘总容量(字节) */
@ -27,102 +21,44 @@ interface DiskSpaceInfo {
mountPoint: string
}
/**
* 检查磁盘空间(Linux/WSL 使用 df 命令)
*/
async function checkDiskSpaceLinux(path: string): Promise<DiskSpaceInfo | null> {
try {
// df -B1: 以字节为单位显示
// --output: 指定输出字段
const { stdout } = await execAsync(`df -B1 --output=size,used,avail,pcent,target "${path}"`, {
timeout: 5000, // 5秒超时
})
const lines = stdout.trim().split('\n')
if (lines.length < 2) {
console.warn('[DiskSpace] Failed to parse df output')
return null
}
// 跳过标题行,解析数据行
const data = lines[1].trim().split(/\s+/)
if (data.length < 5) {
console.warn('[DiskSpace] Invalid df output format')
return null
}
const total = parseInt(data[0], 10)
const used = parseInt(data[1], 10)
const available = parseInt(data[2], 10)
const usePercentStr = data[3].replace('%', '')
const usePercent = parseInt(usePercentStr, 10)
const mountPoint = data[4]
return {
total,
used,
available,
usePercent,
mountPoint,
}
} catch (error) {
console.error('[DiskSpace] Failed to check disk space:', error)
return null
}
function clampToSafeNumber(value: bigint): number {
if (value <= BigInt(0)) return 0
const maxSafeInteger = BigInt(Number.MAX_SAFE_INTEGER)
return value > maxSafeInteger ? Number.MAX_SAFE_INTEGER : Number(value)
}
/**
* 检查磁盘空间(Windows 原生使用 PowerShell)
*/
async function checkDiskSpaceWindows(path: string): Promise<DiskSpaceInfo | null> {
try {
// 获取盘符
const driveLetter = path.charAt(0).toUpperCase()
const drive = driveLetter + ':'
// 使用 PowerShell Get-PSDrive 获取磁盘信息
const { stdout } = await execAsync(
`powershell -Command "Get-PSDrive -Name '${driveLetter}' | Select-Object Used,Free | ConvertTo-Json"`,
{ timeout: 5000 }
)
const result = JSON.parse(stdout)
if (!result) {
return null
}
const used = result.Used || 0
const free = result.Free || 0
const total = used + free
const usePercent = total > 0 ? Math.round((used / total) * 100) : 0
return {
total,
used,
available: free,
usePercent,
mountPoint: drive,
}
} catch (error) {
console.error('[DiskSpace] Failed to check disk space (Windows):', error)
return null
}
}
/**
* 检查磁盘空间(自动检测平台)
* 检查磁盘空间
* @param path 要检查的路径(默认为临时目录)
* @returns 磁盘空间信息,失败返回 null
*/
export async function checkDiskSpace(path: string = require('os').tmpdir()): Promise<DiskSpaceInfo | null> {
const platform = process.platform
try {
const space = await statfs(path, { bigint: true })
const zero = BigInt(0)
const blockSize = space.bsize > zero ? space.bsize : zero
const totalBytes = space.blocks > zero ? space.blocks * blockSize : zero
const freeBytes = space.bfree > zero ? space.bfree * blockSize : zero
const availableBytes = space.bavail > zero ? space.bavail * blockSize : zero
const usedBytes = totalBytes > freeBytes ? totalBytes - freeBytes : zero
// 与 df 的 Use% 语义一致:非特权进程不能使用的保留块不进入分母。
// 绝对空间门禁同样使用 bavail,避免把 root 保留空间误算给 nextjs。
const usableBytes = usedBytes + availableBytes
const usePercent = usableBytes > zero
? Number((usedBytes * BigInt(100) + usableBytes - BigInt(1)) / usableBytes)
: 0
if (platform === 'win32' && !process.env.WSL_DISTRO_NAME) {
// Windows 原生环境(非 WSL)
return checkDiskSpaceWindows(path)
} else {
// Linux、macOS、WSL
return checkDiskSpaceLinux(path)
return {
total: clampToSafeNumber(totalBytes),
used: clampToSafeNumber(usedBytes),
available: clampToSafeNumber(availableBytes),
usePercent: Math.min(100, Math.max(0, usePercent)),
// statfs 不返回挂载点;保留被检查路径用于日志与诊断。
mountPoint: path,
}
} catch (error) {
console.error('[DiskSpace] Failed to check disk space:', error)
return null
}
}