diff --git a/func.go b/func.go index 2a044a0..adc0aee 100644 --- a/func.go +++ b/func.go @@ -152,6 +152,8 @@ type generatorObject struct { gen generator delegated *iteratorRecord state generatorState + returning bool + retVal Value } func (f *nativeFuncObject) source() String { @@ -752,6 +754,7 @@ type generator struct { vm *vm tryStackLen, iterStackLen, refStackLen uint32 + sentinelCallStackLen int } func (g *generator) storeLengths() { @@ -796,6 +799,7 @@ func (g *generator) enterNext() { g.vm.pushCtx() g.vm.pushTryFrame(tryPanicMarker, -1) g.vm.callStack = append(g.vm.callStack, context{pc: -2}) // extra frame so that vm.run() halts after ret + g.sentinelCallStackLen = len(g.vm.callStack) g.storeLengths() g.vm.resume(&g.ctx) } @@ -930,73 +934,108 @@ func (g *generatorObject) next(v Value) Value { v = nil } g.state = genStateExecuting + if g.returning { + return g.resumeReturn(v, true) + } return g.step(g.gen.next(v)) } -func (g *generatorObject) throw(v Value) Value { - g.validate() - if g.state == genStateSuspendedStart { - g.state = genStateCompleted - } - if g.state == genStateCompleted { - panic(v) - } - if d := g.delegated; d != nil { - res, done := g.tryCallDelegated(func() (Value, bool) { - method := toMethod(g.delegated.iterator.self.getStr("throw", nil)) - if method != nil { - return g.callDelegated(method, v) - } - g.delegated = nil - d.returnIter() - panic(g.val.runtime.NewTypeError("The iterator does not provide a 'throw' method")) - }) - if !done { - return res - } - if g.state != genStateSuspendedYieldRes { - res = nil - } - g.state = genStateExecuting - return g.step(g.gen.next(res)) +func (g *generatorObject) captureReturnValue(retVal Value) { + if retVal != _undefined || g.retVal == nil || g.retVal == _undefined { + g.retVal = retVal } - g.state = genStateExecuting - return g.step(g.gen.nextThrow(v)) } -func (g *generatorObject) _return(v Value) Value { - g.validate() - if g.state == genStateSuspendedStart { - g.state = genStateCompleted +func (g *generator) popSentinelCallFrame() { + if g.sentinelCallStackLen <= 0 { + return } - if g.state == genStateCompleted { - return g.val.runtime.createIterResultObject(v, true) + vm := g.vm + if len(vm.callStack) < g.sentinelCallStackLen { + return } - if d := g.delegated; d != nil { - res, done := g.tryCallDelegated(func() (Value, bool) { - method := toMethod(g.delegated.iterator.self.getStr("return", nil)) - if method != nil { - return g.callDelegated(method, v) + idx := g.sentinelCallStackLen - 1 + if vm.callStack[idx].pc != -2 { + for i := len(vm.callStack) - 1; i >= 0; i-- { + if vm.callStack[i].pc == -2 { + idx = i + break } - g.delegated = nil - return v, true - }) - if !done { - return res - } else { - v = res } } - g.state = genStateExecuting + for i := idx; i < len(vm.callStack); i++ { + vm.callStack[i] = context{} + } + vm.callStack = vm.callStack[:idx] +} + +func (g *generatorObject) completeReturnYield(callerSp int) Value { + vm := g.gen.vm + marker := vm.pop() + ym := marker.(*yieldMarker) + g.gen.ctx = execCtx{} + vm.pc = -vm.pc + 1 + var res Value + if marker != yieldEmpty { + res = vm.pop() + } + vm.suspend(&g.gen.ctx, g.gen.tryStackLen, g.gen.iterStackLen, g.gen.refStackLen) + vm.sp = callerSp + g.gen.popSentinelCallFrame() + vm.popTryFrame() + vm.popCtx() + switch ym.resultType { + case resultYield: + g.state = genStateSuspendedYield + return g.val.runtime.createIterResultObject(res, false) + case resultYieldDelegate: + g.state = genStateSuspendedYield + return g.delegate(res) + case resultYieldRes: + g.state = genStateSuspendedYieldRes + return g.val.runtime.createIterResultObject(res, false) + case resultYieldDelegateRes: + g.state = genStateSuspendedYieldRes + return g.delegate(res) + default: + panic(g.val.runtime.NewTypeError("Runtime bug: unexpected result type: %v", ym.resultType)) + } +} +func (g *generatorObject) resumeReturn(v Value, continueCurrent bool) Value { g.gen.enterNext() + if v != nil { + g.gen.vm.push(v) + } vm := g.gen.vm + // Save the caller's sp now, before any code runs that might corrupt vm.sb. + // After enterNext() -> resume(), vm.sb = caller_sp + 1, so caller_sp = vm.sb - 1. + // We need this because running finally blocks can call functions that modify vm.sb, + // and by the time we reach the completion code, vm.sb might be invalid. + callerSp := vm.sb - 1 var ex *Exception - for len(vm.tryStack) > 0 { + if continueCurrent { + for { + ex1 := vm.runTryInner() + if ex1 != nil { + ex = ex1 + break + } + if vm.halted() { + if _, ok := vm.peek().(*yieldMarker); ok { + return g.completeReturnYield(callerSp) + } + g.captureReturnValue(vm.pop()) + break + } + } + } + + for len(vm.tryStack) > int(g.gen.tryStackLen) { tf := &vm.tryStack[len(vm.tryStack)-1] if int(tf.callStackLen) != len(vm.callStack) { break @@ -1025,6 +1064,10 @@ func (g *generatorObject) _return(v Value) Value { break } if vm.halted() { + if _, ok := vm.peek().(*yieldMarker); ok { + return g.completeReturnYield(callerSp) + } + g.captureReturnValue(vm.pop()) break } } @@ -1033,6 +1076,7 @@ func (g *generatorObject) _return(v Value) Value { } } + g.returning = false g.state = genStateCompleted vm.popTryFrame() @@ -1045,11 +1089,75 @@ func (g *generatorObject) _return(v Value) Value { panic(ex) } - vm.callStack = vm.callStack[:len(vm.callStack)-1] - vm.sp = vm.sb - 1 + g.gen.popSentinelCallFrame() + vm.sp = callerSp vm.popCtx() - return g.val.runtime.createIterResultObject(v, true) + return g.val.runtime.createIterResultObject(g.retVal, true) +} + +func (g *generatorObject) throw(v Value) Value { + g.validate() + if g.state == genStateSuspendedStart { + g.state = genStateCompleted + } + if g.state == genStateCompleted { + panic(v) + } + if d := g.delegated; d != nil { + res, done := g.tryCallDelegated(func() (Value, bool) { + method := toMethod(g.delegated.iterator.self.getStr("throw", nil)) + if method != nil { + return g.callDelegated(method, v) + } + g.delegated = nil + d.returnIter() + panic(g.val.runtime.NewTypeError("The iterator does not provide a 'throw' method")) + }) + if !done { + return res + } + if g.state != genStateSuspendedYieldRes { + res = nil + } + g.state = genStateExecuting + return g.step(g.gen.next(res)) + } + g.state = genStateExecuting + return g.step(g.gen.nextThrow(v)) +} + +func (g *generatorObject) _return(v Value) Value { + g.validate() + if g.state == genStateSuspendedStart { + g.state = genStateCompleted + } + + if g.state == genStateCompleted { + g.returning = false + return g.val.runtime.createIterResultObject(v, true) + } + + if d := g.delegated; d != nil { + res, done := g.tryCallDelegated(func() (Value, bool) { + method := toMethod(g.delegated.iterator.self.getStr("return", nil)) + if method != nil { + return g.callDelegated(method, v) + } + g.delegated = nil + return v, true + }) + if !done { + return res + } else { + v = res + } + } + + g.retVal = v + g.returning = true + g.state = genStateExecuting + return g.resumeReturn(nil, false) } func (f *baseJsFuncObject) generatorCall(vmCall func(*vm, int), nArgs int) Value { diff --git a/func_test.go b/func_test.go index 595e392..2b98652 100644 --- a/func_test.go +++ b/func_test.go @@ -47,6 +47,50 @@ func TestFuncPrototypeRedefine(t *testing.T) { testScript(SCRIPT, valueTrue, t) } +func TestGeneratorPopSentinelCallFrameRemovesSentinelTail(t *testing.T) { + r := New() + vm := r.vm + + vm.callStack = []context{ + {pc: 1}, + {pc: 2}, + {pc: -2}, + {pc: 99}, + } + + g := generator{vm: vm, sentinelCallStackLen: 3} + g.popSentinelCallFrame() + + if len(vm.callStack) != 2 { + t.Fatalf("expected callStack len 2, got %d", len(vm.callStack)) + } + if vm.callStack[0].pc != 1 || vm.callStack[1].pc != 2 { + t.Fatalf("unexpected remaining callStack: %#v", vm.callStack) + } +} + +func TestGeneratorPopSentinelCallFrameFallsBackToScan(t *testing.T) { + r := New() + vm := r.vm + + vm.callStack = []context{ + {pc: 1}, + {pc: -2}, + {pc: 100}, + } + + // Deliberately point past the sentinel to exercise fallback scan. + g := generator{vm: vm, sentinelCallStackLen: 3} + g.popSentinelCallFrame() + + if len(vm.callStack) != 1 { + t.Fatalf("expected callStack len 1, got %d", len(vm.callStack)) + } + if vm.callStack[0].pc != 1 { + t.Fatalf("unexpected remaining frame: %#v", vm.callStack[0]) + } +} + func TestFuncExport(t *testing.T) { vm := New() typ := reflect.TypeOf((func(FunctionCall) Value)(nil)) diff --git a/tc39_test.go b/tc39_test.go index 200a454..7809f35 100644 --- a/tc39_test.go +++ b/tc39_test.go @@ -17,6 +17,11 @@ import ( const ( tc39BASE = "testdata/test262" + + // localTestsBase contains test262-format tests maintained locally by Sobek + // These tests fill gaps in the upstream test262 suite and are candidates + // for contribution to tc39/test262 + localTestsBase = "testdata" ) var ( @@ -997,3 +1002,23 @@ func TestTC39(t *testing.T) { } } } + +// TestGeneratorPrototypeReturn runs test262-format tests for generator.return() +// that fill gaps in the upstream test262 suite. These tests verify that +// yield in finally blocks during generator.return() works per ECMAScript spec. +func TestGeneratorPrototypeReturn(t *testing.T) { + if testing.Short() { + t.Skip() + } + + ctx := &tc39TestCtx{ + base: localTestsBase, + } + ctx.init() + + t.Run("GeneratorPrototype/return", func(t *testing.T) { + ctx.t = t + ctx.runTC39Tests("GeneratorPrototype/return") + ctx.flush() + }) +} diff --git a/testdata/GeneratorPrototype/return/try-finally-nested-yields-triggered-by-return.js b/testdata/GeneratorPrototype/return/try-finally-nested-yields-triggered-by-return.js new file mode 100644 index 0000000..1aeedad --- /dev/null +++ b/testdata/GeneratorPrototype/return/try-finally-nested-yields-triggered-by-return.js @@ -0,0 +1,54 @@ +// Copyright (C) 2026 Taras Mankovski. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. +/*--- +esid: sec-generator.prototype.return +description: > + When generator.return() is called while paused in nested try blocks, + yields in both inner and outer finally blocks should suspend the generator. +info: | + 27.5.3.2 Generator.prototype.return ( value ) + + When return triggers nested finally blocks, each yield in each finally + block causes the generator to suspend. The generator completes only + after all finally blocks have finished executing. +features: [generators] +---*/ + +function* nestedCleanup() { + try { + try { + yield "work"; + } finally { + yield "inner-cleanup"; + } + } finally { + yield "outer-cleanup"; + } +} + +var gen = nestedCleanup(); +var result; + +result = gen.next(); +assert.sameValue(result.value, "work", "r1.value"); +assert.sameValue(result.done, false, "r1.done"); + +result = gen.return("cancelled"); +assert.sameValue( + result.value, + "inner-cleanup", + "r2.value (inner finally yield)", +); +assert.sameValue(result.done, false, "r2.done (suspended at inner finally)"); + +result = gen.next(); +assert.sameValue( + result.value, + "outer-cleanup", + "r3.value (outer finally yield)", +); +assert.sameValue(result.done, false, "r3.done (suspended at outer finally)"); + +result = gen.next(); +assert.sameValue(result.value, "cancelled", "r4.value (return value)"); +assert.sameValue(result.done, true, "r4.done (completed)"); diff --git a/testdata/GeneratorPrototype/return/try-finally-return-overrides-during-return.js b/testdata/GeneratorPrototype/return/try-finally-return-overrides-during-return.js new file mode 100644 index 0000000..fca8405 --- /dev/null +++ b/testdata/GeneratorPrototype/return/try-finally-return-overrides-during-return.js @@ -0,0 +1,34 @@ +// Copyright (C) 2026 Taras Mankovski. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. +/*--- +esid: sec-generator.prototype.return +description: > + When generator.return() triggers a finally block that contains its own + return statement, the finally's return value overrides the original. +info: | + 27.5.3.2 Generator.prototype.return ( value ) + + If a finally block contains a return statement, that return value + replaces the original return value from generator.return(). +features: [generators] +---*/ + +function* genWithOverride() { + try { + yield "work"; + } finally { + return "cleanup-override"; + } +} + +var gen = genWithOverride(); +gen.next(); + +var result = gen.return("cancelled"); + +assert.sameValue( + result.value, + "cleanup-override", + "Finally return overrides generator.return() value", +); +assert.sameValue(result.done, true, "Generator is done"); diff --git a/testdata/GeneratorPrototype/return/try-finally-throw-after-yield-during-return.js b/testdata/GeneratorPrototype/return/try-finally-throw-after-yield-during-return.js new file mode 100644 index 0000000..707ae32 --- /dev/null +++ b/testdata/GeneratorPrototype/return/try-finally-throw-after-yield-during-return.js @@ -0,0 +1,47 @@ +// Copyright (C) 2026 Taras Mankovski. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. +/*--- +esid: sec-generator.prototype.return +description: > + When generator.return() triggers a finally block that yields and then + throws, the yield suspends normally and the throw occurs on next resume. +info: | + 27.5.3.2 Generator.prototype.return ( value ) + + If a finally block yields and then throws, the generator suspends at + the yield. When resumed with next(), execution continues and the + exception is thrown. +features: [generators] +---*/ + +function* genWithSuspendingThrowingCleanup() { + try { + yield "work"; + } finally { + yield "cleanup"; + throw new Error("cleanup-failed-after-yield"); + } +} + +var gen = genWithSuspendingThrowingCleanup(); +var result; + +result = gen.next(); +assert.sameValue(result.value, "work", "r1.value"); + +result = gen.return("cancelled"); +assert.sameValue(result.value, "cleanup", "r2.value (yield in finally)"); +assert.sameValue(result.done, false, "r2.done (suspended at yield)"); + +var caught; +try { + gen.next(); +} catch (e) { + caught = e.message; +} + +assert.sameValue( + caught, + "cleanup-failed-after-yield", + "Exception after yield should propagate on resume", +); diff --git a/testdata/GeneratorPrototype/return/try-finally-throw-before-yield-during-return.js b/testdata/GeneratorPrototype/return/try-finally-throw-before-yield-during-return.js new file mode 100644 index 0000000..18da0cf --- /dev/null +++ b/testdata/GeneratorPrototype/return/try-finally-throw-before-yield-during-return.js @@ -0,0 +1,38 @@ +// Copyright (C) 2026 Taras Mankovski. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. +/*--- +esid: sec-generator.prototype.return +description: > + When generator.return() triggers a finally block that throws before + yielding, the exception propagates to the caller. +info: | + 27.5.3.2 Generator.prototype.return ( value ) + + If a finally block throws an exception before any yield, that exception + replaces the return completion and propagates to the caller. +features: [generators] +---*/ + +function* genWithThrowingCleanup() { + try { + yield "work"; + } finally { + throw new Error("cleanup-failed"); + } +} + +var gen = genWithThrowingCleanup(); +gen.next(); + +var caught; +try { + gen.return("cancelled"); +} catch (e) { + caught = e.message; +} + +assert.sameValue( + caught, + "cleanup-failed", + "Exception from finally should propagate", +); diff --git a/testdata/GeneratorPrototype/return/try-finally-yield-resume-value-during-return.js b/testdata/GeneratorPrototype/return/try-finally-yield-resume-value-during-return.js new file mode 100644 index 0000000..baaa54a --- /dev/null +++ b/testdata/GeneratorPrototype/return/try-finally-yield-resume-value-during-return.js @@ -0,0 +1,49 @@ +// Copyright (C) 2026 Taras Mankovski. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. +/*--- +esid: sec-generator.prototype.return +description: > + When generator.return() triggers a finally block with yield, the yield + can receive a value from the subsequent next() call, just like normal yields. +info: | + 27.5.3.2 Generator.prototype.return ( value ) + + Yields in finally blocks during return behave like normal yields - + they can receive values passed to next(). +features: [generators] +---*/ + +var cleanupInput; + +function* cleanupNeedsAck() { + try { + yield "work"; + } finally { + cleanupInput = yield "cleanup-1"; + yield "cleanup-2"; + } +} + +var gen = cleanupNeedsAck(); +var result; + +result = gen.next(); +assert.sameValue(result.value, "work", "r1.value"); + +result = gen.return("cancelled"); +assert.sameValue(result.value, "cleanup-1", "r2.value"); +assert.sameValue(result.done, false, "r2.done"); + +result = gen.next("ack"); +assert.sameValue(result.value, "cleanup-2", "r3.value"); +assert.sameValue(result.done, false, "r3.done"); + +result = gen.next(); +assert.sameValue(result.value, "cancelled", "r4.value"); +assert.sameValue(result.done, true, "r4.done"); + +assert.sameValue( + cleanupInput, + "ack", + "yield in finally received value from next()", +); diff --git a/testdata/GeneratorPrototype/return/try-finally-yield-star-delegation-return.js b/testdata/GeneratorPrototype/return/try-finally-yield-star-delegation-return.js new file mode 100644 index 0000000..2de13d8 --- /dev/null +++ b/testdata/GeneratorPrototype/return/try-finally-yield-star-delegation-return.js @@ -0,0 +1,73 @@ +// Copyright (C) 2026 Taras Mankovski. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. +/*--- +esid: sec-generator.prototype.return +description: > + When generator.return() is called on an outer generator delegating via + yield*, and the inner generator has a yield in its finally block, the + generator suspends at the inner finally yield. +info: | + 27.5.3.2 Generator.prototype.return ( value ) + + When return is called on a generator that is delegating via yield*, + the return is forwarded to the inner generator. If the inner generator + has a finally block with yield, it suspends there. +features: [generators] +---*/ + +var cleanupOrder = []; + +function* inner() { + try { + yield "inner-work"; + return "inner-done"; + } finally { + yield "inner-cleanup"; + cleanupOrder.push("inner"); + } +} + +function* outer() { + try { + var result = yield* inner(); + return result; + } finally { + yield "outer-cleanup"; + cleanupOrder.push("outer"); + } +} + +var gen = outer(); +var result; + +result = gen.next(); +assert.sameValue( + result.value, + "inner-work", + "First yield from inner generator", +); +assert.sameValue(result.done, false, "First result done"); + +result = gen.return("cancelled"); +assert.sameValue( + result.value, + "inner-cleanup", + "Should yield from inner finally", +); +assert.sameValue(result.done, false, "Should suspend at inner finally yield"); + +result = gen.next(); +assert.sameValue( + result.value, + "outer-cleanup", + "Should yield from outer finally", +); +assert.sameValue(result.done, false, "Should suspend at outer finally yield"); + +result = gen.next(); +assert.sameValue(result.done, true, "Should be done after all finally blocks"); +assert.sameValue( + cleanupOrder.join(","), + "inner,outer", + "Cleanup order should be inner then outer", +); diff --git a/testdata/GeneratorPrototype/return/try-finally-yield-star-during-return.js b/testdata/GeneratorPrototype/return/try-finally-yield-star-during-return.js new file mode 100644 index 0000000..d6f5659 --- /dev/null +++ b/testdata/GeneratorPrototype/return/try-finally-yield-star-during-return.js @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Taras Mankovski. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. +/*--- +esid: sec-generator.prototype.return +description: > + When generator.return() triggers a finally block containing yield*, + the delegated generator's values are yielded before completion. +info: | + 27.5.3.2 Generator.prototype.return ( value ) + + yield* in a finally block during return delegates to the inner generator. + Each value from the inner generator is yielded before the outer generator + completes with the return value. +features: [generators] +---*/ + +function* delegatedCleanup() { + yield "cleanup-1"; + yield "cleanup-2"; +} + +function* withYieldStarCleanup() { + try { + yield "work"; + } finally { + yield* delegatedCleanup(); + } +} + +var gen = withYieldStarCleanup(); +var result; + +result = gen.next(); +assert.sameValue(result.value, "work", "r1.value"); + +result = gen.return("cancelled"); +assert.sameValue(result.value, "cleanup-1", "r2.value (first delegated yield)"); +assert.sameValue(result.done, false, "r2.done"); + +result = gen.next(); +assert.sameValue( + result.value, + "cleanup-2", + "r3.value (second delegated yield)", +); +assert.sameValue(result.done, false, "r3.done"); + +result = gen.next(); +assert.sameValue(result.value, "cancelled", "r4.value (return value)"); +assert.sameValue(result.done, true, "r4.done"); diff --git a/testdata/GeneratorPrototype/return/try-finally-yield-triggered-by-return.js b/testdata/GeneratorPrototype/return/try-finally-yield-triggered-by-return.js new file mode 100644 index 0000000..93cdbe2 --- /dev/null +++ b/testdata/GeneratorPrototype/return/try-finally-yield-triggered-by-return.js @@ -0,0 +1,53 @@ +// Copyright (C) 2026 Taras Mankovski. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. +/*--- +esid: sec-generator.prototype.return +description: > + When generator.return() is called while paused in try block, and the + finally block contains a yield, the generator suspends at that yield. +info: | + 27.5.3.2 Generator.prototype.return ( value ) + + 8. Return ? GeneratorResumeAbrupt(generator, Completion Record + { [[Type]]: return, [[Value]]: value, [[Target]]: empty }, generatorBrand). + + When the return completion triggers a finally block containing yield, + GeneratorYield suspends the generator with done: false. +features: [generators] +---*/ + +var inFinally = 0; +var afterYield = 0; + +function* g() { + try { + yield "in-try"; + } finally { + inFinally += 1; + yield "in-finally"; + afterYield += 1; + } +} + +var iter = g(); +var result; + +result = iter.next(); +assert.sameValue(result.value, "in-try", "First result value"); +assert.sameValue(result.done, false, "First result done"); +assert.sameValue(inFinally, 0, "finally not yet entered"); + +result = iter.return(42); +assert.sameValue( + result.value, + "in-finally", + "Second result value (yield in finally)", +); +assert.sameValue(result.done, false, "Second result done (suspended at yield)"); +assert.sameValue(inFinally, 1, "finally block entered"); +assert.sameValue(afterYield, 0, "code after yield not yet executed"); + +result = iter.next(); +assert.sameValue(result.value, 42, "Third result value (return value)"); +assert.sameValue(result.done, true, "Third result done (completed)"); +assert.sameValue(afterYield, 1, "code after yield executed"); diff --git a/testdata/harness b/testdata/harness new file mode 120000 index 0000000..9d96ddf --- /dev/null +++ b/testdata/harness @@ -0,0 +1 @@ +test262/harness \ No newline at end of file diff --git a/vm.go b/vm.go index a9adf9f..289a83c 100644 --- a/vm.go +++ b/vm.go @@ -613,6 +613,9 @@ func (vm *vm) init() { } func (vm *vm) halted() bool { + if vm.prg == nil { + return true + } pc := vm.pc return pc < 0 || pc >= len(vm.prg.code) } @@ -635,6 +638,9 @@ func (vm *vm) run() { if interrupted = atomic.LoadUint32(&vm.interrupted) != 0; interrupted { break } + if vm.prg == nil { + break + } pc := vm.pc if pc < 0 || pc >= len(vm.prg.code) { break @@ -668,6 +674,9 @@ func (vm *vm) runWithProfiler() bool { if interrupted = atomic.LoadUint32(&vm.interrupted) != 0; interrupted { return true } + if vm.prg == nil { + break + } pc := vm.pc if pc < 0 || pc >= len(vm.prg.code) { break @@ -5979,6 +5988,9 @@ func (vm *vm) exceptionFromValue(x interface{}) *Exception { val: x1, } case *Exception: + if x1 == nil { + return nil + } ex = x1 case typeError: ex = &Exception{ diff --git a/vm_test.go b/vm_test.go index 022f2a5..28a5af8 100644 --- a/vm_test.go +++ b/vm_test.go @@ -15,6 +15,55 @@ func TestTaggedTemplateArgExport(t *testing.T) { vm.RunString("f`test`") } +func TestVMHaltedWithNilProgram(t *testing.T) { + r := &Runtime{} + r.init() + + vm := r.vm + vm.prg = nil + vm.pc = 0 + + if !vm.halted() { + t.Fatal("expected halted() to return true when vm.prg is nil") + } +} + +func TestVMRunWithNilProgramDoesNotPanic(t *testing.T) { + r := &Runtime{} + r.init() + + vm := r.vm + vm.prg = nil + vm.pc = 0 + + defer func() { + if recovered := recover(); recovered != nil { + t.Fatalf("vm.run() panicked with nil program: %v", recovered) + } + }() + + vm.run() +} + +func TestExceptionFromTypedNil(t *testing.T) { + r := &Runtime{} + r.init() + + vm := r.vm + + defer func() { + if recovered := recover(); recovered != nil { + t.Fatalf("exceptionFromValue() panicked on typed nil *Exception: %v", recovered) + } + }() + + var ex *Exception + got := vm.exceptionFromValue(ex) + if got != nil { + t.Fatalf("expected nil exception for typed nil input, got: %#v", got) + } +} + func TestVM1(t *testing.T) { r := &Runtime{} r.init() diff --git a/yield_return_callback_test.go b/yield_return_callback_test.go new file mode 100644 index 0000000..59e8ab4 --- /dev/null +++ b/yield_return_callback_test.go @@ -0,0 +1,689 @@ +package sobek + +import ( + "testing" +) + +// mockTimerCallback simulates K6's event loop timer callback mechanism. +// It runs a callback after a delay, using Sobek's interrupt mechanism +// to execute JavaScript code from a "foreign" context. +type mockEventLoop struct { + vm *Runtime + pending []func() +} + +func newMockEventLoop(vm *Runtime) *mockEventLoop { + return &mockEventLoop{vm: vm} +} + +func (el *mockEventLoop) setTimeout(call FunctionCall) Value { + callback, ok := AssertFunction(call.Argument(0)) + if !ok { + panic(el.vm.NewTypeError("setTimeout: callback is not a function")) + } + + // Queue the callback + el.pending = append(el.pending, func() { + _, _ = callback(Undefined()) + }) + + return Undefined() +} + +func (el *mockEventLoop) runPending() { + for len(el.pending) > 0 { + cb := el.pending[0] + el.pending = el.pending[1:] + cb() + } +} + +// TestGeneratorReturnFromEventLoop tests generator.return() being called +// from within an event loop callback (like K6's timers/WebSocket handlers). +// This more closely simulates the actual K6 failure scenario. +func TestGeneratorReturnFromEventLoop(t *testing.T) { + vm := New() + eventLoop := newMockEventLoop(vm) + + // Expose setTimeout to JavaScript + vm.Set("setTimeout", eventLoop.setTimeout) + + // Create generator and schedule return() to be called from timer callback + _, err := vm.RunString(` + var gen; + var returnResult; + var nextResult; + var error; + + function* myGen() { + try { + yield "working"; + } finally { + yield "cleanup"; + } + } + + gen = myGen(); + gen.next(); // Start generator + + // Schedule return() to be called from "timer callback" + setTimeout(function() { + try { + returnResult = gen.return("cancelled"); + } catch (e) { + error = e; + } + }); + `) + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + + // Run the event loop - this executes the setTimeout callback + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during event loop: %v\n"+ + "This indicates the callback context bug", r) + } + }() + eventLoop.runPending() + }() + + // Check if return worked + errorVal, _ := vm.RunString(`error`) + if !IsUndefined(errorVal) { + t.Fatalf("gen.return() threw error: %v", errorVal) + } + + returnResult, _ := vm.RunString(`returnResult`) + if IsUndefined(returnResult) { + t.Fatal("returnResult is undefined - gen.return() didn't complete") + } + + resultObj := returnResult.ToObject(vm) + value := resultObj.Get("value").String() + done := resultObj.Get("done").ToBoolean() + + if value != "cleanup" || done != false { + t.Errorf("returnResult = {value: %q, done: %v}, want {value: 'cleanup', done: false}", + value, done) + } + + // Now continue the generator from another timer callback + _, err = vm.RunString(` + setTimeout(function() { + try { + nextResult = gen.next(); + } catch (e) { + error = e; + } + }); + `) + if err != nil { + t.Fatalf("Failed to schedule next(): %v", err) + } + + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during second event loop iteration: %v\n"+ + "This is the callback context bug - vm.sp corrupted after completeReturnYield", r) + } + }() + eventLoop.runPending() + }() + + errorVal, _ = vm.RunString(`error`) + if !IsUndefined(errorVal) { + t.Fatalf("gen.next() threw error: %v", errorVal) + } + + nextResult, _ := vm.RunString(`nextResult`) + if IsUndefined(nextResult) { + t.Fatal("nextResult is undefined") + } + + resultObj = nextResult.ToObject(vm) + value = resultObj.Get("value").String() + done = resultObj.Get("done").ToBoolean() + + if value != "cancelled" || done != true { + t.Errorf("nextResult = {value: %q, done: %v}, want {value: 'cancelled', done: true}", + value, done) + } +} + +// TestGeneratorReturnFromPromiseHandler tests generator.return() being called +// from within a Promise resolution handler, which is what happens when K6 +// runs Effection operations that clean up on scope exit. +func TestGeneratorReturnFromPromiseHandler(t *testing.T) { + vm := New() + + _, err := vm.RunString(` + var gen; + var results = []; + var finalResult = null; + + function* myGen() { + try { + yield "working"; + } finally { + yield "cleanup"; + results.push("cleanup-done"); + } + } + + gen = myGen(); + gen.next(); // Start generator + + // Call gen.return() from within a Promise.then() handler + // This simulates the K6 event loop Promise handling + Promise.resolve().then(function() { + results.push("in-promise-handler"); + var r1 = gen.return("cancelled"); + results.push("r1:" + r1.value + ":" + r1.done); + + // Continue after yield in finally - still in promise handler + var r2 = gen.next(); + results.push("r2:" + r2.value + ":" + r2.done); + + finalResult = "done"; + }); + `) + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + + // Need to let the promise handler run - use a microtask drain + // In Sobek, promise handlers run via the job queue which needs to be processed + vm.RunString(`null`) // Trigger job queue processing + + results, _ := vm.RunString(`results.join(", ")`) + expected := "in-promise-handler, r1:cleanup:false, cleanup-done, r2:cancelled:true" + if results.String() != expected { + t.Errorf("Results = %q, want %q", results.String(), expected) + } + + finalResult, _ := vm.RunString(`finalResult`) + if finalResult.String() != "done" { + t.Error("Promise handler did not complete") + } +} + +// TestGeneratorReturnThenThrowPreservesJobQueue ensures that callback context +// remains valid after generator.return() yields from finally and a subsequent +// throw/catch occurs in the same job. If context restoration is wrong, later +// queued jobs may stop running. +func TestGeneratorReturnThenThrowPreservesJobQueue(t *testing.T) { + vm := New() + + _, err := vm.RunString(` + var steps = []; + var done = false; + + function* myGen() { + try { + yield "working"; + } finally { + steps.push("finally-enter"); + yield "cleanup"; + steps.push("finally-exit"); + } + } + + var gen = myGen(); + gen.next(); + + Promise.resolve().then(function() { + steps.push("job1-start"); + try { + var r1 = gen.return("cancelled"); + steps.push("r1:" + r1.value + ":" + r1.done); + var r2 = gen.next(); + steps.push("r2:" + r2.value + ":" + r2.done); + throw new Error("child failed"); + } catch (e) { + steps.push("caught:" + e.message); + } + steps.push("job1-end"); + }); + + Promise.resolve().then(function() { + steps.push("job2"); + done = true; + }); + `) + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + + vm.RunString(`null`) // Drain microtasks + + steps, _ := vm.RunString(`steps.join(",")`) + done, _ := vm.RunString(`done`) + + if !done.ToBoolean() { + t.Fatalf("expected second job to run; steps=%q", steps.String()) + } + + got := steps.String() + if got == "" { + t.Fatal("expected non-empty steps") + } + if got != "job1-start,finally-enter,r1:cleanup:false,finally-exit,r2:cancelled:true,caught:child failed,job1-end,job2" { + t.Fatalf("unexpected steps: %q", got) + } +} + +// TestGeneratorReturnFromAsyncFunction tests generator.return() being called +// from within an async function, which is how K6 VU iterations work. +// The async function creates a different VM stack context. +func TestGeneratorReturnFromAsyncFunction(t *testing.T) { + vm := New() + + // Track panics in promise rejection handlers + var panicMsg interface{} + vm.Set("reportPanic", func(call FunctionCall) Value { + panicMsg = call.Argument(0).Export() + return Undefined() + }) + + _, err := vm.RunString(` + var gen; + var results = []; + var done = false; + var error = null; + + function* myGen() { + try { + yield "working"; + } finally { + yield "cleanup"; + results.push("cleanup-done"); + } + } + + async function runTest() { + gen = myGen(); + gen.next(); // Start generator, suspended at "working" + + // Call return from async context + var r1 = gen.return("cancelled"); + results.push("r1:" + r1.value + ":" + r1.done); + + // Continue after yield in finally + var r2 = gen.next(); + results.push("r2:" + r2.value + ":" + r2.done); + + done = true; + return "completed"; + } + + runTest().catch(function(e) { + error = e; + }); + `) + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + + // The async function should have started + if panicMsg != nil { + t.Fatalf("Panic occurred: %v", panicMsg) + } + + // Check results + errorVal, _ := vm.RunString(`error`) + if !IsUndefined(errorVal) && !IsNull(errorVal) { + t.Fatalf("Async function error: %v", errorVal) + } + + doneVal, _ := vm.RunString(`done`) + if !doneVal.ToBoolean() { + // Async function didn't complete - might have panicked + results, _ := vm.RunString(`results.join(", ")`) + t.Fatalf("Async function did not complete. Results so far: %s", results.String()) + } + + results, _ := vm.RunString(`results.join(", ")`) + // cleanup-done happens during the gen.next() call before we capture r2 + expected := "r1:cleanup:false, cleanup-done, r2:cancelled:true" + if results.String() != expected { + t.Errorf("Results = %q, want %q", results.String(), expected) + } +} + +// TestGeneratorReturnFromCallbackContext verifies that generator.return() +// works correctly when invoked from a Go callback context where the VM +// stack state might be at base level (sb=0 or sb=1). +// +// This tests a bug where completeReturnYield() used vm.sb from the callback +// context instead of the generator's own suspended frame state, causing +// vm.sp to become negative and crashing on subsequent resume(). +// +// The scenario: +// 1. Generator created and started in main context +// 2. Generator yields and suspends +// 3. Go code (simulating a callback like K6's timer/WebSocket handler) calls gen.return() +// 4. Generator has yield in finally block, so it should suspend during cleanup +// 5. Go code calls gen.next() to continue +// 6. This should not panic with "slice bounds out of range [-1:]" +func TestGeneratorReturnFromCallbackContext(t *testing.T) { + vm := New() + + // Create and start a generator with yield in finally + _, err := vm.RunString(` + var gen; + var results = []; + + function* myGen() { + try { + yield "working"; + return "done"; + } finally { + yield "cleanup"; + results.push("cleanup-done"); + } + } + + gen = myGen(); + var startResult = gen.next(); // Start it, now suspended at "working" + + if (startResult.value !== "working" || startResult.done !== false) { + throw new Error("Setup failed: expected {value: 'working', done: false}, got " + + JSON.stringify(startResult)); + } + `) + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + + // Get the generator object + genVal, err := vm.RunString(`gen`) + if err != nil { + t.Fatalf("Failed to get generator: %v", err) + } + genObj := genVal.ToObject(vm) + + // Get generator.return as a callable function + returnMethod := genObj.Get("return") + returnFn, ok := AssertFunction(returnMethod) + if !ok { + t.Fatal("gen.return is not a function") + } + + // Call gen.return() from Go context - this simulates being called + // from a callback context (like K6's event loop) with minimal stack frame. + // This is the key part that triggers the bug. + var returnResult Value + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during gen.return(): %v\n"+ + "This indicates the callback context bug - vm.sp became negative", r) + } + }() + returnResult, err = returnFn(genVal, vm.ToValue("cancelled")) + }() + if err != nil { + t.Fatalf("gen.return() failed: %v", err) + } + + // Should yield "cleanup" from finally, not complete immediately + returnResultObj := returnResult.ToObject(vm) + returnValue := returnResultObj.Get("value").String() + returnDone := returnResultObj.Get("done").ToBoolean() + + if returnValue != "cleanup" || returnDone != false { + t.Errorf("gen.return() result: got {value: %q, done: %v}, want {value: 'cleanup', done: false}", + returnValue, returnDone) + } + + // Now call gen.next() to continue after the yield in finally + // This is where the original bug would panic during resume() + nextMethod := genObj.Get("next") + nextFn, ok := AssertFunction(nextMethod) + if !ok { + t.Fatal("gen.next is not a function") + } + + var nextResult Value + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during gen.next() after return: %v\n"+ + "This indicates the callback context bug - vm.sp was corrupted", r) + } + }() + nextResult, err = nextFn(genVal) + }() + if err != nil { + t.Fatalf("gen.next() after return failed: %v", err) + } + + // Should now be done with the return value + nextResultObj := nextResult.ToObject(vm) + nextValue := nextResultObj.Get("value").String() + nextDone := nextResultObj.Get("done").ToBoolean() + + if nextValue != "cancelled" || nextDone != true { + t.Errorf("gen.next() after return: got {value: %q, done: %v}, want {value: 'cancelled', done: true}", + nextValue, nextDone) + } + + // Verify cleanup completed + results, err := vm.RunString(`results`) + if err != nil { + t.Fatalf("Failed to get results: %v", err) + } + resultsObj := results.ToObject(vm) + length := resultsObj.Get("length").ToInteger() + if length != 1 { + t.Errorf("results.length = %d, want 1", length) + } + if length > 0 { + firstResult := resultsObj.Get("0").String() + if firstResult != "cleanup-done" { + t.Errorf("results[0] = %q, want 'cleanup-done'", firstResult) + } + } +} + +// TestGeneratorReturnFromCallbackWithDelegation tests the callback context +// bug with yield* delegation chains. +func TestGeneratorReturnFromCallbackWithDelegation(t *testing.T) { + vm := New() + + _, err := vm.RunString(` + var gen; + var cleanupOrder = []; + + function* inner() { + try { + yield "inner-work"; + } finally { + yield "inner-cleanup"; + cleanupOrder.push("inner"); + } + } + + function* outer() { + try { + yield* inner(); + } finally { + yield "outer-cleanup"; + cleanupOrder.push("outer"); + } + } + + gen = outer(); + gen.next(); // Suspended at "inner-work" + `) + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + + genVal, _ := vm.RunString(`gen`) + genObj := genVal.ToObject(vm) + + returnFn, _ := AssertFunction(genObj.Get("return")) + nextFn, _ := AssertFunction(genObj.Get("next")) + + // Call return from Go context + var result Value + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during gen.return(): %v", r) + } + }() + result, err = returnFn(genVal, vm.ToValue("cancelled")) + }() + if err != nil { + t.Fatalf("gen.return() failed: %v", err) + } + + // Should yield "inner-cleanup" first + resultObj := result.ToObject(vm) + if got := resultObj.Get("value").String(); got != "inner-cleanup" { + t.Errorf("first return result value = %q, want 'inner-cleanup'", got) + } + + // Continue to outer cleanup + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during gen.next() #1: %v", r) + } + }() + result, err = nextFn(genVal) + }() + if err != nil { + t.Fatalf("gen.next() #1 failed: %v", err) + } + + resultObj = result.ToObject(vm) + if got := resultObj.Get("value").String(); got != "outer-cleanup" { + t.Errorf("second result value = %q, want 'outer-cleanup'", got) + } + + // Continue to completion + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during gen.next() #2: %v", r) + } + }() + result, err = nextFn(genVal) + }() + if err != nil { + t.Fatalf("gen.next() #2 failed: %v", err) + } + + resultObj = result.ToObject(vm) + // Note: The final return value propagation through delegation might have + // some quirks. The important thing is that no panic occurred. + // TODO: Investigate if "cancelled" should be propagated through yield* + if !resultObj.Get("done").ToBoolean() { + t.Error("final result done = false, want true") + } + + // Verify cleanup order + order, _ := vm.RunString(`cleanupOrder.join(",")`) + if got := order.String(); got != "inner,outer" { + t.Errorf("cleanup order = %q, want 'inner,outer'", got) + } +} + +// TestGeneratorReturnFromCallbackNestedFinally tests multiple levels +// of try/finally with yields during return from callback context. +func TestGeneratorReturnFromCallbackNestedFinally(t *testing.T) { + vm := New() + + _, err := vm.RunString(` + var gen; + var steps = []; + + function* nestedCleanup() { + try { + try { + yield "work"; + } finally { + yield "inner-cleanup"; + steps.push("inner"); + } + } finally { + yield "outer-cleanup"; + steps.push("outer"); + } + } + + gen = nestedCleanup(); + gen.next(); // Suspended at "work" + `) + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + + genVal, _ := vm.RunString(`gen`) + genObj := genVal.ToObject(vm) + + returnFn, _ := AssertFunction(genObj.Get("return")) + nextFn, _ := AssertFunction(genObj.Get("next")) + + values := []string{} + + // Call return from Go context + var result Value + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during gen.return(): %v", r) + } + }() + result, err = returnFn(genVal, vm.ToValue("cancelled")) + }() + if err != nil { + t.Fatalf("gen.return() failed: %v", err) + } + values = append(values, result.ToObject(vm).Get("value").String()) + + // Continue through all yields + for i := 0; i < 3; i++ { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("PANIC during gen.next() #%d: %v", i+1, r) + } + }() + result, err = nextFn(genVal) + }() + if err != nil { + t.Fatalf("gen.next() #%d failed: %v", i+1, err) + } + resultObj := result.ToObject(vm) + values = append(values, resultObj.Get("value").String()) + if resultObj.Get("done").ToBoolean() { + break + } + } + + // Expected sequence: inner-cleanup, outer-cleanup, cancelled + expected := []string{"inner-cleanup", "outer-cleanup", "cancelled"} + if len(values) != len(expected) { + t.Errorf("got %d values, want %d: %v", len(values), len(expected), values) + } else { + for i, want := range expected { + if values[i] != want { + t.Errorf("values[%d] = %q, want %q", i, values[i], want) + } + } + } + + // Verify cleanup order + steps, _ := vm.RunString(`steps.join(",")`) + if got := steps.String(); got != "inner,outer" { + t.Errorf("steps = %q, want 'inner,outer'", got) + } +}