You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/07/10 13:14:52 UTC

(groovy-website) branch asf-site updated (0a28012 -> 490e346)

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a change to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git


    from 0a28012  draft "Compact Closure and Lambda Compilation" GEP
     new 9a81113  draft "Compact Closure and Lambda Compilation" GEP
     new 1ca3168  GEP-27: update with prototype findings; remove outdated framing
     new 69e0ff7  GEP-27: reconcile with implementation — @PackedClosures name and mode diagnostics
     new b7a6027  GEP-27: document PackMode.DISABLED opt-out
     new 490e346  GEP-27: note the DISABLED opt-out in the per-closure-control deferred-feature row

The 5 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 site/src/site/wiki/GEP-27.adoc | 237 +++++++++++++++++++++++++++++++++--------
 1 file changed, 195 insertions(+), 42 deletions(-)


(groovy-website) 04/05: GEP-27: document PackMode.DISABLED opt-out

Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git

commit b7a60275e8240f670a987cfdd46aedc4803d4ecd
Author: Paul King <pa...@asert.com.au>
AuthorDate: Fri Jul 10 23:07:15 2026 +1000

    GEP-27: document PackMode.DISABLED opt-out
    
    Add the fourth mode value: DISABLED packs nothing in its scope (the fine-grained
    opt-out), most-specific declaration wins so a DISABLED method beats a packed
    class, and DISABLED overrides the groovy.target.closure.pack flag.
---
 site/src/site/wiki/GEP-27.adoc | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
index ac4740c..a11e7b0 100644
--- a/site/src/site/wiki/GEP-27.adoc
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -325,7 +325,9 @@ modes:
   case above — an externally-set delegate is invisible at the closure's definition site (the very
   reason dynamic packing needs a trust assertion), so `WARN`/`STRICT` will report "packed" on exactly
   the closure the runtime guard later has to catch. Only the annotation opts in; the automatic
-  `groovy.target.closure.pack` path is always lenient.
+  `groovy.target.closure.pack` path is always lenient. A fourth value, `DISABLED`, is the opposite —
+  a fine-grained *opt-out* that packs nothing in its scope; the most-specific declaration wins, so a
+  `DISABLED` method opts out of a packed class (and `DISABLED` overrides the flag too).
 
 [cols="1,1,3",options="header"]
 |===
@@ -448,9 +450,12 @@ means byte-for-byte today's behaviour.
 |Closure packing under dynamic compilation |`@PackedClosures` opt-in only |off unless annotated
 |===
 
-`@PackedClosures` carries a `mode` element (`LENIENT` | `WARN` | `STRICT`, default `LENIENT`) that
-reports closures it could not pack — silently, as a compiler warning, or as a compile error; the
-automatic `groovy.target.closure.pack` path is always lenient. Every packed closure is backed by the
+`@PackedClosures` carries a `mode` element (`LENIENT` | `WARN` | `STRICT` | `DISABLED`, default
+`LENIENT`) that reports closures it could not pack — silently, as a compiler warning, or as a compile
+error — or, with `DISABLED`, opts the scope out of packing entirely. The most-specific declaration
+wins (a `DISABLED` method beats a packed class), and `DISABLED` also overrides the flag; the
+WARN/STRICT diagnostics apply only to the annotation, never to the automatic
+`groovy.target.closure.pack` path. Every packed closure is backed by the
 `PackedClosure` runtime guard; see _Guarding the dynamic trust assertion_ for what each does and does
 not catch.
 


(groovy-website) 03/05: GEP-27: reconcile with implementation — @PackedClosures name and mode diagnostics

Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git

commit 69e0ff79bb7a0068792b2983056c6ff579c80518
Author: Paul King <pa...@asert.com.au>
AuthorDate: Fri Jul 10 22:42:16 2026 +1000

    GEP-27: reconcile with implementation — @PackedClosures name and mode diagnostics
    
    Align the doc with what shipped:
    - rename @Packed -> @PackedClosures throughout (14 occurrences); the annotation is
      named for what it packs (closures), and since a dynamic arrow-lambda is itself a
      closure with no separate dynamic-lambda packing, the closures-specific name is
      exact -- note added;
    - replace the boolean @Packed(strict = true) design with the delivered
      @PackedClosures(mode = LENIENT | WARN | STRICT) enum (silent / compiler warning
      per decline with reason / compile error), and state that the automatic
      groovy.target.closure.pack path is always lenient;
    - drop 'designed but not yet wired' -- the mode diagnostics are implemented and
      listed among the delivered safety mechanisms.
---
 site/src/site/wiki/GEP-27.adoc | 57 +++++++++++++++++++++++-------------------
 1 file changed, 31 insertions(+), 26 deletions(-)

diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
index 7e38eac..ac4740c 100644
--- a/site/src/site/wiki/GEP-27.adoc
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -212,9 +212,10 @@ _provably safe_ and _valuable_.
   already routes a SAM-targeted expression to the lighter (metafactory) writer; it is the
   programmer saying "treat me as a function." A brace closure and an arrow lambda targeting the
   _same_ functional interface compile differently today for exactly this reason.
