CHAPTER 03 · Fetch Runtime

从求值器到 Fetch Handler

上一章只能执行一段表达式。本章把它推进成可访问的 HTTP 服务:C++ 创建 Request,调用 JavaScript worker.fetch(request, env, ctx),等待返回的 Promise,最后把 Response 写回客户端。

本章新增:Runtime 它在第二章 Engine 的基础上长期持有 Isolate、Context、worker 和 CppHeap,并负责每一次 C++ → JavaScript 调用的 HandleScope、ContextScope、异常边界和微任务检查点。

本章新增:最小 Fetch API。 Request 暂时只有 methodurl;Response 支持 body 和 status;env 传入空对象。本章不做中间件和存储,因为它们与理解一次请求的异步生命周期无关。

本章新增:setTimeoutExecutionContext setTimeout 让 handler 真正异步等待;ctx.waitUntil(promise) 登记响应后的后台工作,但不会把它错误地拼进响应 Promise。

1. 先建立第三章工程

第三章是独立可编译工程,不覆盖第二章。目标分为运行时静态库、HTTP 程序和单元测试程序。

完整文件 · ch03/CMakeLists.txt
正在加载源码...
export LAB_ROOT=/home/zq/v8v8
CMAKE="$LAB_ROOT/.deps/cmake-3.31.8/bin/cmake"
SOURCE="$LAB_ROOT/v8-mod-tutor/src/code/ch03"
BUILD="$SOURCE/build-v137"

"$CMAKE" -S "$SOURCE" -B "$BUILD" -GNinja \
  -DCMAKE_TOOLCHAIN_FILE="$LAB_ROOT/v8-mod-tutor/toolchain/v8-pinned.cmake" \
  -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_PREFIX_PATH="$LAB_ROOT/.deps/v137"
"$CMAKE" --build "$BUILD"
步骤测试 3.1 · 构建三个目标
预期输出
首次构建最后依次出现 libfetch_runtime.afetch-runtime-testv8-fetch 的 Linking 行。
原因
这证明 Runtime 同时能被测试程序和真实 HTTP 程序复用,V8、CppGC、KJ async 与 KJ HTTP 均已完成链接。

2. 把 CppHeap 接入 Isolate

Runtime 不再每个对象临时初始化 V8。Platform 是进程级单例,多个测试 fixture 各自创建 Isolate;每个 Isolate 又连接自己的 CppHeap。Platform 只在进程退出时释放,因为 V8 不允许 Dispose 后在同一进程重新初始化。

完整文件 · ch03/src/runtime.h
正在加载源码...
完整文件 · ch03/src/runtime.c++
正在加载源码...
步骤测试 3.2 · 进程与 Isolate 生命周期
运行
"$BUILD/fetch-runtime-test"
预期输出
六个 case 连续运行,不出现 kPlatformDisposed 或 HandleScope fatal error。
原因
每个 fixture 都销毁自己的 Context、Isolate 和 CppHeap,但共享尚未释放的进程 Platform;异步回调也只在建立 HandleScope 后解引用 Global handle。

3. 实现可取消的 setTimeout

TimerQueue 给每个回调分配数字 id,用 v8::Global<Function> 保持回调可达,再把 KJ timer Promise 放入 TaskSet。到期时先从表中移出回调,再进入 Runtime 调用;clearTimeout(id) 只需提前移除它。

完整文件 · ch03/src/timer-queue.h
正在加载源码...
完整文件 · ch03/src/timer-queue.c++
正在加载源码...
步骤测试 3.3 · 到期与取消
预期输出
到期、取消和“带 pending timer 退出”三个 case 都显示 [PASS]
原因
虚拟时钟推进到 29ms 时 active timer 仍为 1;第 30ms 回调执行并归零。取消测试证明被移除的 Global 没有被调用;退出测试证明剩余 Global 会在 Isolate 销毁前清空。

4. 模拟 Fetch API

