34 的 HTTP 实验在测试应用与回环端口证明了 202 先于后台任务完成,但那时服务由测试管理;测试结束时由调用方手动停引擎、停内存任务。把类装进发行包后,真实 JVM 收到终止请求的顺序由谁保证?单看“进程没了”无法判断:请求可能仍被受理,挂起任务可能没等清理完成,打印的终态也可能与实际资源退出不一致。

本篇沿用 Kotlin 2.2.21、Ktor 3.3.1、Gradle 8.14、JDK 21。依据是 Gradle application 插件的 installDist/distZip、Ktor embeddedServer 的服务配置、JDK 21 Runtime.addShutdownHook(2026-10-10 核对)。下述只运行本机 127.0.0.1 的合成任务,不构成公网部署。

发行包里有两个不同的入口

累计工程从 00 起的 main(args) 仍是校验 submit alpha 的教学 CLI:它接受输入,不执行后台任务。修改 application.mainClass 去指向 HTTP 服务,会让已经回归了几十篇的 CLI 正反例失去原来的语义。38 新增 TaskServerMain,通过 Gradle application 插件的额外 CreateStartScripts 在同一个 installDist/distZip 下生成 bin/task-server,保留 bin/kotlin-task-language。发行包内有应用 JAR 与运行依赖;verification/38/zipContents.txt 可核对真实清单。启动命令形如:

1
2
./gradlew --offline --no-daemon --console=plain installDist distZip
./build/install/kotlin-task-language/bin/task-server 18080

端口是启动参数,校验为 1–65535,绑定地址固定为回环口。打包和启动只是两件事:包存在不表示进程已经对外监听,端口可连也不证明关闭时所有任务已完结。实际分发 ZIP 下载 task-server 发行包包含启动脚本和运行库;同名素材目录有 SHA 与本地复跑命令,禁止把下载文件本身误称已部署。

接收请求与执行任务怎样关联

POST /tasks 的 JSON 草稿含合成 task ID,请求另有 X-Request-Id。只接受不超过 64 位的字母、数字、_、-;缺失或格式不符时生成本地 ID,不允许直接把带换行的头写进服务日志。受理时把请求 ID 与任务 ID 一起存入内存 registry 的 Entry;任务运行时 onRun 从这个显式条目读取两个 ID 并记录 TASK_RUN request_id=... task_id=...。这里没有依赖“同一个 ThreadLocal 过一次挂起还在”的假设。返回头 X-Request-Id 表示这次 HTTP 提交的标识,不是后台 Job 的成功回执。

在测试应用中用 req-38 提交 task-38,受理返回 202;后台读取到同一对 ID。随后调用 stopAccepting(),再次 POST 得 503,之前的任务交给 stop() 取消和等待,状态转为 Cancelled。原始 TaskServerLifecycleTest.txt 包含受理、拒绝、终态和 ID 断言。停止提交先于等待已有任务结束:若反过来做,正在等任务的服务仍可能收进更多工作,形成永远追不完的尾巴。

发终止信号后留下的证据

发行包的进程测试不是 Ktor testApplication:从 build/install/.../bin/task-server 实际启动另一个 JDK 21 JVM,以 HTTP 在回环口提交 packaged-38。其合成文本 wait 令步骤进入可取消挂起,用于在进程终止时保留未完成任务;平常的任务文本仍直接得到 Succeeded。测试确认 POST 返回 202 且响应头回显 req-38-package,还等到了后台 TASK_RUN 日志,然后对该 JVM 发送终止请求。

关闭钩子先关闭提交闸门,再对 Netty 调用 stop(500, 5000);之后对 registry 的 Job 树 cancelAndJoin(),把内存快照里的每条任务打印为 TASK_FINAL;最后关闭单线程 dispatcher,等待执行器线程终止。测试记录:

1
2
3
4
5
PACKAGE real_post=202 request_id=req-38-package task_id=packaged-38 final=Cancelled port_closed=true dispatcher_closed=true exit=143
TASK_RUN request_id=req-38-package task_id=packaged-38
SERVICE_STOP_ACCEPTING port=<本机临时端口>
TASK_FINAL request_id=req-38-package task_id=packaged-38 state=Cancelled
RESOURCE_CLOSED dispatcher=true worker=true engine=true

port_closed=true 来自子进程退出后再次连接原回环口失败;worker=true 来自执行器的 awaitTermination;engine=true 表示 server.stop 返回,不代表逐条审计过 Netty 里所有线程。当前 Linux/JDK 对该终止请求的进程退出码实测为 143,这不是 Kotlin 对所有平台的保证。原始测试 XML、命令和退出码见 verification/38/,先前一次用进程管道采集日志在销毁进程时丢了钩子输出,也保留失败 XML;最终改用独立日志文件,避免“日志没读到”被误诊为业务没有清理。

这条关闭路径针对 38 历史快照中的可协作取消合成任务。如果步骤卡在任意不响应取消的原生阻塞调用里,cancelAndJoin() 也可能迟迟不返回,不能把 server.stop 的超时参数误认为全进程的硬超时。SIGKILL 不执行 shutdown hook;任务终态打印在 stdout,并未存入数据库。该 38 快照没有给 HTTP registry 接上 35 的 JDBC Store;39 会在后续整合路径中讨论 TASK_DB_URL、HTTP 与持久状态。38 本身的服务重启后恢复 Job、持久任务状态、外网流量与进程崩溃后的可重试性均 NOT_RUN。

练习与答案

练习一:关闭顺序为什么不是先 owner.cancelAndJoin(),再停接收?答案:先取消等待会让服务窗口继续受理新任务,尾部工作可能越追越多。38 的顺序是先 stopAccepting() 拒绝新提交,再 engine.stop 关闭 HTTP 接收面,随后 owner.cancelAndJoin() 等协作任务清理,最后关闭执行器。

练习二:如果任务步骤是一个不检查取消的阻塞 JDBC 调用或原生调用,owner.cancelAndJoin() 能否保证快速返回?答案:不能。协程取消需要阻塞点协作或额外中断机制;38 只实跑可取消挂起步骤,不写阻塞不协作的通过结论。

练习三:把终止信号换成 SIGKILL,还能看到 TASK_FINAL 和 RESOURCE_CLOSED 吗?答案:不能按 38 证据期待。SIGKILL 不执行 shutdown hook;该反例未实跑,按操作系统语义列为不能由本篇验收的路径。

bash examples/kotlin-language/verification/38.sh 执行累计 JUnit、隔离 JVM 回环、真实发行包进程、ZIP 清单、上一章 JMH 与 CLI 正反例;归档解包也需单独复跑。source 与资源日志在 完整累计工程快照,任务服务不是 CLI 的秘密开关:要复现运行中的 HTTP,使用发行脚本并显式提供本地端口。下一篇才将成功、错误、重复提交、取消与重启等路径合并为一个检查清单;不能用关闭实验替代它们。