-* *An opt-in annotation* (working name `@Packed`) is the same kind of thing: a programmer
-  assertion that a closure needs no external delegate, supplied where the compiler cannot prove
-  it (i.e. under dynamic compilation).
+* *The `@PackedClosures` annotation* is the same kind of thing: a programmer assertion that a
+  closure needs no external delegate, supplied where the compiler cannot prove it (i.e. under
+  dynamic compilation). It is named for what it packs — closures; a dynamic arrow-lambda is itself
+  a closure, and there is no separate dynamic-lambda packing, so the closures-specific name is exact.
 * *SAM vs not* is the target-type _upper bound_ on capability: a SAM target permits the
   object-free metafactory path; a `Closure`/`Object` target caps at the adapter.
 * *Capturing vs not* is a _cost dial_ (allocation vs singleton) within whatever strategy is
@@ -247,7 +248,7 @@ closure / lambda body
         v
  hoist to a method on the enclosing class      <-- shared backend
         |
-   capability inference  (target type; arrow/@Packed declarations;
+   capability inference  (target type; arrow/@PackedClosures declarations;
         |                  static-only escape + @DelegatesTo + serialization analysis)
         v
    +----+----+----------------------+---------------------+
@@ -296,7 +297,7 @@ it can land first.
 === Guarding the dynamic trust assertion
 
 Under `@CompileStatic` the escape analysis _proves_ delegate-independence and declines the rest,
-so the boundary is enforced by construction. Under dynamic compilation `@Packed` is a *trust
+so the boundary is enforced by construction. Under dynamic compilation `@PackedClosures` is a *trust
 assertion* the compiler cannot verify — so the design must protect a user who annotates in good
 faith and gets it wrong. Two mechanisms do this, and they deliberately cover *different* failure
 modes:
@@ -315,18 +316,21 @@ modes:
   so throwing is the only honest response. In the `@CompileStatic` case the guard should never fire
   (those closures are proven and declined), but keeping it is cheap defense-in-depth and documents
   the contract uniformly.
-* *`@Packed(strict = true)` (compile-time visibility, *not* a safety net).* Errors at compile time
-  if any closure in scope cannot be packed, so a decline is reported rather than silently yielding
-  a class. It surfaces the *visible* declines the compiler already knows about (local
-  `delegate`/`resolveStrategy` use, serialization, syntactic escape). It deliberately does *not*
-  protect against the wrong-assumption case above — an externally-set delegate is invisible at the
-  closure's definition site (that is the very reason dynamic packing needs a trust assertion), so
-  `strict` will report "packed" on exactly the closure the runtime guard later has to catch.
+* *`@PackedClosures(mode = ...)` (compile-time visibility, *not* a safety net).* Packing declines
+  are otherwise silent, so the annotation's `mode` element surfaces them: `LENIENT` (the default)
+  stays silent, `WARN` emits a compiler warning per unpacked closure naming the source position and
+  the reason, and `STRICT` turns any decline in scope into a compile error. It surfaces the *visible*
+  declines the compiler already knows about (a closure that resolves against a delegate, escapes its
+  method, or needs a real `Closure`). It deliberately does *not* protect against the wrong-assumption
+  case above — an externally-set delegate is invisible at the closure's definition site (the very
+  reason dynamic packing needs a trust assertion), so `WARN`/`STRICT` will report "packed" on exactly
+  the closure the runtime guard later has to catch. Only the annotation opts in; the automatic
+  `groovy.target.closure.pack` path is always lenient.
 
 [cols="1,1,3",options="header"]
 |===
 |Mechanism |When |Catches
-|`@Packed(strict = true)` |compile time |_visible_ declines — "did I pack what I intended?"
+|`@PackedClosures(mode = WARN or STRICT)` |compile time |_visible_ declines — "did I pack what I intended?"
 |`PackedClosure` throws on `setDelegate`/`setResolveStrategy` |runtime |the _invisible_ wrong assumption — "did anyone misuse what I packed?"
 |===
 
@@ -334,7 +338,7 @@ A conservative *escape gate* shrinks the runtime surface further and is implemen
 closure literal that visibly escapes its method — stored into a field/property/index (the Grails
 `static constraints = { … }` shape), returned, appended with `<<`, or placed in a collection literal
 — is declined at compile time and kept as a class. This moves the clearest delegate-risk cases from