加载的脚本把 handler 放到 globalThis.worker。这避免提前引入 ES module loader,把注意力留在请求生命周期;第四章仍沿用这一约定。

完整文件 · ch03/worker/index.js
正在加载源码...

Runtime 为每个请求创建三个参数:带 method/url 的 Request 对象、空 env 对象、带 waitUntil() 的 ExecutionContext 对象。Response 构造器生成普通 JavaScript 对象;这已经足以教学和测试状态、正文及异步返回,不假装实现完整浏览器标准。

dispatch() 遇到同步 Response 就立即读取;遇到 Promise 则每个虚拟毫秒检查一次状态。定时器回调执行后,显式的 PerformMicrotaskCheckpoint() 会恢复 async fetch()

步骤测试 3.4 · 强制等待三秒
预期输出
[PASS] ... fetch response waits three seconds
原因
时钟停在 2999ms 时 response Promise 的 poll() 为 false;推进到 3000ms 后返回 200 / ready。测试使用虚拟时间,所以不需要真的等待三秒。

5. ExecutionContext 不阻塞响应

ctx.waitUntil(promise) 给 Promise 同时安装 fulfill/reject 回调,并增加后台任务计数。它不改变 fetch 返回的 Promise;后台 Promise settle 后计数才减一。

完整文件 · ch03/src/execution-context.h
正在加载源码...
完整文件 · ch03/src/execution-context.c++
正在加载源码...
步骤测试 3.5 · waitUntil 生命周期
预期输出
[PASS] ... waitUntil outlives the response
原因
响应正文 sent 已经可读时后台计数仍为 1;虚拟时钟再推进 250ms 后计数变成 0,因此后台工作属于请求上下文,但没有拖延响应。

6. 把 Runtime 接到 KJ HTTP

FetchService 只做协议适配:将 KJ method/url 交给 Runtime,收到 FetchResponse 后设置 Content-Type、Content-Length 并写 body。JavaScript 细节不会泄漏到网络层。

完整文件 · ch03/src/fetch-service.h
正在加载源码...
完整文件 · ch03/src/fetch-service.c++
正在加载源码...
完整文件 · ch03/src/main.c++
正在加载源码...

异常测试先让 fetch 抛出 Error('boom'),随后在同一个 Isolate 中加载正常 handler。

步骤测试 3.6 · 请求错误隔离
预期输出
[PASS] ... a thrown fetch does not poison the isolate,最终汇总 6 test(s) passed
原因
TryCatch 将异常转换为当前 dispatch 的 KJ exception;重新加载 handler 后仍返回 ok,说明进程级 V8 状态没有被一次坏请求破坏。

完整测试文件如下。它使用 kj::TimerImpl,因此 30ms、250ms 和 3000ms 都是可精确推进的虚拟时间。

完整文件 · ch03/test/fetch-runtime-test.c++
正在加载源码...
"$BUILD/fetch-runtime-test"
"$LAB_ROOT/.deps/cmake-3.31.8/bin/ctest" \
  --test-dir "$BUILD" --output-on-failure

7. 真实端口验收

启动服务:

"$BUILD/v8-fetch" "$SOURCE/worker/index.js" 127.0.0.1:8080

另一个终端请求 /demo

curl --fail --silent --show-error \
  --write-out '\nstatus=%{http_code} starttransfer=%{time_starttransfer}s\n' \
  http://127.0.0.1:8080/demo
章节验收 · 真实三秒响应
预期输出
正文为 hello after 3 seconds: /demo,状态为 200,starttransfer 约为 3.00 秒。
原因
真实 KJ timer 在三秒后 resolve,V8 微任务恢复 async fetch,FetchService 此时才调用 response.send()。随后登记的 250ms waitUntil 继续执行,不影响已经发出的响应。

下一章不会推倒这个 Runtime,而是在它上面增加 SPA 分流、WebSocket、内存聊天室和持久日志。