JSVM2:return/break/continue 怎么传,Signal 信号与 finally 的坑

📅
2 分钟阅读
·

前两篇说明了骨架(visitor、Path 与作用域链)和变量(提升与闭包)。本文处理语句执行中的控制转移:throw、return、break 和 continue。在 jsvm2 中,它们通过两条不同通道传播。

throw 走宿主通道

throw 的实现只有三行(tryCatch.ts:6-9):

export function ThrowStatement(path: Path<t.ThrowStatement>) {
  // TODO: rewrite the stack log
  throw path.visitor(path.createChild(path.node.argument));
}

先求出要抛的值,再直接抛给宿主。TryStatement 用宿主 try 包住 block 的求值,宿主 catch 取得 err,用 declareLet 绑定到 catch 参数后再求 handler 节点(tryCatch.ts:15-33)。错误对象、catch 参数绑定和异常向调用栈传播均由宿主处理。沙箱中 throw new Error('x') 抛出的是宿主 Error 实例;new 表达式的实现也复用宿主行为,第 5 篇会说明。TODO 注释表明:抛出的 stack 是宿主执行栈,尚未重写为沙箱代码行号。

throw 可以复用宿主异常机制:异常跨调用层传播,并由对应的 catch 拦截。return/break/continue 做不到这一点:visitor 递归里每一层求值函数都有自己的 return,沙箱函数的 return 没法映射成宿主递归中途的 return。理论上也可以把它们包成异常抛出去,一些解释器确实这么干,但当时否掉了,理由有两个。一是 break/continue 要按 label 条件决定在哪一层停下,异常机制不支持条件拦截,只能在每一层 catch 里判类型再 rethrow,比直接返回一个对象更绕。二是控制流信号和真实错误会混在同一个通道里,沙箱代码 try { break; } catch (e) {} 这种写法会把 break 信号当真错误吞掉,还要额外加标记位区分。因此 return、break 和 continue 使用 Signal,并通过普通函数返回值向上传播,与异常通道分离。

Signal 与 Completion Record 的对应关系

signal.ts 全文 21 行(signal.ts:1-21):

export type SignalKind = 'break' | 'continue' | 'return';

export class Signal {
  constructor(public kind: SignalKind, public value?: any) {}

  public static is(v: any, type: SignalKind): v is Signal {
    return v instanceof Signal && v.kind === type;
  }
  // isBreak / isContinue / isReturn 三个快捷守卫
}

ECMA-262 里每条语句执行完产生一个 Completion Record:[[Type]] 取 normal/break/continue/return/throw,[[Value]] 带值,[[Target]] 带 label。Signal 是它的极简投影:kind 对应 [[Type]],value 一个字段同时当 [[Value]] 和 [[Target]] 用,return 时它是返回值,break/continue 时它是 label 名。normal 和 throw 两种类型不存在,throw 走了上一条通道,normal 就是普通返回值。规范里 normal completion 也携带值,语句序列的值是最后一条产生值的语句的结果,eval 靠这个返回值。jsvm2 保留了这一点:BlockStatement 求完每条语句都覆盖 result,最后把它返回(statement.ts:66-79),注释里写着是为了支持 do-expression;Program 同样把最后一条语句的结果作为整个程序的返回值(program.ts:50)。所有求值函数都可能返回普通值或信号,处理子节点结果时必须判断其类型。value 同时保存返回值和 label 名,因此每个消费点都需按信号类型解释该字段;后文的两个 bug 与此相关。

产生信号的地方在 signalStatement.ts:5-20:ReturnStatement 求出 argument 包成 Signal('return', 值),没有 argument 时值就是 undefined,对应裸 return;;Break/Continue 把 label 名(没有就是 undefined)装进 value。

之后每个复合语句都要做同一件事:拿到子节点的求值结果,判是不是 Signal,自己认就消费,不认就原样 return 给上层。BlockStatement(statement.ts:70-72)、SwitchCase(conditional.ts:56-58)、四种循环,全是这个模式。解释器里的「顺序执行」其实是信号在 visitor 递归里的接力,任一层未继续返回未消费的信号,都会改变控制流语义。