-a runtime throw to a compile-time non-event; those declines are also what `strict` would report.
+a runtime throw to a compile-time non-event; those declines are also what `WARN`/`STRICT` report.
 Because syntactic escape analysis cannot prove that, say, a custom `each` does not set a delegate,
 the runtime guard remains the essential backstop under dynamic compilation. (A closure that is
 fully self-contained — every free name binds to the owner or its own parameters — is
@@ -402,8 +406,8 @@ shared `Reference`, including `+=`/`++`), *arbitrary-depth nested closures*, imp
 both `@CompileStatic` and dynamic compilation, correctly declining closures that use
 `delegate`/`owner`/`super`. The dynamic opt-in's two runtime/compile safety mechanisms from
 _Guarding the dynamic trust assertion_ are already implemented and validated in the spike: the
-mandatory `PackedClosure` runtime guard and the compile-time escape decline (the optional
-`@Packed(strict = true)` enforcement is designed but not yet wired). What remains for a real feature
+mandatory `PackedClosure` runtime guard, the compile-time escape decline, and the
+`@PackedClosures(mode = WARN | STRICT)` decline diagnostics. What remains for a real feature
 is the *capability/escape analysis* that makes packing provably sound and therefore automatic under
 `@CompileStatic` (no annotation), falling back to S0 otherwise; and the owner-correct retargeting
 that lets self-contained nested closures (e.g. Grails `validator` blocks) be hoisted — see
@@ -440,14 +444,15 @@ means byte-for-byte today's behaviour.
 |Concern |Flag (proposed) |Default
 
 |Non-capturing SAM lambda hoisting (Groovy 6) |`groovy.target.lambda.hoist.disabled` |off (i.e. hoisting *on*), opt-out
-|Closure packing under `@CompileStatic` (Groovy 7) |`groovy.target.closure.pack` (+ per-scope `@Packed`) |to be decided per release
-|Closure packing under dynamic compilation |`@Packed` opt-in only |off unless annotated
+|Closure packing under `@CompileStatic` (Groovy 7) |`groovy.target.closure.pack` (+ per-scope `@PackedClosures`) |to be decided per release
+|Closure packing under dynamic compilation |`@PackedClosures` opt-in only |off unless annotated
 |===
 
-Under dynamic compilation `@Packed` carries an optional `strict` element
-(`@Packed(strict = true)`) that turns any *decline* in scope into a compile error, and every
-packed closure is backed by the `PackedClosure` runtime guard; see _Guarding the dynamic trust
-assertion_ for what each does and does not catch.
+`@PackedClosures` carries a `mode` element (`LENIENT` | `WARN` | `STRICT`, default `LENIENT`) that
+reports closures it could not pack — silently, as a compiler warning, or as a compile error; the
+automatic `groovy.target.closure.pack` path is always lenient. Every packed closure is backed by the
+`PackedClosure` runtime guard; see _Guarding the dynamic trust assertion_ for what each does and does
+not catch.
 
 The default *direction* is a policy decision independent of the mechanism: a feature can ship
 *on* with an opt-out flag (deliver the win immediately, keep a back-out valve), or *off* with an
@@ -545,7 +550,7 @@ development:
   assert the old bytecode shape need updating to the new structure (behaviour is unchanged); that
   test migration is the bulk of the remaining first-step work.
 * *Closure packing (Groovy 7 direction).* A `ClosureWriter`-based spike (with a shared
-  `PackedClosure` runtime adapter), opt-in via `@Packed`, demonstrates hoisting closures — captures,
+  `PackedClosure` runtime adapter), opt-in via `@PackedClosures`, demonstrates hoisting closures — captures,
   arbitrary-depth nesting, written captures via `Reference`, implicit `it` — under both compilation
   modes. It confirms the writer is the right home (no `@CompileStatic` boundary, unlike an
   AST-transform approach), establishes the soundness cliff empirically, and confirms what is
@@ -611,7 +616,7 @@ suite before it lands.
 
 |1 |Non-capturing SAM lambda class elimination + flag + test migration + IDE/tooling check |Java-parity lambda codegen for the common SAM case; back-out flag |Groovy 6.0
 |2 |Read-only-capturing SAM lambdas (metafactory-captured static impl) |Removes the class, `Closure` alloc, and `Reference` wrapping for read-only capturing SAM lambdas (mutated captures decline) |Groovy 6.0
-|3 |Closure packing (S1) + capability/escape analysis + `@Packed` |Sound closure packing: automatic under `@CompileStatic`, opt-in (with runtime guard + escape decline) under dynamic |Groovy 7.0
+|3 |Closure packing (S1) + capability/escape analysis + `@PackedClosures` |Sound closure packing: automatic under `@CompileStatic`, opt-in (with runtime guard + escape decline) under dynamic |Groovy 7.0
 |4 |Typed static dispatch of hoisted bodies |Packed `@CompileStatic` closures are faster, not just smaller |Groovy 7.0
 |5 |Object elision (S3) + unify the writers |No object for non-escaping call-once bodies; one backend for closures and lambdas |Groovy 7.0
 |===
