Skip to content

fix(coroutine): preserve base-frame tail-call yields - #40

Merged
wolfy-j merged 3 commits into
mainfrom
fix/tailcall-base-yield
Sep 2, 2026
Merged

fix(coroutine): preserve base-frame tail-call yields#40
wolfy-j merged 3 commits into
mainfrom
fix/tailcall-base-yield

Conversation

@wolfy-j

@wolfy-j wolfy-j commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes the critical sole-frame yield loss reported by @butschster in:
https://gist.github.com/butschster/6a07d9628e2f46f7e9f14d3c62344f0c

Reproduction

On released v1.5.18, a coroutine entry function ending in a yielding Go tail call:

function tail()
    return gofn("arg1", "arg2")
end

incorrectly returned ResumeOK on the first resume, often exposing the argument window as successful results. Independent red-first cases also reproduced value loss through coroutine.wrap, at default/128/384 registry starts, after forced registry growth, and across repeated thread reuse.

The tail call collapsed the Lua base frame, then switchToParentThread popped the yielding Go frame. The empty stack made Resume report successful completion even though yieldState said the thread yielded.

The stability audit also reproduced a separate lifecycle failure: a pre-canceled context made Resume/ResumeInto return an error while leaving the child installed as CurrentThread, linked to its parent, and incorrectly resumable.

Fix

  • Preserve a sole root or tail-called Go frame when it yields.
  • Install a one-shot continuation that turns the next resume arguments into final return values.
  • Classify an actual yield from yieldState before treating an empty stack as successful completion.
  • Make cancellation and invalid-yield exits terminal and restore the parent/current-thread relationship.
  • If any future path yields without a resumable frame, return an explicit run error and contain the corrupt thread.

Hardened coverage

  • raw return -1, L.Yield, and Lua coroutine.yield;
  • stateful *LFunction and stateless LGoFunc;
  • plain, method, __call, coroutine.resume, and coroutine.wrap paths;
  • Resume and ResumeInto;
  • direct root Go entry functions;
  • zero, one, and multiple resume values;
  • default, 128-slot, and 384-slot registries with forced growth and repeated reuse;
  • exactly-once invocation, retained suspended frames, and continuation cleanup;
  • context cancellation before start and after yield;
  • invalid-yield containment restores parent/current-thread state.

Verification

  • red-first reproductions on v1.5.18;
  • full tests, shuffled full tests, and full race suite;
  • checkptr=2 full suite;
  • 100 repeated coroutine/context stress runs;
  • all eight repository fuzz targets;
  • go vet ./... and git diff --check;
  • CPU and memory profiles;
  • controlled 15-sample benchmark comparison against v1.5.18.

Controlled benchmark results show no statistically significant timing regression:

  • BenchmarkTailCall: p=0.116;
  • BenchmarkCoroutineYieldResume: p=0.775.

Allocation counts are identical:

  • BenchmarkTailCall: 120 B/op, 3 allocs/op;
  • BenchmarkCoroutineYieldResume: 10.30 KiB/op, 5 allocs/op.

A Go function reached by a base-frame tail call was collapsed and then popped during yield, leaving an empty stack. Resume consequently reported successful completion with the argument window instead of a resumable yield.

Keep a sole Go frame with a default resume continuation, classify completion from yieldState, and fail explicitly if any future path produces a yield without a frame. Cover direct, method, callable, stateless, user-yield, ResumeInto, root-Go, multi-result, and nested coroutine.resume shapes.
@wolfy-j
wolfy-j merged commit e324f6d into main Sep 2, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant