HandBrake Web 0.8.1 Socket.IO 事件协议与内部接口详解
从源码与实测两条线拆解 HandBrake Web 0.8.1 的 Socket.IO 协议:/client 与 /worker 全部事件、载荷与 ack 方式,并复现无鉴权、握手无超时、非法任务 ID 打崩服务端、失败任务占死 worker 等缺陷。
HandBrake Web 0.8.1 Socket.IO 事件协议与内部接口详解
目标:把 HandBrake Web 0.8.1 里两个命名空间(
/client、/worker)的全部事件、载荷、应答方式与握手顺序讲透,并给出可以直接运行的socket.io-client脚本。文中每一条结论都对应 2026-10-27 的真实收发记录,包括几个会让人排查一整晚的协议缺陷:无鉴权、握手无超时、非法任务 ID 直接把服务端打崩、失败任务永久占住 worker。
环境信息
| 项目 | 取值 |
|---|---|
| 应用版本 | HandBrake Web 0.8.1 |
| 实测系统 | Ubuntu 26.04.1 LTS,内核 7.0.0-38-generic |
| Node.js | v24.22.1 |
| socket.io / socket.io-client | 4.8.1(随源码锁定) |
| server 进程 | server/build/server.js,监听 9999 |
| worker 进程 | worker/build/worker.js |
| 实测端口 | http://127.0.0.1:9999 |
| 观测脚本 | d5-client.js、d5-client2.js、d5-fake2.js、d5-crash.js(socket.io-client 4.8.1) |
一、通信拓扑与握手
1.1 两个命名空间,一个 HTTP 端口
server 只监听一个 TCP 端口(源码 server/src/server-startup.ts 里 const port = 9999; 是硬编码的,环境变量改不了),Socket.IO 在这个端口上挂两个命名空间:
| 命名空间 | 谁连 | 用途 |
|---|---|---|
/client | 浏览器前端、自写管理脚本 | 读配置、看队列、增删任务、管预设、管监听目录 |
/worker | 转码节点 | 上报能力、领任务、回传进度与日志 |
服务端 Socket.IO 的初始化参数只有三项:
const socket = new SocketServer(server, {
cors: { origin: '*' },
pingTimeout: 5000,
});
也就是说:CORS 全放开、心跳超时 5 秒、没有任何 token / 密码 / mTLS 校验。这一点在第四节会被实测放大。
1.2 worker 的身份只来自 query 参数
worker 侧连接地址由 worker-startup.ts 拼出:
const serverAddress = `${serverURLPrefix ? serverURL : 'http://' + serverURL}:${serverPort}/worker`;
服务端则从握手 query 里抠出身份:
const workerID = socket.handshake.query['workerID'] as string;
WORKER_ID 环境变量就是通过这个 query 传上来的(socket.io-client 的 query: { workerID })。服务端不做任何校验:query 里写什么,就认为你是谁。
1.3 一条连接就是一次握手
/worker 命名空间的 connection 回调里是串行 await 的:
io.of('/worker').on('connection', async (socket) => {
const workerID = socket.handshake.query['workerID'] as string;
AddWorker(socket);
const properties = await socket.emitWithAck('get-properties');
AddWorkerProperties(workerID, properties);
const existingJobID = await socket.emitWithAck('check-for-existing-job');
if (existingJobID) { /* 恢复中断的任务状态 */ }
else { WorkerForAvailableJobs(workerID); }
// ……之后才注册 disconnect / get-job-data / transcode-* 等监听
});
关键点有三个:
- 两个
emitWithAck没有.timeout(),worker 不回包就会永远等下去; disconnect监听是在两次 await 之后才注册的,握手没走完就断线,服务端不会执行清理;- 握手期间服务端会打印完整的 properties,这段日志是排查 worker 能力的最快入口。
1.4 传输层实测
客户端默认配置连接,拿到的是 HTTP 长轮询起步:
[connect] id=zxHm93WzQ5XVnr_qAAAD transport=polling
Socket.IO 4 的默认行为是先 polling 再升级到 websocket,升级不需要业务代码参与。实测中首帧就是 polling,服务端与客户端都正常收发事件;如果你在反向代理上只放行 WebSocket 而掐掉了轮询路径 /socket.io/,连接会在第一步就失败。
二、/client 命名空间
2.1 下行事件(服务端 → 客户端)
连接建立后 initClient() 会连续推一批状态,源码顺序是 config-update → queue-update → presets-update → default-presets-update → queue-status-update → properties-update → watchers-update;此外 AddClient() 会先触发一次 connections-update。实测抓到的顺序与实测载荷大小如下:
[on] connections-update <- 2 keys
[on] config-update <- 4 keys
[on] queue-update <- 0 items
[on] presets-update <- 1 keys
[on] default-presets-update <- 6 keys
[on] queue-status-update <- number:0
[on] properties-update <- 1 keys
[on] watchers-update <- 0 items
| 事件 | 载荷 | 触发时机 |
|---|---|---|
connections-update | { clients: string[], workers: { workerID, connectionID }[] } | 任何客户端或 worker 连接/断开 |
config-update | config.yaml 的完整对象(4 个顶层键) | 连接时、配置被改写后 |
queue-update | 任务数组(jobs ⋈ jobs_status ⋈ jobs_order) | 连接时、任务状态任何变化 |
queue-status-update | 数字:0=Stopped、1=Idle、2=Active | 连接时、队列状态切换 |
presets-update | 自定义预设,按分类分组 | 连接时、预设增删改 |
default-presets-update | 内置预设,按分类分组(实测 6 个分类) | 连接时 |
properties-update | { [workerID]: { version, capabilities } } | 连接时、worker 上报属性后 |
watchers-update | 监听目录与规则数组 | 连接时、监听配置变更 |
queue-update 里单条任务的字段是 jobs、jobs_status、jobs_order 三张表的并集:
| 字段 | 来源 | 说明 |
|---|---|---|
job_id | jobs | 任务 ID,服务端自增 |
input_path / output_path | jobs | 绝对路径 |
preset_category / preset_id | jobs | 如 Default: General / Very Fast 720p30 |
order_index | jobs_order | 排队序号 |
worker_id | jobs_status | 未派发时为 null |
transcode_stage | jobs_status | 0 Waiting、1 Scanning、2 Transcoding、3 Finished、4 Stopped、5 Error、6 Unknown |
transcode_percentage | jobs_status | 0–1 的小数,不是百分数 |
transcode_eta / transcode_fps_current / transcode_fps_average | jobs_status | 仅转码阶段有意义 |
time_started / time_finished | jobs_status | 毫秒时间戳 |
2.2 上行事件(客户端 → 服务端)
| 事件 | 参数 | 是否 ack | 作用 |
|---|---|---|---|
config-update | ConfigType | 否 | 覆写 config.yaml |
start-queue | — | 否 | 把队列状态从 Stopped 拉起,开始派单 |
stop-queue | — | 否 | 停止派单(不杀正在跑的任务) |
clear-queue | finishedOnly: boolean | 否 | 清空队列,true 只清已完成 |
add-job | { input_path, output_path, preset_category, preset_id } | 是(() => void) | 入队 |
stop-job | jobID: number | 否 | 终止某个任务 |
reset-job | jobID: number | 否 | 已结束任务重置回 Waiting |
remove-job | jobID: number | 否 | 删除任务 |
reorder-job | jobID: number, newOrderIndex: number | 否 | 调整排队顺序 |
get-directory | { path, isRecursive } | 是(返回 DirectoryType) | 浏览目录 |
make-directory | { path, name } | 是(返回 boolean) | 新建目录 |
check-name-collision | path, newItems: string[] | 是(返回去重后的数组) | 输出文件名冲突检查 |
add-preset / remove-preset / rename-preset | 预设对象 / 名称、分类 | 否 | 预设维护 |
get-current-version-info | — | 是(返回 GitHub Release 对象或 null) | 当前版本信息 |
get-latest-version-info | — | 是(同上) | 最新版本信息 |
get-watchers | — | 是(返回数组) | 监听列表 |
add-watcher / remove-watcher | 对象 / id | 否 | 监听目录维护 |
add-watcher-rule / update-watcher-rule / remove-watcher-rule | watcherID, rule / ruleID, rule / ruleID | 否 | 监听规则维护 |
规律很清楚:查询类有 ack,命令类全部 fire-and-forget。命令类没有返回值,客户端只能靠随后的 queue-update / queue-status-update 推断结果。
2.3 ack 实测
查询类事件的 ack 都能正常回包,实测输出(路径已按本地实验目录):
--- get-directory ---
[ack get-directory] {"parent":{"path":".../d5/video","name":".../d5","isDirectory":true},"current":{"path":".../d5/video/input"...
--- check-name-collision ---
[ack check-name-collision] ["sample.mkv"]
--- add-job 缺失输入 ---
[on] queue-update <- 1 items
[queue] [{"id":1,"stage":0,"pct":0,"worker":null}]
[ack add-job 缺失输入] ok(服务端未做存在性校验)
get-current-version-info 触发的是真实的 GitHub API 调用,服务端会缓存到 $DATA_PATH/version-info.json。实测首次调用时服务端日志:
[server] [version] The file 'version-info.json' does not exist. Fetching the information about 'v0.8.1' from github...
[server] [version] Fetched information about the current release.
[server] [version] Wrote information about the current release to '.../version-info.json'.
回包内容中可直接取用的字段:
{"tag_name":"v0.8.1","html_url":"https://github.com/TheNickOfTime/handbrake-web/releases/tag/v0.8.1","published_at":"2025-12-10T20:08:10Z","prerelease":false}
2.4 未注册事件被静默丢弃
socket.io 对没有监听器的事件不会报错,也不会回包。用一个不存在的 no-such-event 带 ack 去调,只能等到客户端自己超时:
--- 未定义事件 no-such-event ---
[ack no-such-event] timeout/error: operation has timed out
这一个现象可以解释很多"事件发了但没反应"的排查:事件名拼错时服务端日志一片安静,客户端只会看到超时。
三、/worker 命名空间
3.1 服务端 → worker
| 事件 | 参数 | ack | 说明 |
|---|---|---|---|
get-properties | — | 必须回 WorkerProperties | 握手第一步,无超时 |
check-for-existing-job | — | 必须回 number | null | 握手第二步,用于接续中断任务 |
start-transcode | jobID: number | 必须回 number(当前任务 ID) | 派单;回的 ID 与请求一致才算启动成功 |
stop-transcode | jobID: number | 否 | 让 worker 杀掉 HandBrakeCLI |
get-properties 的期望结构:
{
"version": { "handbrake": "1.11.0", "application": "0.8.1" },
"capabilities": { "cpu": true, "qsv": false, "nvenc": false, "vcn": false }
}
服务端拿到后直接内存保存(AddWorkerProperties),并按能力做派单过滤:预设里的 VideoEncoder 含 qsv 要求 qsv:true,含 nvenc 要求 nvenc:true,其余要求 cpu:true。
3.2 worker → 服务端
| 事件 | 参数 | ack | 说明 |
|---|---|---|---|
get-job-data | jobID | 是(worker 侧 .timeout(5000)) | 拉任务输入/输出/预设信息 |
get-preset-data | presetCategory, presetID | 是(worker 侧 .timeout(5000)) | 拉预设 JSON,写入 /tmp/preset.json |
transcode-update | jobID, status | 否 | 进度,纯推送 |
transcode-finished | jobID, status | 否 | 成功收尾 |
transcode-error | jobID | 否 | 失败收尾(仅在 HandBrake 输出了 WorkDone.Error != 0 时才会发) |
transcode-stopped | jobID | 是(worker 侧 emitWithAck) | 停止后告知服务端 |
send-log | logName, logContents | 否 | 把任务日志整份推给服务端落盘 |
注意方向差异:worker 调服务端的两个查询带 5 秒超时,服务端调 worker 的握手不带超时。
3.3 transcode-update 的四种载荷
worker 解析 HandBrakeCLI 的 --json 输出,按状态机发不同字段组合:
{"transcode_stage":1,"time_started":1761575300000}
{"transcode_stage":1,"transcode_percentage":0.42}
{"transcode_stage":2,"transcode_percentage":0.42,"transcode_eta":11,"transcode_fps_current":240.1,"transcode_fps_average":238.7}
{"transcode_stage":2,"transcode_percentage":0.99}
{"worker_id":null,"transcode_stage":3,"transcode_percentage":1,"transcode_eta":0,"transcode_fps_current":0,"time_finished":1761575315000}
对应 HandBrakeCLI 的 SCANNING / WORKING / MUXING / WORKDONE 四类进度块。worker_id 只会在 Finished 那一条里被置空。
3.4 握手实测
真实 worker(WORKER_ID=worker-01)连接后,worker 端日志:
[worker-01] [socket] Connected to the server 'http://127.0.0.1:9999/worker' with id 'HJtUqsuQN5dkSvAPAAAB'.
[worker-01] [socket] The server is requesting this worker's properties...
[worker-01] [properties] HandBrake Version = 1.11.0
[worker-01] [properties] Capability 'cpu' support = true
[worker-01] [properties] Capability 'qsv' support = false
[worker-01] [properties] Capability 'nvenc' support = false
[worker-01] [properties] Capability 'vcn' support = false
[worker-01] [socket] The server is requesting the status of this worker...
[worker-01] [socket] This worker is currently not busy with a job - reporting to the server.
服务端对应的三条日志:
[server] [socket] Worker 'worker-01' has connected with ID 'HJtUqsuQN5dkSvAPAAAB'.
[server] [socket] Getting worker 'worker-01' properties...
[server] [socket] Worker properties = {
"version": { "handbrake": "1.11.0", "application": "0.8.1" },
"capabilities": { "cpu": true, "qsv": false, "nvenc": false, "vcn": false }
}
[server] [socket] Checking worker 'worker-01' for an existing job in progress...
[server] [socket] Worker 'worker-01' is not busy with an existing job.
四、可运行脚本与真实收发
4.1 观测客户端初始化(probe.js)
const { io } = require('socket.io-client');
const s = io('http://127.0.0.1:9999/client');
s.on('presets-update', (p) => console.log('presets:', JSON.stringify(Object.keys(p))));
s.on('default-presets-update', (p) => console.log('default categories:', Object.keys(p)));
s.on('queue-update', (q) => console.log('queue:', JSON.stringify(q)));
s.on('config-update', (c) => console.log('config:', JSON.stringify(c)));
实测输出(节选):
config: {"config":{"version":2},"paths":{"media-path":"/video","input-path":"/video","output-path":""},"presets":{"show-default-presets":true,"allow-preset-creator":false},"application":{"queue-startup-behavior":0,"update-check-interval":12}}
presets: ["uncategorized"]
default categories: [ 'General', 'Web', 'Devices', 'Matroska', 'Hardware', 'Professional' ]
4.2 入队 + 启动队列的事件流
const s = io('http://127.0.0.1:9999/client');
s.on('connect', () => {
s.timeout(4000).emit('add-job', {
input_path: '/srv/video/input/sample.mp4',
output_path: '/srv/video/output/sample-out.mkv',
preset_category: 'Default: General',
preset_id: 'Very Fast 720p30',
}, (err) => console.log('add-job ack', err));
setTimeout(() => s.emit('start-queue'), 300);
});
s.on('queue-update', (q) => console.log(JSON.stringify(q.map((j) => ({ id: j.job_id, stage: j.transcode_stage, pct: j.transcode_percentage, worker: j.worker_id })))));
实际收到的完整状态流(一条任务从入队到完成):
[queue] [{"id":1,"stage":0,"pct":0,"worker":null}] // Waiting,刚入队
[on] queue-status-update <- number:2 // Active
[queue] [{"id":1,"stage":1,"pct":0,"worker":null}] // Scanning,worker 已接单
[queue] [{"id":1,"stage":1,"pct":0,"worker":"worker-01"}] // worker_id 回填
[queue] [{"id":1,"stage":2,"pct":0.09305555373430252,"worker":"worker-01"}]
[queue] [{"id":1,"stage":2,"pct":0.6041666865348816,"worker":"worker-01"}]
[queue] [{"id":1,"stage":2,"pct":0.9993055462837219,"worker":"worker-01"}]
[queue] [{"id":1,"stage":3,"pct":1,"worker":null}] // Finished
worker_id 在 stage 从 1 起才出现,且 stage 变 3 时又被置空——这正是源码里 transcode-finished 携带 worker_id: null 的结果。
4.3 假 worker:全流程只靠三个 ack
const { io } = require('socket.io-client');
const w = io('http://127.0.0.1:9999/worker', { query: { workerID: 'fake-01' } });
w.onAny((event, ...args) => {
if (event === 'get-properties') {
args[0]({ version: { handbrake: '9.9.9', application: '0.0.0' },
capabilities: { cpu: false, qsv: false, nvenc: false, vcn: false } });
}
if (event === 'check-for-existing-job') args[0](null);
});
实测客户端侧:
[fake-01 connect] id=6p__WTTgTWj714mwAAAB transport=polling
[fake-01 on] get-properties <- [null]
[fake-01 ack] get-properties -> cpu:false, handbrake:9.9.9
[fake-01 on] check-for-existing-job <- [null]
[fake-01 ack] check-for-existing-job -> null
服务端侧照单全收:
[server] [socket] Worker 'fake-01' has connected with ID '6p__WTTgTWj714mwAAAB'.
[server] [socket] Worker properties = {
"version": { "handbrake": "9.9.9", "application": "0.0.0" },
"capabilities": { "cpu": false, "qsv": false, "nvenc": false, "vcn": false }
}
[server] [queue] There are 1 available jobs and 0 eligible for 'fake-01'.
[server] [queue] There are available jobs, but none that require the capabilities of worker with ID 'fake-01'.
[server] [queue] There are no active workers, setting queue to 'Idle'.
一个自报 cpu:false 的假节点就能让所有 CPU 任务无人可用,而服务端只会把队列切到 Idle,不会报错。
五、协议层实测缺陷
以下五条都是本次实测复现的,不是推测。
5.1 无鉴权:任何人都能冒充 worker
/worker 既没有 token 也没有 IP 白名单,workerID 只是 query 里的一个字符串。4.3 的假 worker 用一个随手起名的 fake-01 就通过了握手,还能随意上报 handbrake: 9.9.9 这种不存在的版本。只要 9999 端口暴露在公网,任何人都能注册成节点、接走包含原始素材路径的任务。
5.2 不带 workerID:握手永久挂起,且无法清理
[no-workerID connect] id=_J17pwQuLEPBx3E1AAAE
[no-workerID on] get-properties(故意不应答)
服务端:
[server] [socket] Worker 'undefined' has connected with ID '_J17pwQuLEPBx3E1AAAE'.
[server] [socket] Getting worker 'undefined' properties...
(此后 9 秒无任何日志,直到服务端关闭)
因为 emitWithAck 没有超时、disconnect 监听又在两次 await 之后才注册,这个连接既不会超时断开,断线时也不会触发任何清理。连接对象会一直挂在 connections.workers 里,connections-update 也会把它当成一个合法 worker 推给前端。
5.3 非法任务 ID 直接打崩服务端
对一个不存在的任务调 reorder-job(该事件本身没有 ack,调用方拿不到任何错误):
s.emit('reorder-job', 999, 0);
服务端进程随即退出:
[server] [database] [error] Could not get the order_index for '999' from the jobs_order table.
NoResultError: no result
at _SelectQueryBuilderImpl.executeTakeFirstOrThrow (...)
at async DatabaseGetJobOrderIndexByID (...)
at async Socket3.<anonymous> (...)
Node.js v24.22.1
脚本退出码 1,随后的新连接拿到:
[after] 新连接失败: xhr poll error
原因是 reorder-job 的处理逻辑先 await DatabaseGetJobOrderIndexByID(jobID) 打日志,Kysely 的 executeTakeFirstOrThrow 在无结果时抛 NoResultError,而 socket 回调没有 try/catch,Node 24 对未捕获的 Promise rejection 默认直接结束进程。前端正常点不会传非法 ID,但任何自写脚本、重放报文、并发删除都会踩到。
5.4 add-job 不校验文件是否存在,失败任务会占死 worker
对不存在的输入文件入队,服务端照样插库并回 ack:
[server] [socket] Client 'zxHm93WzQ5XVnr_qAAAD' has requested to add a job for '.../video/input/nope.mp4' to the queue.
[server] [database] Inserted a new job with id '1' into the database at order index '1'.
任务被派发后,worker 侧任务日志:
hb_stream_open: open .../video/input/nope.mp4 failed
scan: unrecognized file type
libhb: scan thread found 0 valid title(s)
No title found.
HandBrake has exited.
但服务端一条状态更新都没收到,客户端在 start-queue 之后只收到这一条 queue-update:
[queue] [{"id":1,"stage":1,"pct":0,"worker":null},{"id":2,"stage":0,"pct":0,"worker":null}]
(随后 12 秒直到脚本结束,第 1 条停在 stage=1,第 2 条停在 stage=0,再没有新的 queue-update)
根因在 worker 的 transcode.ts:handbrake.on('exit') 里只做了两件事——把任务日志发给服务端、销毁 logger:
handbrake.on('exit', () => {
const transport = (jobLogger.transports as CustomTransportType[]).find((t) => t._dest != undefined);
if (transport && transport.dirname && transport.filename) {
const logPath = path.join(transport.dirname, transport.filename);
SendLogToServer(logPath, socket);
}
jobLogger.destroy();
});
transcode-error 只在解析到 WorkDone 块且 Error != 0 时才发;HandBrakeCLI 因为扫不到标题直接退出时根本没有 WorkDone,于是:既不发 transcode-error,也不清 currentJobID。结果是一个坏任务把 worker 永久标成"忙",队列里后面的任务再也派不下去(实测第 2 条任务在 12 秒内始终 stage=0)。任务本身只在服务端关闭时,因为 worker 掉线被改成 Unknown:
[server] [socket] Disconnected worker 'worker-01' was working on job '1' when disconnected - setting job to 'unknown'.
[server] [database] Status for job with id '1' has changed from 'Scanning' to 'Unknown'.
5.5 失败后 worker 关不掉,日志被刷屏
对一个已经退出的子进程再走停止流程,StopTranscode 会卡在:
handbrake.kill();
while (!handbrake.killed) {
logger.info(`[transcode] Still waiting for the HandBrake child process to exit.`);
await new Promise((res) => setTimeout(res, 100));
}
child.killed 表示的是"信号发送成功",子进程早已退出时 kill() 返回 false、标志位不会置位,循环永不结束。实测在 41 秒内刷出 412 行:
22:39:13 [worker-01] [transcode] Waiting for the HandBrake child process to exit.
22:39:13 [worker-01] [transcode] Still waiting for the HandBrake child process to exit.
(……每 100ms 一行……)
22:39:54 [worker-01] [transcode] Still waiting for the HandBrake child process to exit.
进程只能靠 SIGKILL 结束,systemd 的 TimeoutStopSec 到点后才会兜底。
六、自己写对接端时的注意事项
| 场景 | 必须做的事 |
|---|---|
| 自写 worker | 必须应答 get-properties 与 check-for-existing-job,否则服务端连接永久挂起;start-transcode 的 ack 要回当前真正在跑的任务 ID,回错会被服务端判定为"忙"并重新找节点 |
| 自写 worker | 处理"HandBrakeCLI 无 WorkDone 直接退出"的情况,主动补发 transcode-error 或 transcode-stopped,否则任务卡死、节点被占死 |
| 自写客户端 | 命令类事件(start-queue、stop-queue、stop-job、reorder-job、clear-queue)没有 ack,只能靠 queue-update / queue-status-update 判断结果 |
| 自写客户端 | reorder-job 只能传真实存在的 job_id;升级到修复版本前,任何批量脚本都要先校验 ID |
| 反向代理 | 放行 /socket.io/ 的轮询与 WebSocket 两条路径,pingTimeout 只有 5 秒 |
| 安全加固 | 服务端没有内建鉴权,9999 端口必须由外层网关或网络策略限制来源,否则任何人都能注册节点 |
延伸阅读
- 《HandBrake Web 0.8.1 介绍与选型指南》:先弄清 server/worker 分工再对着本文看事件。
- 《Ubuntu 22.04 安装 HandBrake Web 0.8.1 指南》:把两个进程跑起来,再用本文脚本验证。
- 《HandBrake Web 0.8.1 配置参数详解》:
config.yaml字段与本文的config-update事件一一对应。