@@ -629,8 +634,8 @@ on the shared backend proven in phases 1–2.
 |Packing closures that use externally-set `delegate`/`resolveStrategy` |Not planned |These require a real per-closure representation; they are detected (statically) or declined and fall back to S0.
 |Serializable closure/lambda hoisting |Deferred |Kept on the class-based path initially; hoisting them requires carrying the deserialisation machinery to the enclosing class.
 |Object elision (S3) |Deferred (Groovy 7) |The most aggressive step; needs escape proof and, for iteration, callee knowledge.
-|Dynamic-mode automatic packing |Not planned |Cannot be proven sound without types; dynamic packing is opt-in via `@Packed` only.
-|A user-visible switch between S1/S2/S3 per closure |Not planned |The compiler picks the lightest sound representation; users influence it only via target type, arrow syntax, and `@Packed`.
+|Dynamic-mode automatic packing |Not planned |Cannot be proven sound without types; dynamic packing is opt-in via `@PackedClosures` only.
+|A user-visible switch between S1/S2/S3 per closure |Not planned |The compiler picks the lightest sound representation; users influence it only via target type, arrow syntax, and `@PackedClosures`.
 |===
 
 == Compatibility and impact


(groovy-website) 01/05: draft "Compact Closure and Lambda Compilation" GEP

Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git

commit 9a811130973f6b53a57a4d3da892749a404cfbc0
Author: Paul King <pa...@asert.com.au>
AuthorDate: Fri Jul 10 09:25:36 2026 +1000

    draft "Compact Closure and Lambda Compilation" GEP
---
 site/src/site/wiki/GEP-27.adoc | 49 ++++++++++++++++++++++++++++++++++++------
 1 file changed, 43 insertions(+), 6 deletions(-)

diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
index ceebd39..b7cca8f 100644
--- a/site/src/site/wiki/GEP-27.adoc
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -435,17 +435,54 @@ snapshot; run the debugger step/breakpoint scenarios for both closures and lambd
 common coverage and decompiler tools; and document the flag as the back-out for any tool not yet
 updated. This tooling verification is a first-class deliverable of the GEP, not an afterthought.
 
+=== Codegen-level verification (done for the SAM-lambda first step)
+
+The prototype hoisted method carries the debug metadata a debugger relies on, verified both by
+inspecting `javap -p -l` output and by an automated test (`LambdaHoistTest`) that reads the class
+bytes with ASM and asserts the following on every hoisted `$lambda$...` method:
+
+* *`LineNumberTable` with one entry per body line.* A multi-line lambda body yields a line-number
+  entry for each source statement, so line breakpoints, single-step, and "step into" land on the
+  correct source lines — and stack frames report accurate line numbers.
+* *Populated `LocalVariableTable` with the original names.* The captured values (now leading
+  parameters, e.g. `base`), the SAM parameters (e.g. `x`), and locals declared inside the body
+  (e.g. `a`, `b`) all appear by name, so the debugger's variables view is complete. This is on par
+  with — arguably better than — the old `doCall` frame, because the frame now names the enclosing
+  class.
+* *`ACC_SYNTHETIC` on the impl method.* The hoisted method is `private static synthetic`, so it is
+  correctly excluded from public-API views (Groovydoc, IDE member lists, stub generation).
+
+Because the hoisted body reuses the original AST statements, source positions propagate
+unchanged; there is no separate line-mapping step that could drift.
+
+=== Remaining human-only verification (cannot be scripted)
+
+The following must be exercised interactively in the reference IDEs and cannot be asserted from a
+build, so they remain open items before the flag defaults on:
+
+* IntelliJ IDEA and Eclipse: set a breakpoint inside a hoisted lambda body, confirm it binds and
+  stops; "step into" a call whose target is a hoisted lambda; inspect captured values, parameters,
+  and body locals in the variables view; confirm the frame names the enclosing class sensibly.
+* JDWP / remote-debug parity with the above against a stock JDK debugger.
+* Coverage tools (JaCoCo) and popular decompilers/bytecode viewers: confirm they attribute the
+  hoisted method to the enclosing class without error and do not rely on the old nested-class name.
+
 == Reference implementation
 
 A proof-of-concept exists as a spike in the code generator, exercised through this proposal's
 development:
 
-* *Lambda first step (Groovy 6 candidate).* Implemented in `StaticTypesLambdaWriter`:
-  non-capturing SAM lambda classes eliminated, impl hoisted to `private static` methods on the
-  enclosing class, metafactory singleton and behaviour verified, class-count reductions measured
-  as above. The existing lambda tests that assert the old bytecode shape need updating to the new
-  structure (behaviour is unchanged); that test migration is the bulk of the remaining first-step
-  work.
+* *Lambda first step (Groovy 6 candidate).* Implemented in `StaticTypesLambdaWriter`: both
+  non-capturing *and* read-only-capturing SAM lambda classes are eliminated, with the impl hoisted
+  to `private static` methods on the enclosing class (captured values become leading parameters)
+  and the metafactory bootstrapped directly against them — the non-capturing case yielding a
+  zero-alloc singleton. Behaviour and class-count reductions are verified as above, and the hoisted
+  methods are confirmed to carry full debug metadata (see _IDE and tooling considerations_). Cases
+  that need the shared `Reference` or closure identity — mutated captures, instance access,
+  serializable and nested-function lambdas — correctly decline and keep their class. The whole
+  first step sits behind the `groovy.target.lambda.hoist` flag. The existing lambda tests that
+  assert the old bytecode shape need updating to the new structure (behaviour is unchanged); that
+  test migration is the bulk of the remaining first-step work.
 * *Closure packing (Groovy 7 direction).* A `ClosureWriter`-based spike (with a `PackedClosure`
   runtime adapter) demonstrates hoisting closures — captures, arbitrary-depth nesting, written
   captures via `Reference`, implicit `it` — under both compilation modes, with correct declines


