You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The compiler already emits OP_TAIL_CALL for ordinary calls in tail position. Before #645, a tail call to a *Closure or *MultiArityFn fell through ExecContext.Invoke into a nested Frame.Run, so deep recursion grew the Go stack. #645 makes those direct bytecode spans Go-stack-safe by descending through an explicit parent-linked frame chain.
That is not yet general tail-call elimination. A closure or multi-arity tail call still allocates and retains one child Frame per call. The remaining #620 goal is therefore:
Reuse the current VM frame for every resolvable bytecode tail call, including closures and multi-arity functions, and make apply* capable of handing its target back to the VM tail loop without exposing a continuation as a let-go Value.
The implementation should be stacked on #645. It must not introduce a second callee resolver or a second frame-acquisition path.
None for Go-stack safety; non-tail depth remains linear heap growth
Tail *Func
Existing same-frame reuse
Move onto the shared resolver/transition path
Tail *Closure
Descend to a child frame
Reuse the current frame while installing captures
Tail *MultiArityFn
Resolve the variant, then descend
Reuse the current frame after variant selection
Tail metadata-wrapped bytecode fn
Unwrap, then descend
Reuse through the same target preparation path
Tail native leaf
Invoke and return its value
Preserve terminal behavior and error attribution
Tail apply*
Native calls public ExecContext.Invoke, hiding the target behind a new invocation segment
Return a private pending-call outcome to the tail loop
Tail ProtocolFn
Protocol dispatch calls public ExecContext.Invoke
Resolve the implementation into the shared raw path, if included in v1
Tail MultiFn
Dispatch function and selected method are nested invocations
Requires a resumable dispatcher; explicitly include or defer
Goals and non-goals
Goals for v1
Constant Go and VM-frame space for tail calls to *Func, *Closure, and *MultiArityFn, including metadata wrappers and closures over multi-arity functions.
Tail-transparent apply* without allowing an internal continuation to escape through Value, variables, collections, or public host APIs.
One shared path for callee unwrapping, multi-arity selection, closure captures, fixed/variadic arity validation, argument preparation, and frame transition.
Preserve current OP_TAIL_CALL semantics for protected regions, dynamic bindings, errors, tracing, profiling, and host entry points.
Making every native builtin resumable. reduce, map, and other natives that synchronously call back into let-go remain a broader native-continuation boundary unless they explicitly adopt the raw protocol.
Tail OP_TAIL_CALL: replace the current frame's executable state with the prepared target and continue the same dispatch loop.
The tail replacement must install, at minimum:
code, consts, and constsc;
closedOvers (clear for plain functions, install for closures);
owned arguments and argc;
ip = 0 and sp = 0;
sufficient operand-stack capacity.
The existing separate zero/nonzero-arity OP_TAIL_CALL branches should converge on this path.
3. Make argument ownership explicit
Arguments read by OP_TAIL_CALL are slices into the current operand stack. They cannot remain borrowed while that stack is reset and reused.
The transition must copy/reposition them into storage owned by the reused frame before overwriting the source. Variadic packing must also be fresh or frame-owned; the current append(sargs, restlist) tail path can reuse and mutate an input backing array.
A reusable frame-owned argument buffer is preferable to allocating a new slice on every tail hop. Correctness comes first; allocation behavior is measured and optimized after the ownership invariant is explicit.
4. Split raw invocation from public invocation
Fn.Invoke, NativeFn.proxy, and public ExecContext.Invoke return (Value, error). A pending call must not implement Value merely to fit those interfaces.
Introduce a private/raw outcome with two states:
final Value;
pending (Fn, []Value).
Then enforce these boundaries:
raw invocation performs one dispatch step and may return a pending call;
public ExecContext.Invoke iteratively drains pending calls and returns only a final Value;
non-tail OP_INVOKE uses the draining/public behavior when a callable cannot become a direct bytecode child;
OP_TAIL_CALL consumes the raw outcome, so a pending bytecode target can replace the current frame;
direct Fn.Invoke entry points remain continuation-proof.
Because apply* is defined in pkg/rt, the opt-in API may need a small exported constructor or opaque outcome type. It must remain impossible to store the pending-call object as a let-go value.
5. Make apply* the first raw continuation producer
apply* already normalizes its final sequence into (fn, args). Instead of recursively calling public ec.Invoke, its raw form returns that pending call. Public/non-tail callers drain it immediately; a tail opcode can reuse its frame for the target.
This does not imply that every callback-taking native becomes resumable in v1.
6. Define dispatcher scope explicitly
ProtocolFn can resolve its implementation without first invoking another function, so it is a reasonable v1 candidate for returning the implementation as a pending call.
MultiFn is different: it must invoke the dispatch function, await its value, select a method, and then invoke the method. Constant-space recursion through a multimethod therefore needs resumable dispatcher state, not only a (fn, args) redirect. Either implement that state machine deliberately or mark MultiFn out of scope for v1. Do not call it bounded while it still nests one dispatch continuation per recursive call.
Correctness invariants
Public APIs never return a pending-call outcome.
A prepared call has exactly one owner for argument storage before frame replacement begins.
Closure captures survive metadata and multi-arity unwrapping.
Same-frame replacement is legal only when no handler or cleanup continuation must return to the current body. The compiler already clears tail position inside try/catch/finally; retain runtime assertions or a safe fallback and add regressions.
binding and with-redefs cleanup remains intact.
Eliminated tail frames may disappear from traces, but errors from arity resolution, native leaves, apply*, and dispatcher selection receive a defined nearest-call-site source.
Preserve the current tracing/profile/allocation-attribution semantics of the existing *Func tail-reuse path unless a separate change intentionally revises them.
OP_RECUR and OP_RECUR_FN remain in-frame recursion mechanisms; do not route them through the general resolver merely for symmetry.
Is ProtocolFn required for v1? Recommendation: yes; its lookup can redirect without a resumable intermediate computation.
Is MultiFn required for v1? Recommendation: no; track the resumable dispatch state separately unless implementation work shows it is small and reviewable.
What raw continuation API should pkg/rt receive? It must be opaque to let-go values and impossible to leak through public Invoke.
Summary
#645 changes the starting point for #620.
The compiler already emits
OP_TAIL_CALLfor ordinary calls in tail position. Before #645, a tail call to a*Closureor*MultiArityFnfell throughExecContext.Invokeinto a nestedFrame.Run, so deep recursion grew the Go stack. #645 makes those direct bytecode spans Go-stack-safe by descending through an explicit parent-linked frame chain.That is not yet general tail-call elimination. A closure or multi-arity tail call still allocates and retains one child
Frameper call. The remaining #620 goal is therefore:The implementation should be stacked on #645. It must not introduce a second callee resolver or a second frame-acquisition path.
State after #645
*Func*Closure*MultiArityFnapply*ExecContext.Invoke, hiding the target behind a new invocation segmentProtocolFnExecContext.InvokeMultiFnGoals and non-goals
Goals for v1
*Func,*Closure, and*MultiArityFn, including metadata wrappers and closures over multi-arity functions.apply*without allowing an internal continuation to escape throughValue, variables, collections, or public host APIs.OP_TAIL_CALLsemantics for protected regions, dynamic bindings, errors, tracing, profiling, and host entry points.Non-goals for v1
Frame.Runis Go-recursive — deep lg→lg recursion aborts the process #644 own that path and its resource budget.reduce,map, and other natives that synchronously call back into let-go remain a broader native-continuation boundary unless they explicitly adopt the raw protocol.IFn, callableVar, orifnAdaptervalues are constant-space unless they are explicitly covered by the resolver and tests.Architecture
1. Separate call preparation from frame allocation
#645's
childFrameForcurrently combines two responsibilities:MetaFn/MultiArityFn/Closureto a concrete bytecode*Func, preserving captures and validating/packing arguments;Frame.Extract the first responsibility into a behavior-preserving resolver. The exact names are not prescribed, but the shape is:
bool == falsemeans the callable is not a directly executable bytecode target and must proceed through raw/public invocation. The resolver owns:It does not allocate a frame or decide whether the call is tail-positioned.
2. Give one prepared target two transitions
The caller chooses the transition, not the resolver:
OP_INVOKE: allocate a child frame, link it to the suspended parent, and descend as vm: make direct bytecode calls non-recursive with an explicit frame chain #645 already does.OP_TAIL_CALL: replace the current frame's executable state with the prepared target and continue the same dispatch loop.The tail replacement must install, at minimum:
code,consts, andconstsc;closedOvers(clear for plain functions, install for closures);argc;ip = 0andsp = 0;The existing separate zero/nonzero-arity
OP_TAIL_CALLbranches should converge on this path.3. Make argument ownership explicit
Arguments read by
OP_TAIL_CALLare slices into the current operand stack. They cannot remain borrowed while that stack is reset and reused.The transition must copy/reposition them into storage owned by the reused frame before overwriting the source. Variadic packing must also be fresh or frame-owned; the current
append(sargs, restlist)tail path can reuse and mutate an input backing array.A reusable frame-owned argument buffer is preferable to allocating a new slice on every tail hop. Correctness comes first; allocation behavior is measured and optimized after the ownership invariant is explicit.
4. Split raw invocation from public invocation
Fn.Invoke,NativeFn.proxy, and publicExecContext.Invokereturn(Value, error). A pending call must not implementValuemerely to fit those interfaces.Introduce a private/raw outcome with two states:
Value;(Fn, []Value).Then enforce these boundaries:
ExecContext.Invokeiteratively drains pending calls and returns only a finalValue;OP_INVOKEuses the draining/public behavior when a callable cannot become a direct bytecode child;OP_TAIL_CALLconsumes the raw outcome, so a pending bytecode target can replace the current frame;Fn.Invokeentry points remain continuation-proof.Because
apply*is defined inpkg/rt, the opt-in API may need a small exported constructor or opaque outcome type. It must remain impossible to store the pending-call object as a let-go value.5. Make
apply*the first raw continuation producerapply*already normalizes its final sequence into(fn, args). Instead of recursively calling publicec.Invoke, its raw form returns that pending call. Public/non-tail callers drain it immediately; a tail opcode can reuse its frame for the target.This does not imply that every callback-taking native becomes resumable in v1.
6. Define dispatcher scope explicitly
ProtocolFncan resolve its implementation without first invoking another function, so it is a reasonable v1 candidate for returning the implementation as a pending call.MultiFnis different: it must invoke the dispatch function, await its value, select a method, and then invoke the method. Constant-space recursion through a multimethod therefore needs resumable dispatcher state, not only a(fn, args)redirect. Either implement that state machine deliberately or markMultiFnout of scope for v1. Do not call it bounded while it still nests one dispatch continuation per recursive call.Correctness invariants
try/catch/finally; retain runtime assertions or a safe fallback and add regressions.bindingandwith-redefscleanup remains intact.apply*, and dispatcher selection receive a defined nearest-call-site source.*Functail-reuse path unless a separate change intentionally revises them.OP_RECURandOP_RECUR_FNremain in-frame recursion mechanisms; do not route them through the general resolver merely for symmetry.Execution plan
Phase 1 — resolver extraction, no behavior change
childFrameForwith a pure prepared-target resolver plus a separate child-frame constructor.pkg/vm, allocation ratchets, and the existing vm: make direct bytecode calls non-recursive with an explicit frame chain #645 benchmark matrix.Exit gate: bytecode and observable behavior are unchanged; no tail-reuse behavior is added.
Phase 2 — constant-space bytecode tail replacement
OP_TAIL_CALLarities through the prepared target.Func,Closure, andMultiArityFntargets.Exit gate: deep capturing-closure and multi-arity tail recursion use one live VM frame rather than a linear parent chain.
Phase 3 — raw invocation and
apply*apply*return a pending call through the raw-only API.apply*can replace the current frame; non-tail and host callers still receive ordinary values.Invoke, Vars, collections, macros, callbacks, or host APIs.Exit gate: deep recursion through tail-position
apply*is constant-space, and non-tailapply*is semantically unchanged.Phase 4 — dispatchers
ProtocolFnresolution through the raw path if included in v1.MultiFngets a resumable dispatcher in this issue or a dedicated follow-up.Phase 5 — validation and performance disposition
Frame.RunandFunc.Invoke.loop/recur.apply*tail loops.Required tests
apply*.apply*.Invokecontainment: only finalValueescapes.try/catch/finally,binding, andwith-redefscleanup.ProtocolFnis in v1.MultiFnis claimed in scope.Relationships
Frame.Runis Go-recursive — deep lg→lg recursion aborts the process #644 owns non-tail frame growth, execution budgeting, and the remaining general native-callback boundary.OP_CALL_SELFpath must not be revived unchanged.Open decisions before Phase 3
ProtocolFnrequired for v1? Recommendation: yes; its lookup can redirect without a resumable intermediate computation.MultiFnrequired for v1? Recommendation: no; track the resumable dispatch state separately unless implementation work shows it is small and reviewable.pkg/rtreceive? It must be opaque to let-go values and impossible to leak through publicInvoke.