HandBrake Web 0.8.1 Socket.IO 事件协议与内部接口详解

从源码与实测两条线拆解 HandBrake Web 0.8.1 的 Socket.IO 协议:/client 与 /worker 全部事件、载荷与 ack 方式,并复现无鉴权、握手无超时、非法任务 ID 打崩服务端、失败任务占死 worker 等缺陷。

HandBrake WebHandBrake视频转码Node.js运维自动化2026/10/27

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.jsv24.22.1
socket.io / socket.io-client4.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-* 等监听
});

关键点有三个:

  1. 两个 emitWithAck 没有 .timeout(),worker 不回包就会永远等下去;
  2. disconnect 监听是在两次 await 之后才注册的,握手没走完就断线,服务端不会执行清理;
  3. 握手期间服务端会打印完整的 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-updateconfig.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_idjobs任务 ID,服务端自增
input_path / output_pathjobs绝对路径
preset_category / preset_idjobs如 Default: General / Very Fast 720p30
order_indexjobs_order排队序号
worker_idjobs_status未派发时为 null
transcode_stagejobs_status0 Waiting、1 Scanning、2 Transcoding、3 Finished、4 Stopped、5 Error、6 Unknown
transcode_percentagejobs_status0–1 的小数,不是百分数
transcode_eta / transcode_fps_current / transcode_fps_averagejobs_status仅转码阶段有意义
time_started / time_finishedjobs_status毫秒时间戳

2.2 上行事件(客户端 → 服务端)

事件参数是否 ack作用
config-updateConfigType否覆写 config.yaml
start-queue—否把队列状态从 Stopped 拉起,开始派单
stop-queue—否停止派单(不杀正在跑的任务)
clear-queuefinishedOnly: boolean否清空队列,true 只清已完成
add-job{ input_path, output_path, preset_category, preset_id }是(() => void)入队
stop-jobjobID: number否终止某个任务
reset-jobjobID: number否已结束任务重置回 Waiting
remove-jobjobID: number否删除任务
reorder-jobjobID: number, newOrderIndex: number否调整排队顺序
get-directory{ path, isRecursive }是(返回 DirectoryType)浏览目录
make-directory{ path, name }是(返回 boolean)新建目录
check-name-collisionpath, 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-rulewatcherID, 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-transcodejobID: number必须回 number(当前任务 ID)派单;回的 ID 与请求一致才算启动成功
stop-transcodejobID: 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-datajobID是(worker 侧 .timeout(5000))拉任务输入/输出/预设信息
get-preset-datapresetCategory, presetID是(worker 侧 .timeout(5000))拉预设 JSON,写入 /tmp/preset.json
transcode-updatejobID, status否进度,纯推送
transcode-finishedjobID, status否成功收尾
transcode-errorjobID否失败收尾(仅在 HandBrake 输出了 WorkDone.Error != 0 时才会发)
transcode-stoppedjobID是(worker 侧 emitWithAck)停止后告知服务端
send-loglogName, 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 端口必须由外层网关或网络策略限制来源,否则任何人都能注册节点

延伸阅读