这套协议有个工程上的弱点:所有求值函数的返回类型都是 any,TypeScript 帮不上忙,「这里该判 Signal 而没判」这类错误编译器一个都抓不到,只能靠 Signal.isXxx 这几个类型守卫在每个消费点手工补判。第 2 篇说过 visitor 分发靠 mapped type 在编译期保证节点必有 handler,那是声明层面的完备;信号协议是值层面的约定,没有同等的编译期保护。后面两个 bug 从根上说是这个弱点的兑现。

label 协议:贴标签的和认标签的分离

label 的设计拆成两半。LabeledStatement 自己不消费循环信号,只负责贴标签(statement.ts:16-23):

export function LabeledStatement(path: Path<t.LabeledStatement>) {
  const label = path.node.label as t.Identifier;
  const res = path.visitor(path.createChild(path.node.body, path.scope, { labelName: label.name }));
  if (res && Signal.isBreak(res) && res.value === label.name) {
    return undefined;
  }
  return res;
}

label 名通过 ctx.labelName 传给子节点。ctx 的浅拷贝是第 2 篇讲过的不可变下行通道,父节点改 ctx 不影响兄弟节点。末尾那三行处理 labeled block:foo: { break foo; },标签贴在普通块上,块里 break 这个标签,在这里直接消费掉,返回 undefined 当无事发生。

认标签的是循环。ForStatement 里的信号仲裁(loopStatement.ts:27-49)是个三段式:

if (Signal.isBreak(signal)) {
  if (!signal.value) {
    break;
  }
  if (signal.value === labelName) {
    break;
  }
  return signal;
} else if (Signal.isContinue(signal)) {
  // 结构相同,消费动作换成宿主 continue
} else if (Signal.isReturn(signal)) {
  return signal;
}

三种情况:break 没带 label,本层消费,用宿主 break 停掉循环;带了 label 且正好贴在本循环头上,也消费;label 对不上,说明目标是外层某层循环,原样上抛。continue 同构。return 不做任何判断,永远上抛。

还有一个细节:循环进 body 之前会把 ctx 的 labelName 置成 undefined(loopStatement.ts:23-25)。label 只对紧跟的那一层循环有效,body 里嵌套的语句看不到它,除非里面再出现一个新的 LabeledStatement。这段仲裁逻辑在 For/While/DoWhile/ForIn 四个函数里一字不差复制了四遍,当时没抽公共函数,现在看应该抽。

拿一个嵌套循环把三段仲裁走一遍:

var hit = [];
outer: for (var i = 0; i < 3; i++) {
  for (var j = 0; j < 3; j++) {
    if (j === 1) {
      continue outer;
    }
    if (i === 2) {
      break outer;
    }
    hit.push(i * 10 + j);
  }
}
hit; // [0, 10]

i=0 那轮:内层循环的 labelName 是 undefined,continue outer 的信号 value 是 ‘outer’,三段里前两段都不中,原样上抛到外层;外层 labelName 是 ‘outer’,匹配,宿主 continue 掉本轮,i 进到 1。i=2 那轮 break outer 同理,外层匹配后宿主 break,整个双层循环结束。label 匹配上的那一层负责动手,中间各层只负责传。循环的标签匹配遵循该协议;其他消费点未完整实现该协议,后文会说明。

switch 中的 matched 标记

SwitchStatement 的核心是一个 matched 布尔(conditional.ts:26-49):

let matched = false;
for (const $case of node.cases) {
  if (
    !matched &&
    (!$case.test || discriminant === path.visitor(path.createChild($case.test, switchScope)))
  ) {
    matched = true;
  }

  if (matched) {
    const result = path.visitor(path.createChild($case, switchScope));
    if (Signal.isBreak(result)) {
      break;
    } else if (Signal.isContinue(result)) {
      return result;
    } else if (Signal.isReturn(result)) {
      return result;
    }
  }
}

该布尔值实现三项语义。严格匹配使用 discriminant === test,类型转换沿用宿主规则。matched 置为 true 后不会恢复,后续 case 不再判断 test 并依次执行,形成穿透语义。default 分支没有 test 节点,因此由 !$case.test 命中。continue 不能作用于 switch,但可穿过 switch 作用于外层循环,因而与 return 一样原样上抛。

