fix(ci): preserve npm install diagnostics and classify forced timeout
Independent Staging Quality Gate / validate (push) Successful in 10m17s
Independent Staging Quality Gate / publish (push) Successful in 21m1s

This commit is contained in:
Jesse_Chen
2026-09-26 17:52:37 +08:00
parent 40d7930ab4
commit e801fcf53b
3 changed files with 126 additions and 11 deletions
+31 -5
View File
@@ -251,8 +251,15 @@ jobs:
"timezonefinder==8.2.5" \
-r requirements.txt -r requirements-dev.txt
workdir="$(pwd -P)"
npm_diagnostics="$(mktemp -d "${TMPDIR:-/tmp}/jyotisha-npm-XXXXXXXX")"
cleanup_npm() {
if [ -s "$npm_diagnostics/container.cid" ]; then
docker rm -f "$(cat "$npm_diagnostics/container.cid")" >/dev/null 2>&1 || true
fi
}
trap cleanup_npm EXIT
set +e
docker run --rm \
docker run --cidfile "$npm_diagnostics/container.cid" \
--cpus=1.5 \
--memory=2g \
--memory-swap=2g \
@@ -261,9 +268,11 @@ jobs:
--volume "$workdir:$workdir" \
--workdir "$workdir" \
--env HOME=/tmp \
--env LC_ALL=C \
--volume "$npm_diagnostics:/npm-diagnostics" \
--env "NPM_CONFIG_REGISTRY=$NPM_CONFIG_REGISTRY" \
"$NODE_TOOL_SOURCE_IMAGE" \
timeout --signal=TERM --kill-after=30s 900s \
timeout --verbose --signal=TERM --kill-after=30s 900s \
npm ci --prefix frontend \
--no-audit \
--no-fund \
@@ -272,10 +281,24 @@ jobs:
--fetch-timeout=60000 \
--fetch-retries=2 \
--fetch-retry-mintimeout=1000 \
--fetch-retry-maxtimeout=10000
npm_ci_status=$?
--fetch-retry-maxtimeout=10000 \
--foreground-scripts \
--loglevel=http \
--logs-dir=/npm-diagnostics/npm 2>&1 | tee "$npm_diagnostics/install.log"
npm_ci_status=${PIPESTATUS[0]}
set -e
if [ "$npm_ci_status" -eq 124 ]; then
if [ "$npm_ci_status" -ne 0 ]; then
echo "npm diagnostics retained at $npm_diagnostics (runner-local; do not publish unredacted logs)" >&2
npm_oom="$(docker inspect --format '{{.State.OOMKilled}}' "$(cat "$npm_diagnostics/container.cid" 2>/dev/null)" 2>/dev/null || echo unknown)"
echo "npm container exit=$npm_ci_status OOMKilled=$npm_oom" >&2
if [ "$npm_oom" = true ]; then
exit "$npm_ci_status"
fi
fi
if [ "$npm_ci_status" -eq 124 ] || {
[ "$npm_ci_status" -eq 137 ] &&
grep -Eq '^timeout: sending signal (TERM|KILL) to command' "$npm_diagnostics/install.log"
}; then
echo "frontend npm ci exceeded bounded 900-second timeout; check npm mirror/network or dependency postinstall hang" >&2
exit 124
fi
@@ -283,6 +306,9 @@ jobs:
echo "frontend npm ci failed with status $npm_ci_status inside bounded Node container" >&2
exit "$npm_ci_status"
fi
cleanup_npm
trap - EXIT
rm -rf -- "$npm_diagnostics"
- name: Validate backend, package, frontend, and database contracts
run: |
+22 -2
View File
@@ -2509,9 +2509,9 @@
## BUG-149 | staging quality gate npm ci 缺少安装级 deadline 导致 45 分钟黑洞
- 状态:resolved(local candidate,远端 gate/deploy 待本提交)
- 状态:investigating(2026-09-26 安装卡住复发;既有 deadline 生效,底层卡点待 runner 证据)
- 首次发现:2026-08-09
- 最近更新:2026-08-09
- 最近更新:2026-09-26
- 影响面:Gitea staging quality gate 的依赖安装阶段、`manman-linux` runner 与 Gitea/runner 控制面可用性;测试、lint、build、exact-SHA checkout/publish/deploy 合同未改变。
- 用户现象:Run 1618(SHA `ffbe505c`)在 Python pip 完成后进入 digest-pinned Node 容器执行 `npm ci`;步骤无后续 npm 输出,直到 45 分钟 job timeout,publish/deploy skipped。
- 触发条件:self-hosted runner 在受限 Node 容器中执行 frontend `npm ci`,但安装命令本身没有 fail-closed deadline,npm registry/fetch 也没有比 job-level 更短的诊断边界。
@@ -2523,6 +2523,26 @@
- 复发自:无
- 修复版本:待本次提交 / gate / deploy
### 2026-09-26 诊断补充(未修改 workflow / 未重跑部署)
- 版本:远端 staging 与隔离诊断 worktree 均为 `40d7930ab4c8c44e504f3a6caaee351ed262f172`。Gitea run `2937`(显示序号 `1522`)、job `6464` 在 `xiaoxin` runner 的 `Install dependencies` 失败;Python pip 成功,测试步骤与 publish 跳过。
- 原始错误:`frontend npm ci failed with status 137 inside bounded Node container`。npm 最后一条警告时间为 `2026-09-26T08:28:41.857Z`,失败时间为 `08:44:11.716Z`,约 930 秒,吻合现有 `timeout --signal=TERM --kill-after=30s 900s`。本地 GNU timeout 缩时验证:子进程忽略 TERM 后被 KILL,退出码确为 137。直接退出路径高度符合超时强杀,但仅凭该退出码不能证明或排除 runner OOM。
- 对照:此前成功 run `2934` / job `6456` 在 `07:05:36Z` 输出 `added 859 packages in 40s`。两次的 workflow、`frontend/package.json` 与 lockfile 完全相同;两次都出现 `posthog-node@5.41.0` 要求 Node `^20.20.0 || >=22.22.0`、实际 `22.16.0` 的 EBADENGINE 警告,因此不能把该警告认定为本轮直接失败原因。
- 历史防线:900 秒安装 deadline、资源上限与 fetch timeout 均仍存在,阻止了旧 45 分钟黑洞;workflow 仅对 124 输出超时专用文案,137 落入通用失败分支。既有 `staging-backend-workflows.test.ts` 只锁定命令文本与边界,未验证 TERM 不退出后 137 的诊断语义,也不能防止外部安装链路卡住。
- 未证实部分:npm 下载/解压/postinstall 的具体卡点及内存/PID 状态。容器使用 `--rm`,npm 日志位于容器 HOME `/tmp` 且未持久化;现有 Actions 日志不足以区分镜像网络、安装脚本或资源问题,不虚构具体故障包。
- 后续最小排查:由产品负责人授权后,为安装容器持久化 npm debug 日志并保留退出/OOM 证据,在相同 SHA 重跑;不要仅调大超时或禁用安装脚本。Node engine 版本告警需另行对齐,不冒充本轮根因修复。
- 运行态:只读健康检查返回 `status=ok`,web/API SHA 均为此前成功版本 `f74825a27cca881af3373eb0c77f8f52135da378`,本轮最新代码未部署。未执行 push、migration 或 workflow dispatch。
### 2026-09-26 本地诊断修补(未推送 / 未部署,原始卡死仍 investigating)
- 授权与范围:产品负责人要求修复上述 workflow;仅修改 backend quality gate 的 npm 安装诊断及其回归,不升级 Node、不更换 registry、不增加重试、不放宽 900 秒 deadline 或 CPU/memory/PID 边界。
- 已确认缺陷:GNU timeout 的 TERM 后强杀可能返回 137,旧分支只识别 124;`--rm` 和容器内临时 HOME 丢失 OOM 状态及 npm debug 日志。它们是诊断缺陷,不是已证实的 npm 卡死根因。
- 本地修补:使用独占 cidfile 和 EXIT trap 清理本次容器;先读取 `State.OOMKilled`,只有 124 或「137 + timeout 实际发送信号的日志」才报告超时,OOM/未知失败仍 fail closed。用 `PIPESTATUS[0]` 保留 Docker 的失败码,不让 tee 掩盖失败。mode-0700 临时目录挂载 npm debug 日志并保存安装输出,成功删除、失败保留在 runner 本地;日志未经脱敏不发布。开启 foreground scripts 与 HTTP 进度用于定位下载或 postinstall 卡点。
- 回归:直接提取 workflow 中的安装 shell,以 Docker stub 执行成功、124、强杀 137、OOM 137、未知 137、容器启动失败 125 六种场景,验证分类、退出码、日志保留与定向清理。修补前强杀场景复现通用 137 失败,修补后通过;使用主 checkout 的 Python venv 提供 PyYAML,`node --test frontend/tests/staging-backend-workflows.test.ts` 为 44/44 passed;`bash -n` 与 `git diff --check` 通过。系统 Python 缺 PyYAML 的首次检查失败属于本机工具环境,不是 workflow YAML 错误。
- 真实容器验证:本机 Docker Linux/amd64 使用 CI 同一 pinned Node 镜像、同一 lockfile 和资源限制,修改后安装 shell 成功安装 859 包(约 2 分钟)。将验证副本的期限缩至 1 秒 + 1 秒、命令替换为忽略 TERM 的进程,真实 Docker 返回 137、`OOMKilled=false`,workflow 报告超时并返回 124;容器由 trap 删除。此人为强杀实验只验证退出诊断,不复现 npm 卡死。
- 剩余边界:本机无法复现 xiaoxin 的原始卡死;未在真实 runner 验证修补版本,未执行 push、workflow dispatch、migration 或 deploy。BUG-149 保持 investigating,下一步是获准推送后以新 SHA 的 runner 日志判断下载、安装脚本或资源卡点,不能将本次诊断修补称为安装问题彻底解决。
## BUG-150 | staging publish 的 Webpack 镜像构建耗尽共享 Gitea 资源
- 状态:resolved(local candidate,远端 gate/deploy 待本提交)
@@ -279,8 +279,10 @@ test("Gitea quality gate validates before publishing an immutable ACR manifest",
assert.match(installStep, /rm -rf -- \.venv/);
assert.match(installStep, /python3 -m venv --clear \.venv/);
assert.match(installStep, /workdir="\$\(pwd -P\)"/);
assert.match(installStep, /docker run --rm/);
assert.doesNotMatch(installStep, /timeout --signal=TERM --kill-after=30s 900s docker run/);
assert.match(installStep, /docker run --cidfile/);
assert.match(installStep, /--logs-dir=\/npm-diagnostics\/npm/);
assert.match(installStep, /trap cleanup_npm EXIT/);
assert.doesNotMatch(installStep, /timeout[^\n]*docker run/);
assert.match(installStep, /--cpus=1\.5/);
assert.match(installStep, /--memory=2g/);
assert.match(installStep, /--memory-swap=2g/);
@@ -290,8 +292,8 @@ test("Gitea quality gate validates before publishing an immutable ACR manifest",
assert.match(installStep, /--workdir "\$workdir"/);
assert.match(installStep, /--env HOME=\/tmp/);
assert.match(installStep, /--env "NPM_CONFIG_REGISTRY=\$NPM_CONFIG_REGISTRY"/);
assert.match(installStep, /"\$NODE_TOOL_SOURCE_IMAGE" \\\n\s+timeout --signal=TERM --kill-after=30s 900s \\\n\s+npm ci --prefix frontend \\\n\s+--no-audit \\\n\s+--no-fund \\\n\s+--progress=false \\\n\s+--maxsockets=4 \\\n\s+--fetch-timeout=60000 \\\n\s+--fetch-retries=2 \\\n\s+--fetch-retry-mintimeout=1000 \\\n\s+--fetch-retry-maxtimeout=10000/);
assert.match(installStep, /npm_ci_status=\$\?/);
assert.match(installStep, /"\$NODE_TOOL_SOURCE_IMAGE" \\\n\s+timeout --verbose --signal=TERM --kill-after=30s 900s \\\n\s+npm ci --prefix frontend \\\n\s+--no-audit \\\n\s+--no-fund \\\n\s+--progress=false \\\n\s+--maxsockets=4 \\\n\s+--fetch-timeout=60000 \\\n\s+--fetch-retries=2 \\\n\s+--fetch-retry-mintimeout=1000 \\\n\s+--fetch-retry-maxtimeout=10000/);
assert.match(installStep, /npm_ci_status=\$\{PIPESTATUS\[0\]\}/);
assert.match(installStep, /frontend npm ci exceeded bounded 900-second timeout/);
assert.match(installStep, /frontend npm ci failed with status \$npm_ci_status inside bounded Node container/);
assert.doesNotMatch(installStep, /--ignore-scripts|--omit(?:=|\s+)optional|--force/);
@@ -1627,3 +1629,70 @@ test("gate checkouts fetch the exact SHA from a host-persistent mirror and fall
]);
}
});
test("bounded npm install distinguishes forced timeout, OOM, and unknown failure and cleans its container", () => {
const workflow = readFileSync(giteaQualityWorkflow, "utf8");
const start = workflow.indexOf(' workdir="$(pwd -P)"');
assert.notEqual(start, -1);
const script = "set -euo pipefail\n" + workflow.slice(start)
.split("\n - name: Validate backend")[0].replace(/^ {10}/gm, "");
const root = mkdtempSync(join(tmpdir(), "npm-workflow-test-"));
try {
const docker = join(root, "docker");
writeFileSync(docker, `#!/bin/bash
case "$1" in
run)
while [ "$#" -gt 0 ]; do
if [ "$1" = --cidfile ] && [ "$FAKE_STATUS" != 125 ]; then printf test-container > "$2"; fi
shift
done
if [ "$FAKE_TIMEOUT" = true ]; then echo "timeout: sending signal TERM to command 'npm'" >&2; fi
exit "$FAKE_STATUS" ;;
inspect)
if [ "$FAKE_STATUS" = 125 ]; then exit 1; fi
echo "$FAKE_OOM" ;;
rm) echo "$*" >> "$CLEANUP_LOG" ;;
esac
`);
chmodSync(docker, 0o755);
for (const [status, oom, timedOut, expectedStatus, message] of [
[137, false, true, 124, "exceeded bounded 900-second timeout"],
[0, false, false, 0, ""],
[124, false, true, 124, "exceeded bounded 900-second timeout"],
[137, true, true, 137, "OOMKilled=true"],
[137, false, false, 137, "failed with status 137"],
[125, false, false, 125, "failed with status 125"],
] as const) {
const cleanupLog = join(root, "cleanup.log");
writeFileSync(cleanupLog, "");
const result = spawnSync("bash", ["-c", script], {
cwd: root,
env: { ...process.env, PATH: `${root}:${process.env.PATH}`, TMPDIR: root,
NODE_TOOL_SOURCE_IMAGE: "test-image", NPM_CONFIG_REGISTRY: "https://registry.npmmirror.com",
FAKE_STATUS: String(status), FAKE_OOM: String(oom), FAKE_TIMEOUT: String(timedOut),
CLEANUP_LOG: cleanupLog },
encoding: "utf8", timeout: 10000,
});
const output = result.stdout + result.stderr;
assert.equal(result.status, expectedStatus, output);
assert.ok(output.includes(message), output);
if (status === 125) {
assert.equal(readFileSync(cleanupLog, "utf8"), "");
assert.match(output, /OOMKilled=unknown/);
} else {
assert.match(readFileSync(cleanupLog, "utf8"), /rm -f test-container/);
}
const diagnostics = readdirSync(root).filter(name => name.startsWith("jyotisha-npm-"));
if (status !== 0) {
assert.equal(diagnostics.length, 1, output);
assert.ok(existsSync(join(root, diagnostics[0], "install.log")));
assert.match(output, /npm diagnostics retained at/);
rmSync(join(root, diagnostics[0]), { recursive: true, force: true });
} else {
assert.equal(diagnostics.length, 0);
}
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});