(groovy-website) 02/05: GEP-27: update with prototype findings; remove outdated framing

Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git

commit 1ca316812363c3fb630b636387c3c30b0e66273e
Author: Paul King <pa...@asert.com.au>
AuthorDate: Fri Jul 10 12:52:38 2026 +1000

    GEP-27: update with prototype findings; remove outdated framing
    
    Fold in the validated spike results and reconcile stale sections:
    - Reference implementation: PackedClosure runtime guard and compile-time escape
      decline are implemented+validated (every delegate-redirect funnels through the
      two setters); add measured impact (FormTagLib 8->1 classes, ~90% of closure
      bytes are scaffolding; RemarkCriteria domain 24 classes/80KB); document the
      Grails artefact-DSL safety and the unsound naive nested-validator hoist (needs
      owner-correct retargeting = Groovy 7).
    - Piece 2 (read-only-capturing SAM lambdas) is implemented, not a stretch; phase
      table and abstract updated accordingly.
    - Guarding section: resolveStrategy guard covers all non-owner modes (incl.
      TO_SELF); escape gate described as implemented syntactic form.
    - Bump last-modified date.
---
 site/src/site/wiki/GEP-27.adoc | 162 ++++++++++++++++++++++++++++++++++-------
 1 file changed, 134 insertions(+), 28 deletions(-)

diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
index b7cca8f..7e38eac 100644
--- a/site/src/site/wiki/GEP-27.adoc
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -14,7 +14,7 @@
 *Comment*:: Reduces the per-closure/per-lambda generated-class explosion by hoisting bodies onto the enclosing class. A small, sound-by-construction slice potentially targets Groovy 6; the broader closure work targets Groovy 7. Strictly additive; gated by a flag for back-out.
 *Leader*:: Paul King
 *Created*:: 2026-07-09
-*Last modification*:: 2026-07-09
+*Last modification*:: 2026-07-10
 ****
 
 [width="80%",align="center"]
@@ -52,8 +52,8 @@ actually needs:
 * where a value neither escapes nor needs closure identity, the object can be elided entirely.
 
 The proposal is deliberately phased. A **small, sound-by-construction slice — eliminating the
-generated class for non-capturing SAM lambdas — targets Groovy 6.0**. The **broader closure work,
-which needs a soundness analysis and a performance path, targets Groovy 7.0**. The change is
+generated class for non-capturing and read-only-capturing SAM lambdas — targets Groovy 6.0**. The
+**broader closure work, which needs a soundness analysis and a performance path, targets Groovy 7.0**. The change is
 strictly additive, defaults can be flipped via a flag for back-out, and it is gated so that any
 tooling that reflects on generated-class names has an escape hatch.
 
@@ -293,10 +293,59 @@ resolution — a `Function` has no `delegate`. And a SAM-typed lambda is, by its
 that SAM. So the entire soundness cliff is defined away for the SAM case, which is precisely why
 it can land first.
 
