From 41829e266570d9feecc0445ee751405798c28ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 22 Oct 2026 11:30:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(build):=20Windows=20=E6=A0=91=E6=9D=80?= =?UTF-8?q?=E4=B8=8E=E9=94=99=E8=AF=AF=E7=A0=81=E5=88=86=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E5=8C=85=E8=A3=B9=EF=BC=9B=E6=B8=85=E6=AE=8B=E8=A6=86=E7=9B=96?= =?UTF-8?q?=20manifest=EF=BC=9B=E5=8F=8D=E6=96=9C=E6=9D=A0=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E4=B8=8E=20stat=20=E6=94=B6=E7=AA=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 质量复审五修(Plan 4 Task 5): - Fix 1 超时杀整棵进程树:win32 走 taskkill /PID /T /F(child.kill 只杀 pnpm.cmd 壳层,astro/vite 孤儿会继续写 dist 并占句柄);POSIX detached 进程组 + kill(-pgid, SIGKILL)。暂存 dist 预清 rm 包成 THEME_BUILD_FAILED (孤儿句柄 EPERM 不再裸抛误标 SNAPSHOT_FAILED)。 - Fix 2 五处裸抛分阶段裹码:媒体校验读回 → MEDIA_COPY_FAILED;sitemap/ robots 写、dist 拷出、目录哈希 → MANIFEST_WRITE_FAILED;service 对 building 期非 PipelineError 裸错误标 PIPELINE_INTERNAL(码位新增)。 - Fix 3 失败清残覆盖 route-manifest.json;build_artifacts 已登记行注明 审计豁免(与快照行同语义)。 - Fix 4 媒体 public_path 反斜杠拒绝(Windows 路径解析会绕过 '/'-split 的 '..' 段检测)。 - Fix 5 stat 缺席语义缩窄到 ENOENT,其余错误原样上抛。 Co-Authored-By: Claude Code --- apps/api/src/modules/build/build.service.ts | 44 +++++-- apps/api/src/modules/build/pipeline.ts | 132 +++++++++++++++++--- tests/integration/build-pipeline.test.ts | 4 +- 3 files changed, 150 insertions(+), 30 deletions(-) diff --git a/apps/api/src/modules/build/build.service.ts b/apps/api/src/modules/build/build.service.ts index c7afcbd..533510d 100644 --- a/apps/api/src/modules/build/build.service.ts +++ b/apps/api/src/modules/build/build.service.ts @@ -59,6 +59,7 @@ export type BuildErrorCode = | 'NOT_PUBLISHED' | 'NO_SITE_CONFIG' | 'SNAPSHOT_FAILED' + | 'PIPELINE_INTERNAL' | 'REPORT_WRITE_FAILED' | 'ARTIFACT_REGISTER_FAILED' @@ -363,7 +364,11 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P const reportPath = join(reportDir, 'build.json') input.onStatus?.('queued', jobId) - // 失败收口:job 行保留(failed 终态 + error 面 + input/ 目录清场)后原样上抛——镜像 import 纪律 + // 失败收口:job 行保留(failed 终态 + error 面 + 产物清场)后原样上抛——镜像 import 纪律。 + // 阶段标记(审查复审 Fix 2):catch 里对"非 BuildError/PipelineError 的裸错误"按阶段 + // 给码——building 期裸错误是管线内部程序性破裂(PIPELINE_INTERNAL),不误标 + // SNAPSHOT_FAILED;更早阶段(快照事务/物化)沿用 SNAPSHOT_FAILED 总括码。 + let phase: 'snapshotting' | 'building' | 'validating' | 'reporting' = 'snapshotting' try { // 阶段 4:snapshotting(attempt 延续重试谱系:新建 1 / 撞死行重跑 prev+1;本地 worker 就地推进) await db @@ -416,6 +421,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P // stderr 标记进 error_summary——收口见下方 catch)。 await db.update(buildJobs).set({ status: 'building' }).where(eq(buildJobs.id, jobId)) input.onStatus?.('building', jobId) + phase = 'building' const built = await runBuildPipeline(db, { jobId: jobId!, siteId: site.id, @@ -426,6 +432,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P // 阶段 7:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整) await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId)) input.onStatus?.('validating', jobId) + phase = 'validating' // 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在 const report: BuildReport = { @@ -474,6 +481,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P durationMs: performance.now() - startedAt, } const reportText = `${JSON.stringify(report, null, 2)}\n` + phase = 'reporting' try { await mkdir(reportDir, { recursive: true }) await writeAtomically(reportPath, reportText) @@ -520,22 +528,36 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P durationMs: performance.now() - startedAt, } } catch (err) { - // 阶段敏感的错误码映射:BuildError 保自身码(解析/报告/登记);PipelineError 带 - // 管线阶段码(spawn/媒体/manifest——stderr 标记在 message 内一并入 error_summary); - // 其余(快照事务/物化)沿用 SNAPSHOT_FAILED 总括码。 - const code = err instanceof BuildError ? err.code : err instanceof PipelineError ? err.code : 'SNAPSHOT_FAILED' + // 阶段敏感的错误码映射(审查复审 Fix 2):BuildError 保自身码(解析/报告/登记); + // PipelineError 带管线阶段码(spawn/媒体/manifest——stderr 标记在 message 内一并 + // 入 error_summary);其余裸错误按阶段给码——building 期是 PIPELINE_INTERNAL + // (管线内部程序性破裂),更早阶段(快照事务/物化)沿用 SNAPSHOT_FAILED 总括码。 + const code = err instanceof BuildError + ? err.code + : err instanceof PipelineError + ? err.code + : phase === 'building' + ? 'PIPELINE_INTERNAL' + : 'SNAPSHOT_FAILED' const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err) await db .update(buildJobs) .set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` }) .where(eq(buildJobs.id, jobId)) - // 失败清场(审查 Fix 3 + Task 5 扩面):半途落盘的 input/ 与 dist/(job 级产物目录 - // + 主题包暂存 dist)移除——failed 终态无产物语义,不留"行说 failed 而目录半存" - // 的歧义现场。快照行若已插入则由不可变触发器保留为审计记录(manifest_path 悬空 - // 可接受:job 已 failed,无下游消费方)。清场失败不掩盖原始错误(残留目录由操作者处置)。 - for (const dir of [inputDir, join(artifactsRoot, 'jobs', jobId, 'dist'), themeDistDir()]) { + // 失败清场(审查 Fix 3 + Task 5 扩面 + 复审 Fix 3):半途落盘的 input/、dist/(job 级 + // 产物目录 + 主题包暂存 dist)与 route-manifest.json 移除——failed 终态无产物语义, + // 不留"行说 failed 而目录半存"的歧义现场。快照行若已插入则由不可变触发器保留为 + // 审计记录;build_artifacts 行若已登记(如 report 落盘后、终态更新前的窗口)同样 + // 保留为审计记录——storage_path 悬空可接受:job 已 failed,无下游消费方。清场失败 + // 不掩盖原始错误(残留由操作者处置)。 + for (const target of [ + inputDir, + join(artifactsRoot, 'jobs', jobId, 'dist'), + join(artifactsRoot, 'jobs', jobId, 'route-manifest.json'), + themeDistDir(), + ]) { try { - await rm(dir, { recursive: true, force: true }) + await rm(target, { recursive: true, force: true }) } catch { // best-effort:保留原错误上抛 } diff --git a/apps/api/src/modules/build/pipeline.ts b/apps/api/src/modules/build/pipeline.ts index e44c2e7..847dabf 100644 --- a/apps/api/src/modules/build/pipeline.ts +++ b/apps/api/src/modules/build/pipeline.ts @@ -19,8 +19,9 @@ // @astrojs/telemetry 在构建期真调 global fetch POST(实测撞无网 stub);离线构建 // 纪律下遥测必须关。NODE_OPTIONS 原样透传(spread 继承 + 本管线不自加标志的合并 // 语义):测试可注入 `--import ` 预载钩子证明构建进程树不联网。 -import { spawn } from 'node:child_process' +import { spawn, type ChildProcess } from 'node:child_process' import { createHash } from 'node:crypto' +import type { Stats } from 'node:fs' import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -122,6 +123,30 @@ function spawnEnv(inputDir: string): NodeJS.ProcessEnv { } } +/** + * 超时杀**整棵进程树**(审查复审 Fix 1):Windows 下 child.kill 只杀 pnpm.cmd 壳层, + * astro/vite 子进程会存活继续写 dist(孤儿进程占句柄还会让后续 rm 撞 EPERM)—— + * taskkill /T /F 才是树杀;POSIX 以 detached 进程组长起树、kill(-pgid) 组杀 + * (组长已被回收时回退杀 pid 单点)。 + */ +function killProcessTree(child: ChildProcess): void { + const pid = child.pid + if (pid === undefined) { + child.kill('SIGKILL') + return + } + if (process.platform === 'win32') { + // taskkill.exe(System32,PATH 内 .exe 无需 shell);/T 递归子树 /F 强杀 + spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) + } else { + try { + process.kill(-pid, 'SIGKILL') + } catch { + child.kill('SIGKILL') + } + } +} + /** astro 构建 spawn:cwd=仓库根(pnpm workspace 解析),退出非零即败(stderr 摘要入 message) */ function spawnAstroBuild(inputDir: string): Promise { return new Promise((resolvePromise, rejectPromise) => { @@ -131,15 +156,23 @@ function spawnAstroBuild(inputDir: string): Promise { const child = spawn('pnpm', ['--filter', THEME_PACKAGE, 'exec', 'astro', 'build'], { cwd: REPO_ROOT, shell: true, // Windows:pnpm 是 pnpm.cmd,须经 shell 解析(参数均为无空格字面量) + // POSIX:detached 让子树自成进程组(超时可 kill(-pgid) 树杀);Windows 不设—— + // detached 在 win32 会开新控制台窗(树杀走 taskkill /T,不需要) + detached: process.platform !== 'win32', env: spawnEnv(inputDir), stdio: ['ignore', 'pipe', 'pipe'], }) const timer = setTimeout(() => { - child.kill('SIGKILL') + killProcessTree(child) if (!settled) { settled = true rejectPromise( - new PipelineError('THEME_BUILD_FAILED', `astro 构建超时(>${SPAWN_TIMEOUT_MS}ms)——SIGKILL 终结`), + new PipelineError( + 'THEME_BUILD_FAILED', + `astro 构建超时(>${SPAWN_TIMEOUT_MS}ms)——进程树终结(${ + process.platform === 'win32' ? 'taskkill /T /F' : 'kill(-pgid, SIGKILL)' + })`, + ), ) } }, SPAWN_TIMEOUT_MS) @@ -201,6 +234,19 @@ export function deriveTargetFile(routePath: string): string { return rel.endsWith('.html') ? rel : `${rel}/index.html` } +/** + * stat 缺席语义缩窄到 ENOENT(→ null;审查复审 Fix 5):其余错误(EPERM/IO 破裂) + * 不是"产物缺席",原样上抛——静默吞掉会把权限/磁盘故障误判成"构建产物缺失"。 + */ +async function statOrNull(p: string): Promise { + try { + return await stat(p) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } +} + /** temp+rename 原子写(与 build.service 同款纪律——不留半成品) */ async function writeAtomically(path: string, content: string): Promise { const tmpPath = `${path}.tmp` @@ -216,8 +262,14 @@ async function writeAtomically(path: string, content: string): Promise { /** 媒体复制:copyPath→dist/public_path;**每条**落盘后 sha256 全量校验(非抽检)失配即败 */ async function copyMedia(distDir: string, assets: AssetEntry[]): Promise { for (const a of assets) { - // public_path 必须是根相对安全路径(media-writer 上游已按此登记——此处守越界写) - if (!a.publicPath.startsWith('/') || a.publicPath.split('/').includes('..')) { + // public_path 必须是根相对安全路径(media-writer 上游已按此登记——此处守越界写); + // 反斜杠单列拒绝(审查复审 Fix 4):Windows 路径解析把 '\' 当分隔符,混入会绕过 + // 上面的 '/'-split '..' 段检测(如 '/safe\..\..\x')——落盘前拦死。 + if ( + !a.publicPath.startsWith('/') || + a.publicPath.split('/').includes('..') || + a.publicPath.includes('\\') + ) { throw new PipelineError( 'MEDIA_COPY_FAILED', `媒体 public_path 非根相对安全路径(${a.publicPath})——拒绝越界落盘`, @@ -234,7 +286,17 @@ async function copyMedia(distDir: string, assets: AssetEntry[]): Promise `媒体复制失败(${a.publicPath} ← ${a.copyPath}):${err instanceof Error ? err.message : String(err)}`, ) } - const copied = createHash('sha256').update(await readFile(dst)).digest('hex') + // 校验读回也不裸抛(审查复审 Fix 2):复制成功但读回失败同样是媒体落盘阶段失败 + let copiedBuf: Buffer + try { + copiedBuf = await readFile(dst) + } catch (err) { + throw new PipelineError( + 'MEDIA_COPY_FAILED', + `媒体落盘回读失败(${a.publicPath}):${err instanceof Error ? err.message : String(err)}`, + ) + } + const copied = createHash('sha256').update(copiedBuf).digest('hex') if (copied !== a.sha256) { throw new PipelineError( 'MEDIA_COPY_FAILED', @@ -372,21 +434,30 @@ export async function runBuildPipeline(db: PipelineDb, input: RunBuildPipelineIn throw new PipelineError('INPUT_INVALID', 'render-manifest.json sourceHashes.route 缺席——哈希链断链') } - // ① spawn(主题包 dist 暂存先行清空——跨 build 残留归零,astro 自身清 outDir 之外双保险) + // ① spawn(主题包 dist 暂存先行清空——跨 build 残留归零,astro 自身清 outDir 之外双保险)。 + // 清空失败按构建阶段失败收口(审查复审 Fix 1):最常见诱因是超时孤儿进程仍持文件 + // 句柄(EPERM)——这不是快照阶段错误,裸抛会被 service 误标 SNAPSHOT_FAILED。 const scratchDist = themeDistDir() - await rm(scratchDist, { recursive: true, force: true }) + try { + await rm(scratchDist, { recursive: true, force: true }) + } catch (err) { + throw new PipelineError( + 'THEME_BUILD_FAILED', + `清空主题暂存 dist 失败(${scratchDist}):${err instanceof Error ? err.message : String(err)}——疑似孤儿构建进程占用`, + ) + } await spawnAstroBuild(input.inputDir) // ② dist 定位与完整性:每条 html 行 target_file(派生映射)在暂存 dist 存在且是文件 // (主题侧折平集成已对齐映射——此处失配即主题/管线映射破裂,禁静默放行) - const scratchStat = await stat(scratchDist).catch(() => null) + const scratchStat = await statOrNull(scratchDist) if (scratchStat === null || !scratchStat.isDirectory()) { throw new PipelineError('THEME_BUILD_FAILED', `主题构建产物目录缺席:${scratchDist}`) } for (const r of routes) { if (r.routeKind !== 'html') continue const rel = deriveTargetFile(r.routePath) - const st = await stat(join(scratchDist, rel)).catch(() => null) + const st = await statOrNull(join(scratchDist, rel)) if (st === null || !st.isFile()) { throw new PipelineError( 'THEME_BUILD_FAILED', @@ -395,16 +466,32 @@ export async function runBuildPipeline(db: PipelineDb, input: RunBuildPipelineIn } } - // ③ 媒体复制(sha256 全量校验)④ sitemap/robots(写入暂存 dist——它们是站点服务面文件) + // ③ 媒体复制(sha256 全量校验)④ sitemap/robots(写入暂存 dist——它们是站点服务面文件); + // 落盘不裸抛(审查复审 Fix 2):此阶段 IO 失败是 manifest/服务面文件写入失败 const mediaCopied = await copyMedia(scratchDist, assets) const sitemap = buildSitemap(origin, routes) - await writeAtomically(join(scratchDist, 'sitemap.xml'), sitemap.xml) - await writeAtomically(join(scratchDist, 'robots.txt'), buildRobots(origin)) + try { + await writeAtomically(join(scratchDist, 'sitemap.xml'), sitemap.xml) + await writeAtomically(join(scratchDist, 'robots.txt'), buildRobots(origin)) + } catch (err) { + throw new PipelineError( + 'MANIFEST_WRITE_FAILED', + `sitemap/robots 落盘失败(${scratchDist}):${err instanceof Error ? err.message : String(err)}`, + ) + } - // ⑤ 暂存拷出到 job 级权威产物目录,暂存清空(见模块头双位置纪律) + // ⑤ 暂存拷出到 job 级权威产物目录,暂存清空(见模块头双位置纪律);拷出不裸抛 + // (审查复审 Fix 2)——dist 产物落位失败按 manifest 写入阶段收口 const distPath = join(input.artifactsRoot, 'jobs', input.jobId, 'dist') - await rm(distPath, { recursive: true, force: true }) - await copyTree(scratchDist, distPath) + try { + await rm(distPath, { recursive: true, force: true }) + await copyTree(scratchDist, distPath) + } catch (err) { + throw new PipelineError( + 'MANIFEST_WRITE_FAILED', + `dist 产物拷出失败(${scratchDist} → ${distPath}):${err instanceof Error ? err.message : String(err)}`, + ) + } await rm(scratchDist, { recursive: true, force: true }) // ⑥ route-manifest(§12.1:物化哈希链复用 + 行序 = routes.json 物化序) @@ -429,8 +516,17 @@ export async function runBuildPipeline(db: PipelineDb, input: RunBuildPipelineIn ) } - // ⑦ build_artifacts 登记(dist/manifest 两行;UNIQUE(build_job_id,kind,storage_path)) - const dirHash = await hashDirectory(distPath) + // ⑦ build_artifacts 登记(dist/manifest 两行;UNIQUE(build_job_id,kind,storage_path)); + // 目录哈希计算不裸抛(审查复审 Fix 2)——计算失败按 manifest 登记阶段收口 + let dirHash: { sha256: string; byteSize: number; fileCount: number } + try { + dirHash = await hashDirectory(distPath) + } catch (err) { + throw new PipelineError( + 'MANIFEST_WRITE_FAILED', + `dist 目录哈希计算失败(${distPath}):${err instanceof Error ? err.message : String(err)}`, + ) + } const manifestSha256 = createHash('sha256').update(manifestText, 'utf8').digest('hex') try { await db.insert(buildArtifacts).values([ diff --git a/tests/integration/build-pipeline.test.ts b/tests/integration/build-pipeline.test.ts index 1ed27cd..ee01bf0 100644 --- a/tests/integration/build-pipeline.test.ts +++ b/tests/integration/build-pipeline.test.ts @@ -369,8 +369,10 @@ describe('构建管线全链(mini 夹具 DB 物化输入)', () => { expect(job[0].error_summary).toContain('Render Snapshot 文件缺失或不可读') expect(job[0].error_summary).toContain('content.json') - // dist 清残:产物目录与主题暂存 dist 均无残留;input/ 同样清场(failed 无产物语义) + // dist 清残:产物目录与主题暂存 dist 均无残留;input/ 与 route-manifest.json + // 同样清场(failed 无产物语义——复审 Fix 3:清残面覆盖 manifest 文件) expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'dist'))).toBe(false) + expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'route-manifest.json'))).toBe(false) expect(existsSync(themeDistDir())).toBe(false) expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'input'))).toBe(false) // 半途失败无 build_artifacts 行