拿它对照规范,有一处小偏差:default 写在匹配 case 前面时行为不对。规范里 default 是所有 case 都不匹配才进的兜底,位置不影响语义;这里按源码顺序扫,default 排在前面就先命中:

var r = [];
switch (1) {
  default: r.push('d');
  case 1: r.push('c1');
}
// 规范: ['c1']      default 被跳过,case 1 命中
// jsvm2: ['d','c1'] default 先命中,再穿透进 case 1

babel 产物里 default 永远排最后,所以没炸过,但语义是错的。真正的 bug 在下面。

finally 如何覆盖 return

tryCatch.ts:34-45 的 finally 实现借了宿主一个特性:宿主 finally 块在 try 的 return 生效之前执行。

} finally {
  if (node.finalizer) {
    const finallyScope = scope.createChild(ScopeType.Block);
    const single = path.visitor(path.createChild(node.finalizer, finallyScope));
    if (Signal.isReturn(single)) {
      return single;
    }
  }
}

沙箱 try 块里执行了 return,产生 Signal(‘return’) 准备作为 TryStatement 的返回值。但宿主要 return 之前得先跑 finally 块,finally 里如果又产生一个 return 信号,直接 return single 盖掉原来那个。这正是规范语义:finally 的 abrupt completion 替换掉 pending 的那个。

function g() {
  try {
    return 1;
  } finally {
    return 2;
  }
}
g(); // 2,沙箱里结果也对

宿主 finally 的执行顺序实现了 completion 替换,因此不需要额外状态管理。try 块存在待定宿主异常时(例如沙箱 throw 了一个值),finally 中的 return 信号同样会替换该异常,因为宿主 return 会终止待定异常。

两个实现缺陷

这两个 bug 在复盘时通过代码与规范的比对发现。

Bug 1:switch 吞掉带 label 的 break。

回看 SwitchStatement 第 39 行:if (Signal.isBreak(result)) { break; }。循环里的三段仲裁到这里只剩一段,只要是 break 就消费,根本不看 value 里的 label。可是 break outer 的目标是外层循环,switch 凭什么吃掉它:

var result = [];
outer: for (var i = 0; i < 5; i++) {
  switch (i) {
    case 2:
      break outer;
    default:
      result.push(i);
  }
}
// 规范:  [0, 1]        break outer 直接结束 for 循环
// jsvm2: [0, 1, 3, 4]  switch 把 break 吞了,for 继续跑

把 i=2 那轮在 jsvm2 里走一遍:break outer 产生 Signal(‘break’, ‘outer’);SwitchCase 见信号就上抛,这一步没错;SwitchStatement 收到后命中第 39 行,宿主 break 跳出 case 循环,函数正常返回 undefined;ForStatement 拿到 undefined,不是信号,继续下一轮。一个本该终止整个外层循环的信号,在 switch 这一层被消化成了「case 执行完毕」。规范里 switch 只能消费不带 label 的 break 和指向自己的 labeled break,其余都必须上抛,这条协议在循环里实现全了,在 switch 里丢了。

修法是把循环那套判断搬过来:无 label 的 break 才消费,value 等于贴在自己头上的 labelName 也消费(foo: switch (...) { case 1: break foo; } 是合法写法,但 SwitchStatement 目前根本没读 ctx.labelName,这个场景是靠 bug 1 的「全吞」误打误撞才对的),其余上抛。

Bug 2:finally 只认 return,吞掉 break/continue。

tryCatch.ts:41 行:if (Signal.isReturn(single)) { return single; }。finally 块产生的信号只有 return 会被返回去替换 pending completion,break 和 continue 直接掉进 void。规范里 finally 的任何 abrupt completion 都要替换:

function f() {
  for (var i = 0; i < 3; i++) {
    try {
      return i;
    } finally {
      break;
    }
  }
  return 'loop-done';
}
f();
// 规范:  'loop-done'  finally 的 break 替换掉 return,循环终止,走到最后的 return
// jsvm2: 0            break 被吞,try 里的 return 0 生效

