
ArchiveBox 子进程统一治理基于 Process 模型的 Hook 执行集成方案深度解析【免费下载链接】ArchiveBox Open source self-hosted web archiving. Takes URLs/browser history/bookmarks/Pocket/Pinboard/etc., saves HTML, JS, PDFs, media, and more...项目地址: https://gitcode.com/gh_mirrors/ar/ArchiveBox本文围绕 ArchiveBox 仓库内 old/TODO_Process_cleanup_unification.md 这一技术设计蓝图展开系统梳理其提出的核心问题Hook 子进程无追踪、Process 模型被闲置、重复的子进程管理逻辑完整呈现以 Process 模型作为所有子进程唯一事实来源的统一架构设计、五步迁移路径、收益与风险缓解策略并结合当前仓库中 Process 模型、插件 Hook 运行时 与 ArchiveResult 关联实现 的源码现状说明该方案的落地程度与背后的工程取舍。读完本文你将理解 ArchiveBox 是如何把散落各处的subprocess.Popen调用收敛为可追踪、可观测、可治理的进程生命周期模型并掌握其状态机、PID 追踪、stdout/stderr 采集与优雅终止的设计要点。背景Hook 执行流程中的进程管理痛点当时的执行架构在 ArchiveBox 中网页存档工作由两类 Worker 驱动它们都会调用插件 Hook如on_Snapshot__50_wget.py、on_Snapshot__63_ytdlp.bg.pyOrchestrator ├─ CrawlWorker │ └─ Crawl.run() [state machine started.enter] │ └─ run_hook() for on_Crawl__* hooks │ └─ subprocess.Popen (NOT using Process model) │ └─ SnapshotWorker └─ Snapshot.run() [planned - doesnt exist yet] └─ ArchiveResult.run() [state machine started.enter] └─ run_hook() for on_Snapshot__* hooks └─ subprocess.Popen (NOT using Process model)run_hook()直接调用subprocess.Popen拉起子进程并且不创建任何 Process 记录这是整个方案要解决的核心矛盾。四个核心问题设计文档明确列出当时架构的四个问题无 Process 追踪run_hook()直接使用subprocess.Popen从不创建 Process 记录导致数据库中没有任何关于本次 Hook 子进程的痕迹Process 模型被闲置OrphanedProcess模型上已经实现了.launch()、.wait()、.terminate()等方法却从未被调用手动进程管理SnapshotWorker需要自己用 psutil 去做等待wait与终止kill操作重复逻辑Process模型与run_hook()各自独立维护一套子进程管理逻辑两套实现互不知晓容易产生行为漂移。这四点归纳起来就是一句话进程管理的事实散落在多处缺少单一事实来源Single Source of Truth。统一架构让 Process 模型成为唯一事实来源设计目标方案的核心目标非常明确——让Process模型接管所有子进程相关操作Hook 执行前/后台PID 追踪stdout / stderr 采集进程生命周期管理launch、wait、terminate。薄封装设计run_hook 变为返回 Process 的包装层设计文档给出了run_hook()的目标形态——它不再返回HookResult字典而是返回一个Process模型实例内部完成建记录 → 启动 → 返回三步# hooks.py - Thin wrapper def run_hook(...) - Process: Run a hook using Process model (THIN WRAPPER). Returns Process model instance for tracking and control. from archivebox.machine.models import Process # Build command cmd build_hook_cmd(script, kwargs) # Use Process.launch() - handles everything process Process.objects.create( machineMachine.current(), process_typeProcess.TypeChoices.HOOK, pwdstr(output_dir), cmdcmd, envbuild_hook_env(config), timeouttimeout, ) # Launch subprocess process.launch(backgroundis_background_hook(script.name)) return process # Return Process, not dict关键点在于薄封装run_hook()只负责把Process记录写入数据库关联当前机器、标记为HOOK类型、写入工作目录/命令/环境/超时随后把启动动作委托给process.launch()返回后由调用方持有这个可追踪的句柄。后台 Hook 关闭时的并行优雅终止设计文档特别设计了一个后台 Hook 的on_shutdown流程——这是后台插件.bg.py能正确收尾的关键def on_shutdown(self): Terminate all background hooks in parallel with per-plugin timeouts. Phase 1: Send SIGTERM to all in parallel (polite request to wrap up) Phase 2: Wait for all in parallel, respecting individual plugin timeouts Phase 3: SIGKILL any that exceed their timeout # Send SIGTERM to all processes in parallel for hook_name, process in self.background_processes.items(): os.kill(process.pid, signal.SIGTERM) # Build per-process deadlines based on plugin-specific timeouts deadlines { name: (proc, time.time() max(0, proc.timeout - (time.time() - proc.started_at.timestamp()))) for name, proc in self.background_processes.items() } # Poll all processes in parallel - no head-of-line blocking still_running set(deadlines.keys()) while still_running: time.sleep(0.1) for name in list(still_running): proc, deadline deadlines[name] if not proc.is_running(): still_running.remove(name) elif time.time() deadline: os.kill(proc.pid, signal.SIGKILL) # Timeout exceeded still_running.remove(name)这里有一个非常值得借鉴的细节每个插件拥有独立的超时配置如SCREENSHOT_TIMEOUT60、YTDLP_TIMEOUT300等。有些 Hook如 consolelog、responses收到SIGTERM会立即退出而另一些如 ytdlp、wget需要完整超时窗口来完成实际写入工作。因此关闭策略是Phase 1对所有后台进程并行发送SIGTERM礼貌请求收尾Phase 2并行轮询等待每个进程按自身剩余超时计算独立 deadline避免头端阻塞head-of-line blockingPhase 3超过各自 deadline 的进程直接SIGKILL。源码纵深当前仓库中 Process 模型的生命周期实现这份蓝图设计的方法在现仓库中已经落地为完整实现位置在 archivebox/machine/models.py 的Process类第 993 行起。下面逐一对照蓝图代码与真实实现。状态机与类型定义Process继承ModelWithDeleteAfter其生命周期状态与蓝图中的设计完全一致见 Process 模型定义class StatusChoices(models.TextChoices): QUEUED queued, Queued # 已入队准备启动 RUNNING running, Running # 正在执行 EXITED exited, Exited # 已退出看 exit_code 判断成败 class TypeChoices(models.TextChoices): SUPERVISORD supervisord, ... ORCHESTRATOR orchestrator, ... SERVER server, ... UPDATE update, ... ADD add, ... SEARCH search, ... WORKER worker, ... # ... 还包括 HOOK 等类型从源码结构看Process不仅覆盖 Hook 子进程还覆盖 supervisord、orchestrator、server、worker 等 ArchiveBox 各类常驻/临时进程这与单一事实来源的目标一脉相承。launch()启动并记录真实实现的launch()machine/models.py#L2085-L2159比蓝图更健壮要点包括校验self.pwd必须已设置用于确定输出目录stdout/stderr 追加写入self.stdout_file/self.stderr_fileruntime_dir/stdout.log、runtime_dir/stderr.log见 stdout_file/stderr_file 属性通过subprocess.Popen(cmd, cwdworking_dir, stdoutout, stderrerr, envself._build_env())启动优先用 psutil 获取精确的create_time()作为started_at比timezone.now()更准落库pid、started_at、statusRUNNING前台模式backgroundFalse会proc.wait(timeoutself.timeout)TimeoutExpired时proc.kill()并以128 signal.SIGKILL记录退出码Unix 惯例退出后回读 stdout/stderr 文本、置statusEXITED并保存。wait()轮询等待真实实现的wait()machine/models.py#L2247-L2276引入了对 Hook 类型的全局硬上限保护timeout timeout or self.timeout if self.process_type self.TypeChoices.HOOK: timeout min(int(timeout), int(CONSTANTS.MAX_HOOK_RUNTIME_SECONDS))其中MAX_HOOK_RUNTIME_SECONDS定义在 archivebox/config/constants.py#L137值为60 * 60 * 1212 小时防止个别 Hook 无限期挂死。轮询过程中调用self.poll()判断是否已退出超时抛出TimeoutError。terminate()SIGTERM → 等待 → SIGKILL 三级降级真实实现的terminate()machine/models.py#L2278-L2337正是蓝图中优雅终止的落地方案且通过self.proc经 start-time 校验的 psutil 句柄避免误杀 PID 被复用的进程进程已不存在则仅更新状态返回False发送proc.terminate()SIGTERM等待graceful_timeout默认 5 秒超时则升级proc.kill()SIGKILL以128 signal.SIGKILL记录退出码。docstring 明确指出该逻辑整合了runner_watch.py、workers/pid_utils.py stop_worker()、supervisord_util.py等三处此前各自实现的 SIGTERM/SIGKILL 逻辑——这正是消除重复逻辑目标的直接证据。is_running() 与资源观测is_runningmachine/models.py#L1815-L1834优先用 psutil 验证 OS 进程真实存在并与记录匹配且将僵尸进程STATUS_ZOMBIE视为已退出比仅看status字段更可靠get_memory_info()machine/models.py#L1842-L1851返回{rss: ..., vms: ...}内存快照get_children_pids()machine/models.py#L1863-L1871从 OS 递归获取子进程 PID另有kill_tree()machine/models.py#L2339并行终止整棵进程树整合了core/takeover_util.py与supervisord_util.py中的逻辑。五步迁移路径完整继承设计文档给出了一条分步推进、可回滚的迁移路径Step 1完善 Process.launch()已完成蓝图标注为DONEProcess模型的.launch()、.wait()、.terminate()方法当时已在machine/models.py实现蓝图记录行号 1295-1593现版本位于 archivebox/machine/models.py#L2085 起。Step 2重构 run_hook() 为 Process 薄封装文件蓝图中的archivebox/hooks.py在当前仓库中该职责已演化为 archivebox/plugins/hooks.py 的插件适配层签名变化run_hook(...) - HookResult返回 dict→run_hook(...) - Process返回模型实例实现要点def run_hook(script, output_dir, config, timeoutNone, **kwargs) - Process: from archivebox.machine.models import Process, Machine # Build command cmd build_hook_cmd(script, kwargs) env build_hook_env(config) is_bg is_background_hook(script.name) # Create Process record process Process.objects.create( machineMachine.current(), process_typeProcess.TypeChoices.HOOK, pwdstr(output_dir), cmdcmd, envenv, timeouttimeout or 120, ) # Launch subprocess process.launch(backgroundis_bg) return process注意timeout or 120的默认兜底值与is_background_hook(script.name)对前后台的分流——后台 Hook 在文件名层面即可识别。Step 3Worker 全面改用 Process 方法蓝图建议SnapshotWorker用Process.wait()替代手写 psutil 逻辑并给出落地的调用形态class SnapshotWorker: def _run_hook(self, hook_path, ar) - Process: Fork hook using Process model. process run_hook( hook_path, ar.create_output_dir(), self.snapshot.config, urlself.snapshot.url, snapshot_idstr(self.snapshot.id), ) # Link ArchiveResult to Process ar.process process ar.save() return process def _wait_for_hook(self, process, ar): Wait using Process.wait() method. exit_code process.wait(timeoutNone) # Update AR from hook output ar.update_from_output() ar.status ar.StatusChoices.SUCCEEDED if exit_code 0 else ar.StatusChoices.FAILED ar.save()这里出现的关键模式是ArchiveResult ↔ Process 的一对一关联ar.process process后ArchiveResult 的全部进程派生信息cmd、pwd、binary、machine、timeout 等都可以从 Process 单一来源读取。Step 4ArchiveResult.run() 适配新 run_hook()蓝图记录了archivebox/core/models.py的改造前后对照当时行号 2559# 改造前返回 HookResult dict靠 None 判断后台 Hook result run_hook(...) # Returns HookResult dict if result is None: is_bg_hook True # 改造后直接返回 Process状态一目了然 process run_hook(...) # Returns Process self.process process self.save() if process.status Process.StatusChoices.RUNNING: # Background hook - still running return else: # Foreground hook - completed self.update_from_output()这一改造把是否后台的判断从隐式的None返回值升级为显式的Process.status RUNNING状态查询语义清晰得多。Step 5Crawl.run() 同构迁移蓝图标注archivebox/crawls/models.py当时行号 374采用与ArchiveResult.run()完全相同的模式无需另起炉灶。收益统一之后得到什么1. 单一事实来源Process模型接管全部子进程操作run_hook()、Process、Worker 之间不再存在重复逻辑PID 追踪、stdout/stderr 处理行为完全一致。2. 正确的进程层级通过Process.parent_id可以构建整棵进程树Orchestrator (PID 1000) └─ CrawlWorker (PID 1001, parent1000) └─ on_Crawl__01_chrome.js (PID 1010, parent1001) └─ SnapshotWorker (PID 1020, parent1000) └─ on_Snapshot__50_wget.py (PID 1021, parent1020) └─ on_Snapshot__63_ytdlp.bg.py (PID 1022, parent1020)这让哪个插件由哪个 Worker 拉起成为可查询的数据关系。3. 更好的可观测性蓝图给出的查询能力在现仓库中已可实际使用查询某快照全部 Hook 进程snapshot.process_set.all()——对应 Snapshot.process_set 属性按Process.objects.filter(archiveresult__snapshot_id...)实现统计运行中进程Process.objects.filter(statusrunning).count()资源占用Process.get_memory_info()。4. 更干净的代码蓝图给出了量化的精简预期按当时的实现估算SnapshotWorker._wait_for_hook25 行 → 8 行SnapshotWorker.on_shutdown12 行 → 7 行run_hook()约 200 行 → 约 50 行总计节省约 100 行代码。风险与缓解策略Risk 1破坏既有 run_hook() 调用方缓解——三阶段滚动发布Phase 1新增run_hook_v2()返回ProcessPhase 2逐个迁移调用方到run_hook_v2()Phase 3旧函数改名run_hook_legacy、新函数夺回run_hook名字最后删除 legacy。这种先加后迁再换名的路径保证任意时刻仓库都处于可运行状态。Risk 2后台 Hook 追踪行为变化缓解Process.launch(backgroundTrue)原生支持异步启动Process.wait()内部轮询完成状态对外行为与原先直接subprocess.Popen保持一致。Risk 3额外数据库写入带来的性能开销缓解Process 记录原本就在创建只是未被使用新增开销有限尽量批量更新通过指标持续监控。时间线规划原方案的三段式Immediate当时 PR状态机修复✅ 已完成、步骤推进优化✅ 已完成、本文档统一架构说明落库Next PRProcess 集成新增run_hook_v2()→ 更新SnapshotWorker→ 迁移ArchiveResult.run()与Crawl.run()→ 废弃旧run_hook()Future删除run_hook_legacy、增加Process.get_tree()做层级可视化、引入ProcessMachine状态机统一管理生命周期。现状对照方案在仓库中的落地程度结合当前仓库源码可以看到这份蓝图的大部分目标已经实现以下均为可在仓库中直接验证的事实Process 模型已是活动模型Process具备完整的launch/wait/terminate/kill_tree/is_running/get_memory_info生命周期方法archivebox/machine/models.py#L1815-L2376并拥有queued/running/exited状态机ArchiveResult 已与 Process 关联ArchiveResult.process_id外键存在且cmd、pwd、cmd_version、binary、machine、timeout等属性全部改为从关联的Process派生archivebox/core/models.py#L4509-L4556这正是单一事实来源的直接体现Hook 执行已统一为记录优先ArchiveResult的进程路径中启动前即以Process.objects.create(machine..., process_typeHOOK, worker_typearchiveresult, ...)写入记录archivebox/core/models.py#L1927-L1935与蓝图设计的薄封装一致Hook 运行时代已演进蓝图中的archivebox/hooks.py在当前仓库已演化为 archivebox/plugins/hooks.py ——Hook 的发现与执行移交给 abx-dl 插件运行时ArchiveBox 保留 Django 投影适配器discover_hooks、is_background_hook、collect_urls_from_plugins同时Process.parse_records_from_text被用于解析插件输出的urls.jsonl说明 Process 记录与插件产物解析链路也已打通。可以推断蓝图中的Process 集成阶段Next PR已基本完成剩余的可选增强项如Process.get_tree()层级可视化、ProcessMachine状态机封装则属于后续演进空间读者可在仓库中持续追踪。小结old/TODO_Process_cleanup_unification.md是一份质量很高的进程治理设计文档它先精准定位了子进程无追踪、模型被闲置、管理逻辑重复四个问题再给出单一事实来源的统一架构与薄封装设计最后用五步迁移、三阶段发布、独立超时的并行关闭策略把风险压到可控。对照当前仓库源码可以发现这套设计不仅停留在纸面其核心方法论Process 状态机、PID 追踪、stdout/stderr 采集、SIGTERM→SIGKILL 降级、通过外键派生 ArchiveResult 进程属性已经完整落地对任何需要统一管理插件/Worker 子进程的系统都有直接的参考价值。如需进一步深入建议从 Process 模型实现 与 ArchiveResult 进程关联 两处源码切入并结合 Hook 适配层 理解插件运行时与进程模型的协作边界。【免费下载链接】ArchiveBox Open source self-hosted web archiving. Takes URLs/browser history/bookmarks/Pocket/Pinboard/etc., saves HTML, JS, PDFs, media, and more...项目地址: https://gitcode.com/gh_mirrors/ar/ArchiveBox创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考