+=== Guarding the dynamic trust assertion
+
+Under `@CompileStatic` the escape analysis _proves_ delegate-independence and declines the rest,
+so the boundary is enforced by construction. Under dynamic compilation `@Packed` is a *trust
+assertion* the compiler cannot verify — so the design must protect a user who annotates in good
+faith and gets it wrong. Two mechanisms do this, and they deliberately cover *different* failure
+modes:
+
+* *Runtime guard (the safety net).* A `PackedClosure` throws — with an actionable message naming
+  the hoisted method — when asked for a configuration it cannot honour: `setDelegate(x)` to anything
+  other than the owner, or `setResolveStrategy(...)` to any non-owner mode (`DELEGATE_FIRST`,
+  `DELEGATE_ONLY`, or `TO_SELF` — which would resolve against the memberless shared adapter). This
+  is *fail-fast at the exact point the wrong assumption is exercised*,
+  rather than the silent alternative (free names quietly resolve against the owner, surfacing as a
+  confusing `MissingMethodException` frames away). Almost all delegate-dependent usage funnels
+  through these setters — explicit assignment, `with`/`tap` (which set `delegate` +
+  `DELEGATE_FIRST` internally), and builder DSLs — so the guard catches the realistic paths.
+  Setting `delegate` to the owner stays a no-op, so benign/defensive framework code is unaffected.
+  There is no runtime de-opt option: the hoisted body is already compiled to owner-bound bytecode,
+  so throwing is the only honest response. In the `@CompileStatic` case the guard should never fire
+  (those closures are proven and declined), but keeping it is cheap defense-in-depth and documents
+  the contract uniformly.
+* *`@Packed(strict = true)` (compile-time visibility, *not* a safety net).* Errors at compile time
+  if any closure in scope cannot be packed, so a decline is reported rather than silently yielding
+  a class. It surfaces the *visible* declines the compiler already knows about (local
+  `delegate`/`resolveStrategy` use, serialization, syntactic escape). It deliberately does *not*
+  protect against the wrong-assumption case above — an externally-set delegate is invisible at the
+  closure's definition site (that is the very reason dynamic packing needs a trust assertion), so
+  `strict` will report "packed" on exactly the closure the runtime guard later has to catch.
+
+[cols="1,1,3",options="header"]
+|===
+|Mechanism |When |Catches
+|`@Packed(strict = true)` |compile time |_visible_ declines — "did I pack what I intended?"
+|`PackedClosure` throws on `setDelegate`/`setResolveStrategy` |runtime |the _invisible_ wrong assumption — "did anyone misuse what I packed?"
+|===
+
+A conservative *escape gate* shrinks the runtime surface further and is implemented in the spike: a
+closure literal that visibly escapes its method — stored into a field/property/index (the Grails
+`static constraints = { … }` shape), returned, appended with `<<`, or placed in a collection literal
+— is declined at compile time and kept as a class. This moves the clearest delegate-risk cases from
+a runtime throw to a compile-time non-event; those declines are also what `strict` would report.
+Because syntactic escape analysis cannot prove that, say, a custom `each` does not set a delegate,
+the runtime guard remains the essential backstop under dynamic compilation. (A closure that is
+fully self-contained — every free name binds to the owner or its own parameters — is
+delegate-independent and safe to pack even where it escapes; exploiting that for nested closures
+needs owner-correct retargeting, see _Reference implementation_.)
+
 == Groovy 6.0 deliverables (pending discussion and findings)
 
-The Groovy 6 target is the sound-by-construction slice. One piece is proposed as the primary
-deliverable, with a second offered as an option.
+The Groovy 6 target is the sound-by-construction slice: eliminating the generated class for SAM
+lambdas. Both pieces below are implemented on the first-step branch behind a single flag; the
+mutated-capture and serializable cases correctly stay on the class-based path.
 
 === Piece 1 (primary): eliminate the class for non-capturing SAM lambdas
 
@@ -329,14 +378,16 @@ method onto the enclosing class). It requires no change to the `invokedynamic`/m
 bootstrap other than the implementation method's owner class, and does not require the
 class-visitor changes the closure work needs.
 
-=== Piece 2 (option): capturing SAM lambdas
+=== Piece 2 (implemented): read-only-capturing SAM lambdas
 
 A capturing SAM lambda today instantiates its lambda class to hold the captured values (Groovy
-even `Reference`-wraps them). The Java-style form uses a `static` implementation method taking
-the captured values as leading arguments, with the metafactory capturing them directly — which
-_also_ removes the `Closure` allocation and the `Reference` wrapping. This is a larger change
-than Piece 1 (the impl signature and bootstrap arguments change), so it is offered as a Groovy 6
-*stretch* or an early Groovy 7 item rather than a commitment.
+even `Reference`-wraps them). The Java-style form uses a `static` implementation method taking the
+captured values as leading arguments, with the metafactory capturing them directly — which _also_
+removes the `Closure` allocation and the `Reference` wrapping. This is implemented on the same
+first-step branch for the *read-only* capture case (the common one): captured values are passed as
+typed leading parameters. A lambda that *mutates* a capture still needs the shared `Reference`, so
+it correctly declines and keeps its class. This lands the bulk of the capturing benefit in Groovy 6
+behind the same flag; only mutated-capture lambdas remain on the class-based path.
 
 == Groovy 7.0 deliverables
 
@@ -349,9 +400,14 @@ closures rather than lambdas. A proof-of-concept in the code generator already d
 mechanism across the hard cases: no-capture and captured closures (by value; written captures via
 shared `Reference`, including `+=`/`++`), *arbitrary-depth nested closures*, implicit `it`, under
 both `@CompileStatic` and dynamic compilation, correctly declining closures that use