jsvm2 里的执行轨迹:try 块产生 Signal(‘return’, 0),TryStatement 准备返回它;宿主 finally 先跑,finalizer 求值得到 Signal(‘break’);第 41 行 isReturn 判不中,single 被丢弃,宿主 finally 正常结束,try 的 return 生效,Signal(‘return’, 0) 一路到函数边界拆出 0。规范语义里 finally 的 break 应该替换掉 pending 的 return,让循环终止,f 走到最后的 return 'loop-done'

修法和上一个同样机械,把 isReturn 放宽成 single instanceof Signal。但两个 bug 摆在一起看,方向正好相反:switch 消费得太宽,不看 label 全吞;finally 消费得太窄,只认 return 全漏。根因是同一个,value 字段身兼返回值和 label 两种用途,每个消费点都要自己记住完整协议,漏一项就是一个语义 bug。

测试为什么没抓住它们:break 带 label、finally 里写 break,这两类代码 babel 产物都不会生成,回归用例里没有。jsvm2 语义裁剪的实际边界,说到底是「babel 产物会长成什么样」决定的,这个话题第 6 篇还会回来。

消费边界的不对称

信号最终要在边界上拆掉,jsvm2 的边界有两处,行为不对称。

函数边界拆得最彻底(function.ts:73-75):

if (result instanceof Signal) {
  return result.value;
}

不区分 kind,一律拆 value 作为函数返回值。正常情况下传到这里的只有 return,break/continue 在循环里已经消费掉了,一切正确。但如果有漏网的信号传到这里,函数照样拆,返回一个 label 名或者 undefined,错误被静默吞掉而不是报出来。

Program 边界只认 return(program.ts:44-47):

result = path.visitor(path.createChild(node));
if (Signal.isReturn(result)) {
  return result;
}

顶层如果出现 break/continue 信号,不会中断后续语句的执行,会被下一条语句的结果覆盖;如果它是最后一条语句的结果,Signal 对象就作为整个程序的运行结果泄漏给调用方,调用方拿到一个既不是值也不是错误的奇怪对象。

这个泄漏我实际构造出来看过。babel 不放行顶层 break,就手工拼 AST:break; log(42); break;,走不带 module 外壳的 run() 入口(vm.ts:29-34)。两个观察:第一,中间的 log(42) 照常执行,顶层 break 完全没有阻断作用;第二,返回值 res 是一个 Signal 实例,kind 为 ‘break’,value 为 undefined,调用方拿到它无从判断程序到底是正常跑完还是出了状况。同一个 AST 换 runInContext 走,Signal 又被 module/exports 外壳挡掉,拿到的是 {}(vm.ts:25-26),因为 MODULE 在根作用域永远存在,res 那个分支实际上摸不到。

好在parser 会拒绝这类输入:顶层 break 在规范里是 SyntaxError,babel 解析时直接报「Unsyntactic break」,正常源码根本到不了引擎,@babel/parser 对裸的 break; 直接拒掉。等于这道防线的检查整个外包给了编译期,又是编译期收窄解释期的一个样本,只不过这次是被动收窄,引擎自己没有任何防御。

两处的取舍对比值得记下来:函数边界「拆得宽」,因为它假设上游协议完备,漏过来的信号本来就反常;Program 边界「认得窄」,因为它假设 parser 已经把非法输入拦掉了。两个假设在日常输入下都成立,但假设就是假设,它们都不构成校验。

控制流实现的边界

throw 直接复用宿主异常机制;try/catch/finally 复用异常传播和 finally 的执行顺序;return、break 和 continue 由 21 行的 Signal 类及各复合语句的仲裁逻辑实现。每个消费点都必须完整处理信号协议,遗漏会造成语义错误。

写本文时这两处尚未修改,修复方式已在上文列出,计划在后续维护中处理。测试未覆盖的输入仍需通过代码与规范比对发现。

下一篇说明 jsvm2 如何将沙箱函数实现为宿主函数,以及 call/apply/bind、instanceof、原型链和手写逻辑之间的边界。


859 字 · 52 段落
ximing

Follow onGitHub

相关文章