Node execFile 捕获不到子进程真实报错?错在只看 stderr
一个调用 Python 脚本做文件解析的后端接口,某天线上反馈报错信息完全看不懂。打开日志一看:
解析失败:Command failed: python3 /tmp/xxx/script.py /tmp/file.pdf
命令行截断,没有任何业务细节。SSH 上服务器手跑同一条命令,却能清楚地看到:
{"ok": false, "error": "markitdown_missing", "message": "Python package markitdown is not installed, run pip3 install markitdown first"}那错误信息去哪了?
现象
- 前端 / 日志只显示
Command failed: python3 ...这条无用的包装信息 err.stderr是空字符串- 手跑子进程有完整 JSON 输出,但
echo $?返回2(非零) - 无论怎么改日志格式,有效信息就是不出现
根因
Python(以及很多 CLI 工具)有一种常见的错误上报约定:把结构化错误 JSON 打印到 stdout,同时以非零退出码退出。
try:
from markitdown import MarkItDown
except Exception as exc:
print(json.dumps({"ok": False, "error": "markitdown_missing", "message": "..."}))
sys.exit(2) # 非零退出Node 的 child_process.execFile(包括 promisify(execFile) 异步版)遇到非零退出码会 reject Promise,reject 出来的 error 对象结构是:
| 字段 | 内容 |
|---|---|
| err.message | "Command failed: python3 /path/script.py /tmp/file" |
| err.stderr | "" (脚本根本没写 stderr) |
| err.stdout | '{"ok":false,"error":"markitdown_missing","message":"..."}' |
绝大多数 TypeScript catch 块只看 err.message 和 err.stderr,err.stdout 被完全忽略——真实错误静默丢弃。
这是双层错位的经典案例:子进程用「JSON on stdout + 非零退出」这种合理但非主流的约定;Node 封装层假设「错误在 stderr」这个主流约定。两边都"没错",合在一起就是信息黑洞。
err.stdout 在 Node execFile reject 的 error 对象上是存在的,但几乎所有教程、模板代码都不提它。
正解
catch 块同时解析 err.stdout,把它合并进错误分类的 haystack,再按优先级决定展示给用户的文案:
} catch (err: any) {
const message = String(err?.message || err)
const stderr = err?.stderr ? String(err.stderr).slice(0, 2000).trim() : ''
// 子进程可能把结构化错误 JSON 打到 stdout + 非零退出
// 不能让这份 JSON 被静默丢弃
const stdout = err?.stdout ? String(err.stdout).slice(0, 4000).trim() : ''
const stdoutPayload = stdout ? safeParseJson(stdout) : null
const subprocessError = stdoutPayload?.error ? String(stdoutPayload.error) : ''
const subprocessMessage = stdoutPayload?.message ? String(stdoutPayload.message) : ''
// 把所有来源合并成 haystack,regex 分类才能匹配到子进程内部的错误码
const haystack = `${message} ${stderr} ${subprocessError} ${subprocessMessage}`
if (/markitdown_missing|No module named/.test(haystack)) {
throw new Error('文档解析依赖未安装,请先在服务器执行 pip3 install markitdown')
}
// 展示优先级:子进程 message > stderr > Node 包装 message
const detail = subprocessMessage || subprocessError || stderr || message
throw new Error(`文件解析失败:${detail}`)
}
function safeParseJson(raw: string): any {
try { return JSON.parse(raw) } catch { return null }
}四个要点:
- slice 再 stringify:子进程崩溃时可能输出几 MB 乱码,2-4 KB 截断足够,不要撑爆响应体或日志。
- safeParseJson 不是 JSON.parse:stdout 可能是残缺 JSON 或纯文本,catch 块自身绝不能再抛异常。
- 先合并 haystack,再跑 regex:让现有的错误分类逻辑不用改,自然命中
markitdown_missing等错误码。 - 展示优先级:子进程自己的
message字段质量最高,只有它缺席才向上回退到 stderr / Node 包装信息。
排查方法
怀疑是这个问题时,两步确认:
# 1. 手跑子进程,观察 stdout / stderr / exit code
python3 script.py args
echo "exit: $?"
# 2. 在 Node catch 块临时打印所有字段
console.error('err.stdout:', err?.stdout)
console.error('err.stderr:', err?.stderr)如果手跑时 stdout 有 JSON、stderr 为空、exit 非零,就是这个坑。
一句话外卖
Node
execFilereject 出来的 error 对象携带err.stdout,Python 等子进程把错误 JSON 打在 stdout 时 catch 块必须显式读它,否则你看到的永远只是那行毫无意义的 "Command failed"。