-`delegate`/`owner`/`super`. What remains for a real feature is the *capability/escape analysis*
-described above, so packing is chosen only when it is provably sound (automatic under
-`@CompileStatic`; opt-in via `@Packed` under dynamic), falling back to S0 otherwise.
+`delegate`/`owner`/`super`. The dynamic opt-in's two runtime/compile safety mechanisms from
+_Guarding the dynamic trust assertion_ are already implemented and validated in the spike: the
+mandatory `PackedClosure` runtime guard and the compile-time escape decline (the optional
+`@Packed(strict = true)` enforcement is designed but not yet wired). What remains for a real feature
+is the *capability/escape analysis* that makes packing provably sound and therefore automatic under
+`@CompileStatic` (no annotation), falling back to S0 otherwise; and the owner-correct retargeting
+that lets self-contained nested closures (e.g. Grails `validator` blocks) be hoisted — see
+_Reference implementation_.
 
 === Typed static dispatch (making it fast, not just small)
 
@@ -388,6 +444,11 @@ means byte-for-byte today's behaviour.
 |Closure packing under dynamic compilation |`@Packed` opt-in only |off unless annotated
 |===
 
+Under dynamic compilation `@Packed` carries an optional `strict` element
+(`@Packed(strict = true)`) that turns any *decline* in scope into a compile error, and every
+packed closure is backed by the `PackedClosure` runtime guard; see _Guarding the dynamic trust
+assertion_ for what each does and does not catch.
+
 The default *direction* is a policy decision independent of the mechanism: a feature can ship
 *on* with an opt-out flag (deliver the win immediately, keep a back-out valve), or *off* with an
 opt-in flag and be flipped a release later (conservative). The same one-line gate serves either
@@ -483,19 +544,64 @@ development:
   first step sits behind the `groovy.target.lambda.hoist` flag. The existing lambda tests that
   assert the old bytecode shape need updating to the new structure (behaviour is unchanged); that
   test migration is the bulk of the remaining first-step work.
-* *Closure packing (Groovy 7 direction).* A `ClosureWriter`-based spike (with a `PackedClosure`
-  runtime adapter) demonstrates hoisting closures — captures, arbitrary-depth nesting, written
-  captures via `Reference`, implicit `it` — under both compilation modes, with correct declines
-  for delegate/owner/super. It confirms the writer is the right home (no `@CompileStatic`
-  boundary, unlike an AST-transform approach) and empirically establishes the soundness cliff and
-  what is preserved. It does *not* yet include the capability/escape analysis or typed dispatch;
-  those are the Groovy 7 work.
-
-One supporting change surfaced by the closure spike is worth noting: hoisting nested closures
+* *Closure packing (Groovy 7 direction).* A `ClosureWriter`-based spike (with a shared
+  `PackedClosure` runtime adapter), opt-in via `@Packed`, demonstrates hoisting closures — captures,
+  arbitrary-depth nesting, written captures via `Reference`, implicit `it` — under both compilation
+  modes. It confirms the writer is the right home (no `@CompileStatic` boundary, unlike an
+  AST-transform approach), establishes the soundness cliff empirically, and confirms what is
+  preserved (`curry`/`memoize`/`trampoline` all keep working on the adapter). Two of the three
+  safety layers from _Guarding the dynamic trust assertion_ are now implemented and validated:
+** the *runtime guard* — `PackedClosure` rejects `setDelegate`/`setResolveStrategy` set to any
+   non-owner configuration. Probing confirmed every delegate-redirect path (`with`/`tap`,
+   `rehydrate`, builder DSLs, DGM) funnels through those two setters, so the guard turns what was a
+   silent wrong answer (owner-resolved where a delegate was expected) into a fail-fast error at the
+   point of misuse; owner-based `each`/`collect` closures stay byte-identical.
+** the *compile-time escape decline* — a closure literal that visibly escapes its method (stored
+   into a field/property/index, returned, appended with `<<`, or placed in a collection literal) is
+   kept as a class, moving the clearest delegate-risk cases from a runtime throw to a compile-time
+   non-event.
+
+The remaining Groovy 7 work is the *capability/escape analysis* for the `@CompileStatic` automatic
+path (proving packing sound so no annotation is needed) and typed static dispatch.
+
+=== Measured impact
+
+Closure classes are dominated by scaffolding, not logic. On a class reproducing seven real
+`FormTagLib` closure patterns, packing eliminated all seven closure classes (8 class files → 1;
+23.8 KB → 7.3 KB, ~69% fewer bytes); the hoisted method bodies added only 1.8 KB, so roughly *90%
+of a closure class's bytes were `Closure` scaffolding* — constructor, metaclass init,
+`serialVersionUID`, `Reference` fields — rather than the closure's own code. Grails domain classes
+are the extreme case: a representative `RemarkCriteria` domain generated *24 closure classes
+totalling 80 KB* — more bytecode than the entire rest of the class (1.19×).
+
+=== Grails artefact DSLs and nested closures
+
+The delegate DSLs Grails relies on — `static constraints = { … }`, `static mapping`,
+`namedQueries`, old-style `def action = { … }` — are field-assigned, which is exactly the escape
+gate's decline signal, so they are kept as real closure classes and their delegate resolution is
+unchanged (verified end-to-end against a builder delegate). Because Grails *discovers* these blocks
+by their being fields, the whole family lands on the safe side by construction rather than by
+special-casing; and the Groovy 6 SAM-lambda work never touches them at all (they are `Closure`s, not
+lambdas).
+
+The value left on the table in a domain class is the many *self-contained* nested closures — the
+`validator: { val, obj -> … }` blocks inside `constraints`, which reference only their own
+parameters and owner statics, not the constraints delegate. A spike that relaxed the nested-closure
+decline for these confirmed the class-count prize (the shown `RemarkCriteria` subset dropped
+6 → 2 closure classes) but proved the naive form *unsound*: the hoisted body was added to the
+enclosing closure class as an instance method, while a `static constraints` block's owner resolves
+to the domain *class*, so dispatch attempted a non-existent static method (`MissingMethodException`),
+and where it did dispatch, owner-static references resolved against the wrong owner. Hoisting a
+nested closure therefore requires *retargeting* the method onto the correct outermost owner,
+rebinding its free names to that owner, and emitting static-vs-instance dispatch to match — the
+owner-correct capability work already scoped to Groovy 7. This makes domain classes the single
+highest-value target for that phase, and the reason it is Groovy 7 and not a cheap gate change.
+
+A supporting change surfaced by the closure spike is worth noting: hoisting nested closures also
 requires the class visitor to visit methods added _during_ code generation to a fixpoint (the
-current two-round `visitMethods` misses methods added while compiling a method that was itself
-added during compilation). That change is general and low-risk but must be validated against the
-full suite before it lands.
+current two-round `visitMethods` misses methods added while compiling a method that was itself added
+during compilation). That change is general and low-risk but must be validated against the full
+suite before it lands.
 
 === Phased delivery
 
