Skip to content

Interpret with-continuation-mark via a CEK abstract machine#83

Merged
pmatos merged 5 commits into
mainfrom
issue-11
Jul 2, 2026
Merged

Interpret with-continuation-mark via a CEK abstract machine#83
pmatos merged 5 commits into
mainfrom
issue-11

Conversation

@pmatos

@pmatos pmatos commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Summary

Implements with-continuation-mark (issue #11). Because the interpreter had no
first-class continuation to hang marks on, this takes the CEK/CESK abstract-machine
route agreed for the issue: the recursive tree-walker is replaced by a machine whose
continuation is an explicit stack of typed frames, and continuation marks live on
those frames (as they do in Racket). This is also the foundation for the remaining
delimited-continuation primitives, tracked as follow-ups #79, #80, #81.

Delivered in two reviewable commits:

  • Re-architect the interpreter as a CEK abstract machine — explicit continuation
    frames + a driver loop, a first-class environment chain (preserving the previous
    set!/closure semantics), per-frame mark maps, and the WithContinuationMark node /
    ContinuationMarkSet value / valueEq infrastructure. Behavior-preserving: all
    pre-existing tests pass on this commit alone.
  • Interpret with-continuation-mark and mark query primitives — parser wiring plus
    current-continuation-marks, continuation-mark-set-first, and
    continuation-mark-set->list, with tests.

Semantics

(with-continuation-mark key val result) evaluates key and val, installs the mark
on the current continuation frame, and evaluates result in tail position. Marks for
the same key in one frame overwrite; marks in separate frames (e.g. a caller and a
callee) accumulate, innermost-first.

Scope / follow-ups

Per the plan for this issue, the heavier delimited-continuation primitives are separate
follow-ups on top of this machine:

Known limitation for a future refinement: the machine pushes an activation frame per
call (no tail-call space-safety yet), so a tail loop that repeatedly re-marks the same
key accumulates frames rather than collapsing them. Mark values are correct; only the
space behaviour differs.

Fixes #11

Test plan

  • ctest --preset debug — 14/14 passing (adds a with-continuation-mark parser test)
  • nora-lit test/integration — 54/54 passing (adds 6 with-continuation-mark*.rkt tests)
  • clang-format (v22) clean on all edited files
  • Warning-free build with warnings-as-errors
  • First commit builds and passes all pre-existing tests in isolation (rewrite is behavior-preserving)

pmatos added 2 commits July 1, 2026 19:33
Replace the recursive tree-walking interpreter with a CEK/CESK abstract
machine whose continuation is an explicit, first-class stack of typed
frames stepped by a driver loop. The environment becomes a first-class
chain of shared scopes (preserving the previous set!/closure semantics),
and each continuation frame carries a set of continuation marks - the
representation Racket uses, and the foundation for the delimited-
continuation primitives (prompts, call/cc, composable continuations)
tracked as follow-ups.

This also adds the machine-side pieces continuation marks need: the
WithContinuationMark expression node, the ContinuationMarkSet runtime
value, an eq?-approximating valueEq for mark keys, and the with-
continuation-mark / current-continuation-marks transitions. They are not
yet reachable from source (no parser wiring) or observable (no query
primitives); that follows in the next commit. Behavior is otherwise
unchanged - all existing tests pass.
Wire (with-continuation-mark key val result) into the parser and register
the continuation-mark query primitives (current-continuation-marks,
continuation-mark-set-first, continuation-mark-set->list) so marks are
now both writable and observable from linklet source.

A with-continuation-mark installs its key/value on the current
continuation frame while the result runs in tail position; marks set for
the same key in one frame overwrite, while marks in separate frames (e.g.
a caller and a callee) accumulate innermost-first. Adds integration tests
for each of these behaviours plus a parser unit test.
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.14706% with 135 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.17%. Comparing base (d950d13) to head (59ad629).

Files with missing lines Patch % Lines
src/Interpreter.cpp 83.19% 80 Missing ⚠️
src/ASTRuntime.cpp 53.44% 27 Missing ⚠️
src/AST.cpp 60.00% 8 Missing ⚠️
src/Parse.cpp 82.35% 6 Missing ⚠️
src/AnalysisFreeVars.cpp 0.00% 5 Missing ⚠️
src/Runtime.cpp 87.80% 5 Missing ⚠️
src/Environment.cpp 84.61% 2 Missing ⚠️
src/include/ASTRuntime.h 88.88% 1 Missing ⚠️
src/include/Interpreter.h 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #83      +/-   ##
==========================================
- Coverage   76.47%   75.17%   -1.30%     
==========================================
  Files          26       25       -1     
  Lines        2567     2908     +341     
  Branches      371      393      +22     
==========================================
+ Hits         1963     2186     +223     
- Misses        604      722     +118     
Flag Coverage Δ
integration 75.17% <80.14%> (-1.30%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4da6f46607

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Interpreter.cpp
@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Code review

Found one significant issue in the new CEK machine's environment handling — see inline comment.

  • Closure application leaks the caller's local environment into the callee, breaking lexical scoping (src/Interpreter.cpp, applyProcedure): a free variable unbound at a closure's definition site can incorrectly resolve to a same-named local binding at the call site, instead of raising an unbound-identifier error.

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Details on the environment-scoping issue

src/Interpreter.cpp, in Interpreter::applyProcedure (

nora/src/Interpreter.cpp

Lines 327 to 332 in 4da6f46

// Build the callee environment: the caller's environment, extended with the
// closure's captured bindings, extended with the argument bindings.
EnvPtr AfterClosure = envExtend(ApplyEnv, C->getEnvironment());
auto CalleeScope = std::make_shared<Scope>();
CalleeScope->Parent = AfterClosure;
):

// Build the callee environment: the caller's environment, extended with the
// closure's captured bindings, extended with the argument bindings.
EnvPtr AfterClosure = envExtend(ApplyEnv, C->getEnvironment());
auto CalleeScope = std::make_shared<Scope>();
CalleeScope->Parent = AfterClosure;

ApplyEnv is the caller's environment — it's set as F.Env = Env in visit(ast::Application), then threaded straight through to applyProcedure via the App frame in continueStep (lines 110-114).

Since Closure::Closure only captures free variables that resolve to a binding at definition time (an unresolved free variable is simply skipped — see src/ASTRuntime.cpp), splicing the caller's local environment into the callee's lexical lookup chain means an identifier that is free and unbound in the closure's defining scope, but happens to be bound in whatever environment is active at the call site, resolves to the caller's binding instead of raising an unbound-identifier error. This is dynamic scoping leaking through, not lexical scoping.

Concrete repro:

(linklet () ()
  (define-values (g) (lambda (x) y))     ; y is free in g's body, unbound at definition
  (define-values (h) (lambda (y) (g 0))) ; h has a local binding for y
  (h 42))

Expected (Racket semantics): an unbound-identifier error for y. With the current chain: (h 42) calls g, and inside g, looking up y misses the params and the (empty, since y was unbound at g's definition) captured env, then falls through to ApplyEnvh's environment — and finds y = 42.

The existing test suite doesn't catch this because every closure test captures its free variables at definition time (so the captured binding shadows the caller's environment before the chain ever reaches it).

Suggested fix: AfterClosure's parent should be GlobalEnv (so later top-level define-values remain visible, per the comment in visit(Linklet)), not ApplyEnv — the caller's local environment shouldn't be part of the callee's lexical chain at all.

pmatos added 2 commits July 1, 2026 20:49
applyProcedure based the callee environment on the caller's environment
(ApplyEnv), so a free variable left uncaptured at a closure's definition
site (because it was unbound there) could resolve to a same-named binding
at the call site - dynamic scoping. Base the callee chain on the
persistent top-level GlobalEnv instead, so top-level define-values stay
visible but caller locals never leak in.

Also make getResult() return null on interpretation failure instead of
asserting/dereferencing a null Result, so an unbound identifier exits
cleanly (main already reports it) rather than aborting. Adds a regression
test.

Reported in review of 4da6f46 (claude[bot]).
with-continuation-mark wrote its mark into the enclosing continuation
frame and never removed it, so in non-tail position the mark outlived
its dynamic extent: (begin (with-continuation-mark 'k 1 0)
(continuation-mark-set-first (current-continuation-marks) 'k)) returned 1
instead of #f. Push a dedicated mark-bearing frame that is popped when
the result expression produces a value, bounding the mark's extent to
that expression while keeping it visible across calls made within it.

Same-key marks in separate frames still accumulate and
continuation-mark-set-first still returns the innermost value; nested
tail same-key marks no longer collapse in continuation-mark-set->list,
consistent with the PR's documented no-tail-call-space-safety limitation.
Adds a non-tail regression test and refreshes now-stale comments.

Reported in review thread #discussion_r3507975126 (Codex).
@pmatos

pmatos commented Jul 1, 2026

Copy link
Copy Markdown
Owner Author

@claude Regarding your code review of 4da6f46 (dynamic-scoping leak in applyProcedure):

Fixed in 00565dc.

Changed: the callee environment is now based on the persistent top-level GlobalEnv instead of the caller's ApplyEnv, so top-level define-values stay visible but caller locals no longer leak into the callee's lexical lookup chain. Your repro now raises Undefined Identifier: y instead of returning 42. I also hardened getResult() to return null on interpretation failure (instead of asserting / null-dereferencing Result) so an unbound identifier now exits cleanly via main's existing failure path rather than aborting.
Validation: added test/integration/unbound-free-var.rkt (a not norac … | FileCheck error test); closure.rkt (=> 12) and let-values3.rkt (=> 8) still pass; full nora-lit (56) + ctest (14) green, warning-free build.

This finding was independently confirmed by two AI reviewers (Claude Opus 4.8 and Codex GPT-5.5) before the fix was applied.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4bc809e34f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Interpreter.cpp
// produces a value (see the WcmMark case), so the mark's dynamic extent is
// exactly the result expression - a with-continuation-mark in non-tail
// position no longer leaks its mark into later expressions.
Kont.emplace_back(Frame::WcmMark);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve same-frame WCM overwrites

When the result expression is itself a with-continuation-mark in tail position, this fresh WcmMark frame makes the two marks look like separate continuation frames. For example, (with-continuation-mark 'k 1 (with-continuation-mark 'k 2 (continuation-mark-set->list (current-continuation-marks) 'k))) returns (2 1) with this machine, but the inner tail-position mark should replace the outer mark for the same key on the current frame, so continuation-mark-set->list exposes a stale outer mark rather than just the current value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope for this PR — you're right: (with-continuation-mark 'k 1 (with-continuation-mark 'k 2 (continuation-mark-set->list (current-continuation-marks) 'k))) returns (2 1) here where Racket gives (2).

This is a symptom of the machine's documented lack of tail-call space safety: each with-continuation-mark currently gets its own frame, so directly-nested same-key marks aren't collapsed. continuation-mark-set-first is already correct, and only continuation-mark-set->list on directly-nested same-key marks diverges. A narrow patch (reuse the top frame in the WcmVal step when it is already a WcmMark) would fix only this exact shape and would leave (wcm 'k 1 (begin x (wcm 'k 2 e))) and (wcm 'k 1 (f)) still returning (2 1) — i.e. same-key collapse would depend on an incidental implementation artifact rather than a rule. The correct fix collapses same-key marks on genuine tail position together with tail-call space safety, so I'd rather land it holistically than special-case the WcmMark-on-top corner now.

Tracked as #87 (#87).

Triaged by two independent AI reviewers (Claude Opus 4.8 and Codex GPT-5.5); they split FIX vs DEFER on scope, so this is deferred to a tracked issue rather than partially patched here. If you think the narrow fix is worth landing now, reply and we'll re-evaluate.

Reconcile the CEK abstract machine (with-continuation-mark, #11) with the
features main gained since this branch was opened: the DiagnosticEngine
error system (#57/#84) and the case-lambda (#3), letrec-values (#8), and
#%variable-reference (#13) / keyword forms.

- Closures now capture the live lexical environment chain instead of a copy
  of their free variables. This gives correct lexical scoping and makes
  letrec-values forward/mutual references resolve, and it subsumes the
  earlier dynamic-scope fix. The interpreter tracks every scope it creates
  and clears their bindings at teardown to break the closure/scope shared_ptr
  cycles this introduces (verified clean under LeakSanitizer).
- All interpreter error paths now report through the DiagnosticEngine at the
  offending node's source location, matching main's messages; main() decides
  the exit status from Diag.hadError().
- case-lambda / case-lambda closures, #%variable-reference, and keyword are
  evaluated by the machine (case-lambda selects the first clause whose formals
  accept the argument count); letrec-values fills a recursive scope in place.

All presets green: ctest 21/21, nora-lit 88/88 under debug, asan, and ubsan;
warning-free build; clang-format clean.
@pmatos pmatos merged commit f4e86f0 into main Jul 2, 2026
20 checks passed
@pmatos pmatos deleted the issue-11 branch July 2, 2026 07:24

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 59ad6299de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Runtime.cpp
Comment on lines +131 to +132
if (Args.size() == 2) {
const auto *CMS = llvm::dyn_cast<ast::ContinuationMarkSet>(Args[0]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle #f in continuation-mark-set-first

When the mark-set argument is #f, this path either falls through after the failed ContinuationMarkSet cast or, for the generated 4-argument form, skips the lookup entirely because Args.size() != 2. The checked-in expander already relies on (continuation-mark-set-first #f parameterization-key) in expander/expander.rktl:79 and 4-argument calls like expander/expander.rktl:5358, so parameterization/code-inspector lookups will always see #f even inside a with-continuation-mark; this needs access to the current continuation like current-continuation-marks instead of being a pure runtime-function lookup.

Useful? React with 👍 / 👎.

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.

Interpret with-continuation-mark

1 participant