type
Post
status
Published
date
Sep 27, 2026
slug
summary
本地 link: 挂载能跑不等于可移植;隔离 DSH_HOME、就绪后再守一段,才算验证过
tags
Write
AI Note
DSH 插件开发
category
DSH
icon
password
url
我写 DSH 插件时,一直是把源码目录直接挂到运行中的实例上的:改一行源码立刻生效,不用构建、不用重装、不用管版本。开发体验好得让人上瘾,也正因为太好,它把「别人把这个包装上去之后会发生什么」整条路都挡住了。这篇记的不是某段代码写错了,而是我为什么被迫给自己加了一道「可移植性验证」的门禁——不是洁癖,是被几次真实事故逼出来的。
本地挂载能跑,是因为它替我跳过了三条路
把源码挂上去这种方式,绕过了几乎所有「装到别人机器上才会走到的路」:打包时按清单裁剪出来的压缩包长什么样、装进一个全新的空档案会不会进加载清单、在一台没有我的配置目录的机器上它会把配置写到哪儿、客户端那一半有没有被外壳收进启动清单。这些路一条都不走,本地当然一切正常。
于是我遇到的不是「某个函数算错了」,而是三种只在别人机器上出现的坏法。
第一种是包里漏文件。包描述里声明的那几个入口都能对得上,但有一类文件不走入口映射,是运行时按路径去加载的,它必须被显式写进打包清单,否则打包之后包里根本没有它。我写的一个零依赖分离助手脚本就属于这一类:它是被独立拉起的进程,不在任何入口声明里,全靠打包清单兜住。本地挂载时它就在磁盘上,永远不会缺。
第二种是硬编码本机路径。源码里直接写死自己的家目录;或者配置目录写死成默认值,不认那个可以覆盖的配置根变量。后一种更阴——启动器和救援胶囊会把整个家目录搬走,写死路径的插件就会写到第二个错的家,用户看到的现象是「插件配置丢了」。这件事的正确约定顺序是:插件自己的覆盖变量 → 通用配置根变量 → 默认家目录。
第三种是客户端那一半静默失效。客户端代码是靠运行时模块加载器按包名注册的,而这个包名来自构建配置;改包名的时候它不会跟着变。有一次我批量给一批插件改了名,漏改了这一处,结果是整批二十多个插件一起加载失败,页面报错、设置面板一片空白。这件事在本地挂载下完全看不出来。
造一个「别人的电脑」:先从打包产物装一遍
第一版思路很直白:新建一个干净档案,把打包产物当普通依赖装进去,再看加载清单里有没有这个包名——没有,就是「装上了也不加载」。
跑通以后我卡在第二个问题上:怎么算「它起来了」。
我最初按端口判——健康路由有响应就报成功。这个判据是错的,而且我在两个地方各犯了一次:一个是我自己写的校验脚本,另一个是产品里的重启助手。留下真实记录的是助手上那次假成功:它报「四秒就绪」,页面切过去却打不开,新起的那份宿主其实已经死了,日志里的致命行是等写锁超时。
端口通了不等于活着:一次假成功的解剖
端口那一下是真的,问题在于它太早。宿主是先把端口绑上、再去加载插件树,中间隔着一整个启动过程。写锁超时这类失败要三十秒左右才爆出来,而判据在它爆之前就宣布通过了。
同一个假成功还有第二个来源:没有隔离配置目录。同一台机器上的第二个实例会去等同一个凭证文件的写锁直到超时,然后启动失败。这跟插件本身没关系,但它会让验证结果全是假的——你测的不是插件,是抢锁。
根因一句话:我把「某一刻的临时状态」当成了「就绪」。端口开始监听确实只要一两秒,而带着令牌的那行地址要等应用全部加载完、大约十秒之后才打印;这中间插件树可能还在加载,也可能正在失败。所以判据不能是「某一刻是否应答」,只能是「一段时间内是否稳定」。
再往上一层,是验证姿势本身。挂源码、用我自己的档案、用我自己的配置目录,这三样都在给我的插件开后门:第一种跳过打包裁剪,第二种假装依赖都装好了,第三种假装配置和凭证本来就在。要验可移植性,这三样必须逐个换掉——这也成了后来那份检查清单的第一句话。
判据要设计成「一段时间内稳定」
我把清单写成了一个可执行的校验脚本,放在一个内部工具链里,复制进每个插件仓库,再接上几条常用命令。日常就是跑一条命令、看一个结论。
它按八步走:静态体检;打包并核对声明过的入口是不是真进了包;在隔离出来的临时配置目录里新建空档案、用打包产物安装(不走源码挂载);挑空闲端口起一个实例;打健康路由;抓首页确认客户端那一半进了启动清单;可选的真实动作;最后是就绪后的稳定性观察。
隔离那一步是硬要求,也是默认行为——理由就是上面那个抢锁。最后一步是后来补上的,逻辑很短,用伪代码说就是:
默认守十五秒,每两秒探一次健康路由,顺手比对进程号,再看一眼启动输出有没有冒新的致命行。没撑住就报「就绪后没撑住」,退出码非零。清理也不能只结束自己拉起的那个子进程——真实动作之后监听端口的是助手拉起来的新进程,得按端口杀。
判定口径只有一条:看到明确的「通过」才允许进发布流程。清单末尾的「一票否决项」里,最后一条正是补上的那条——「起来了又死」不得报成功。
这个坑还有一层,比坑本身更值钱:它先在验证脚本里被发现、被修好,却没有回灌进产品。那个插件自己就是重启工具,它的助手当时也只按「TCP 能连上」报就绪,于是把「新宿主起来四秒后死于写锁超时」报成了成功。修法是:就绪 = 端口应答 + 进程存活 + 启动输出无致命行,并且分两个窗口守——先用一个短窗口确认,再用一个长窗口继续守;报了就绪之后死掉,要改判并撤回就绪。这条跟着某一个版本发布了出去。
这套方法能带走的部分
第一,「在我机器上能跑」不是验证,是自证。要换掉三样才算换了个姿势:安装方式(用打包产物装,不是挂源码)、配置目录(隔离)、档案(空的新建)。少换一样,结论就有后门。
第二,不要拿瞬时观测值当就绪判据。端口监听、界面返回成功都是中间态;就绪等于状态在一段时间内稳定。这条对 CI 健康检查、容器探针同样成立。
第三,失败要能说出「哪一项会让别人装不上」。门禁把每一项判成通过/未通过/警告,未通过要带证据,比如「声明的入口没进包」要能列出具体是哪个文件。没有证据的门禁,最后一定会被人绕过。
第四,门禁工具自己的判定边界要写清楚。静态体检是文件级判定:某个文件里出现了配置目录名、而整个文件里没有出现那个可覆盖的变量名,就报警——星号开头的注释行和双斜杠注释行会被跳过,但块注释的第一行不会。所以连描述文案、系统公告这类字符串也要带上「存配置根下(默认家目录下)」的口径。我是被这条反咬过一次,才把几个仓库统一改了一遍。
第五,诚实地写边界。这套工具只在 macOS 上实测过:平台专有的命令必须被平台判断包住,而跨平台声明要写清实测范围——Windows 和 Linux 我没有验证过。
写到这里回头看,这道门禁拦住的从来不是「技术难题」,而是视角问题:我一直站在自己那台已经配置齐全的机器上,去判断一个要运到别人机器上的东西。所谓可移植性验证,就是把视角换掉——换成安装方式、配置目录、运行环境都不是我的那种状态,然后让它在一段时间里自己证明自己还活着。