@@ -504,8 +610,8 @@ full suite before it lands.
 |Phase |What ships |What works |Target
 
 |1 |Non-capturing SAM lambda class elimination + flag + test migration + IDE/tooling check |Java-parity lambda codegen for the common SAM case; back-out flag |Groovy 6.0
-|2 |Capturing SAM lambdas (metafactory-captured static impl) |Removes the class, `Closure` alloc, and `Reference` wrapping for capturing SAM lambdas |Groovy 6.0 stretch / 7.0
-|3 |Closure packing (S1) + capability/escape analysis + `@Packed` |Sound closure packing: automatic under `@CompileStatic`, opt-in under dynamic |Groovy 7.0
+|2 |Read-only-capturing SAM lambdas (metafactory-captured static impl) |Removes the class, `Closure` alloc, and `Reference` wrapping for read-only capturing SAM lambdas (mutated captures decline) |Groovy 6.0
+|3 |Closure packing (S1) + capability/escape analysis + `@Packed` |Sound closure packing: automatic under `@CompileStatic`, opt-in (with runtime guard + escape decline) under dynamic |Groovy 7.0
 |4 |Typed static dispatch of hoisted bodies |Packed `@CompileStatic` closures are faster, not just smaller |Groovy 7.0
 |5 |Object elision (S3) + unify the writers |No object for non-escaping call-once bodies; one backend for closures and lambdas |Groovy 7.0
 |===


(groovy-website) 05/05: GEP-27: note the DISABLED opt-out in the per-closure-control deferred-feature row

Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git

commit 490e3469b4e639c255747fb41b618c9ed4e00026
Author: Paul King <pa...@asert.com.au>
AuthorDate: Fri Jul 10 23:12:48 2026 +1000

    GEP-27: note the DISABLED opt-out in the per-closure-control deferred-feature row
    
    Users influence packing only via target type, arrow syntax and @PackedClosures --
    including opting a scope out with mode = DISABLED -- but never select which packed
    representation (S1/S2/S3) is used.
---
 site/src/site/wiki/GEP-27.adoc | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
index a11e7b0..a9398ee 100644
--- a/site/src/site/wiki/GEP-27.adoc
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -640,7 +640,7 @@ on the shared backend proven in phases 1–2.
 |Serializable closure/lambda hoisting |Deferred |Kept on the class-based path initially; hoisting them requires carrying the deserialisation machinery to the enclosing class.
 |Object elision (S3) |Deferred (Groovy 7) |The most aggressive step; needs escape proof and, for iteration, callee knowledge.
 |Dynamic-mode automatic packing |Not planned |Cannot be proven sound without types; dynamic packing is opt-in via `@PackedClosures` only.
-|A user-visible switch between S1/S2/S3 per closure |Not planned |The compiler picks the lightest sound representation; users influence it only via target type, arrow syntax, and `@PackedClosures`.
+|A user-visible switch between S1/S2/S3 per closure |Not planned |The compiler picks the lightest sound representation; users influence it only via target type, arrow syntax, and `@PackedClosures` — including opting a scope out entirely with `@PackedClosures(mode = DISABLED)`, but never selecting *which* packed representation is used.
 |===
 
 == Compatibility and impact