From 1b2efaa112b6fa471d7190b137882549bc27f06f Mon Sep 17 00:00:00 2001 From: Rudy Ges Date: Sat, 16 May 2026 18:15:32 +0200 Subject: [PATCH 1/4] [jvm] restore functional_interfaces_used; AST scan misses argument-position SAMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the AbstractCast/Common/Gctx parts of a134fd882c. That commit dropped the functional_interfaces_used hashtbl on the claim that the genjvm AST scan in collect_used_functional_interfaces "strictly subsumes" the AbstractCast writeback. It does not. AbstractCast.do_check_cast's SAM branch unifies eright.etype against the SAM signature and returns eright **unchanged** — no TCast wrapper. So when a function expression is passed where a Java SAM is expected, the SAM TInst never appears in the typed AST. The only place it exists is on the extern callee's signature, which the scan deliberately skips (`if not (has_class_flag c CExtern)`) to keep classpath noise out. For TCall sites this is masked: the callee TField has a TFun etype whose parameter types are visited, so the SAM TInst is reached transitively. But TNew has no callee subexpression — the constructor's parameter types live on c.cl_constructor.cf_type, which Type.iter never visits. A bound instance-method reference passed to a Java extern's constructor (e.g. `new TextToSpeech(this, onTtsInit)` against android.jar) therefore produces a closure that does not implement the SAM interface, hitting ClassCastException at runtime. Restore the field on Common.context + Gctx.t (shared in Common.clone), the AbstractCast writeback keyed by @:native path, and seed collect_used_functional_interfaces's `used` hashtbl from it before the AST scan runs. --- src/context/abstractCast.ml | 14 ++++++++++++++ src/context/common.ml | 10 ++++++++++ src/generators/gctx.ml | 6 ++++++ src/generators/genjvm.ml | 7 +++++++ 4 files changed, 37 insertions(+) diff --git a/src/context/abstractCast.ml b/src/context/abstractCast.ml index f9fe11507c8..9618f895b59 100644 --- a/src/context/abstractCast.ml +++ b/src/context/abstractCast.ml @@ -96,6 +96,20 @@ and do_check_cast ctx uctx tleft eright p = let monos = Monomorph.spawn_constrained_monos map cf.cf_params in unify_raise_custom native_unification_context eright.etype (map (apply_params cf.cf_params monos cf.cf_type)) p; if has_mono tright then raise_typing_error ("Cannot use this function as a functional interface because it has unknown types: " ^ (s_type (print_context()) tright)) p; + (* Record that this interface is genuinely used as a conversion + target. The genjvm AST scan can't see SAM conversions in + argument position — this path returns eright unchanged with + its original TFun type, so the SAM TInst never appears in + the AST. Key by the @:native path when present: + Native.apply_native_paths rewrites cl_path between typing + and generation, so storing the raw cl_path would not match + what the generator sees (e.g. View_OnClickListener vs + View$OnClickListener). *) + let fi_key = + try parse_path (fst (Native.get_native_name c.cl_meta)) + with Not_found -> c.cl_path + in + Hashtbl.replace ctx.com.functional_interfaces_used fi_key (); eright | _ -> raise Not_found diff --git a/src/context/common.ml b/src/context/common.ml index ef36c86a4fe..2b77eac4c89 100644 --- a/src/context/common.ml +++ b/src/context/common.ml @@ -345,6 +345,10 @@ and context = { mutable modules : Type.module_def list; mutable types : Type.module_type list; mutable resources : (string,string) Hashtbl.t; + (* Functional interfaces actually used as a conversion target somewhere in + the program (populated by AbstractCast). Read by the JVM generator to + avoid binding closures to incidental SAM interfaces — see Gctx.t. *) + functional_interfaces_used : (path,unit) Hashtbl.t; (* target-specific *) mutable flash_version : float; mutable neko_lib_paths : string list; @@ -378,6 +382,7 @@ let to_gctx com = { main = com.main; types = com.types; resources = com.resources; + functional_interfaces_used = com.functional_interfaces_used; native_libs = (match com.platform with | Jvm -> (com.native_libs.java_libs :> NativeLibraries.native_library_base list) | Flash -> (com.native_libs.swf_libs :> NativeLibraries.native_library_base list) @@ -765,6 +770,7 @@ let create sctx request_scope part_scope display_mode = fake_modules = Hashtbl.create 0; flash_version = 10.; resources = Hashtbl.create 0; + functional_interfaces_used = Hashtbl.create 0; native_libs = create_native_libs(); hxb_libs = []; neko_lib_paths = []; @@ -868,6 +874,10 @@ let clone com is_macro_context = global_metadata = com.global_metadata; flash_version = com.flash_version; resources = com.resources; + (* Shared with the parent context: a SAM conversion typed in a cloned + context (core-api check, macro context) must still be visible to the + JVM generator, which only ever sees the root context. *) + functional_interfaces_used = com.functional_interfaces_used; native_libs = com.native_libs; hxb_libs = com.hxb_libs; neko_lib_paths = com.neko_lib_paths; diff --git a/src/generators/gctx.ml b/src/generators/gctx.ml index 08d234ffddb..ce5781079ea 100644 --- a/src/generators/gctx.ml +++ b/src/generators/gctx.ml @@ -30,6 +30,12 @@ type t = { main : context_main; types : Type.module_type list; resources : (string,string) Hashtbl.t; + (* Paths of functional interfaces that some expression is actually converted + to (populated by AbstractCast). Seeds the JVM generator's used-SAM set — + the AST scan alone misses implicit SAM conversions in argument position, + where AbstractCast unifies but leaves eright with its original TFun type + so the SAM TInst never appears in the AST. *) + functional_interfaces_used : (path,unit) Hashtbl.t; native_libs : NativeLibraries.native_library_base list; include_files : (string * string) list; std : tclass; (* TODO: I would prefer to not have this here, have to check default_cast *) diff --git a/src/generators/genjvm.ml b/src/generators/genjvm.ml index 742ae14fd31..2210b6576b3 100644 --- a/src/generators/genjvm.ml +++ b/src/generators/genjvm.ml @@ -3221,6 +3221,13 @@ module Preprocessor = struct it; scanning non-extern code only keeps classpath noise out. *) let collect_used_functional_interfaces gctx = let used = Hashtbl.create 0 in + (* Seed with interfaces AbstractCast recorded as implicit SAM-conversion + targets. The AST scan below misses these: when a function expression + is passed where a SAM is expected, AbstractCast unifies but returns + the original TFun-typed expression unchanged, so the SAM TInst never + appears in the typed AST. *) + Hashtbl.iter (fun path () -> Hashtbl.replace used path ()) + gctx.gctx.functional_interfaces_used; let rec note_fi_in_type depth t = if depth < 32 then match follow t with | TInst(c,tl) -> From de6c16d86179b7a9926ce870522c9b98718bfb16 Mon Sep 17 00:00:00 2001 From: Rudy Ges Date: Sat, 16 May 2026 18:15:39 +0200 Subject: [PATCH 2/4] [tests] StructuralSam: cover constructor-position SAM with method reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing cases either bind to a typed local (SAM TInst lands in the AST directly) or call a static method (TCall callee's TField has a TFun etype that exposes parameter types to the scan). Neither exercises the path that AbstractCast's functional_interfaces_used writeback is needed for: a bound instance-method reference passed to a Java extern's constructor, with the SAM type never named anywhere else in user code. Add CtorOnly + CtorSam to test.Listeners and a Holder class in Main.hx that does `new Listeners_CtorSam(onCtor, 42)`. Without the writeback the emitted closure does not implement CtorOnly and the JVM throws ClassCastException at runtime — same shape as RideAssist's `new TextToSpeech(this, onTtsInit)` crash against android.jar. --- tests/misc/jvm/projects/StructuralSam/Main.hx | 25 ++++++++++++++ .../misc/jvm/projects/StructuralSam/Setup.hx | 5 ++- .../StructuralSam/compile.hxml.stdout | 1 + .../StructuralSam/project/test/Listeners.java | 34 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/misc/jvm/projects/StructuralSam/Main.hx b/tests/misc/jvm/projects/StructuralSam/Main.hx index dd855e8312c..47ec0efbaf9 100644 --- a/tests/misc/jvm/projects/StructuralSam/Main.hx +++ b/tests/misc/jvm/projects/StructuralSam/Main.hx @@ -5,6 +5,7 @@ import test.Listeners.Listeners_AbstractEqualsPlusOne; import test.Listeners.Listeners_WithDefaults; import test.Listeners.Listeners_StringMaker; import test.Listeners.Listeners_Unused; +import test.Listeners.Listeners_CtorSam; function main() { // Plain SAM — javac would accept the lambda directly. @@ -35,4 +36,28 @@ function main() { Listeners.runOnClick(cb, 99); trace(Std.isOfType(cb, Listeners_OnClick)); trace(Std.isOfType(cb, Listeners_Unused)); + + // Constructor-position SAM conversion with a bound instance-method + // reference — the exact shape that crashed RideAssist's + // `new TextToSpeech(this, onTtsInit)`. CtorOnly is never named anywhere + // in user code (no typed local, no field, no import, no explicit cast). + // The AST scan in genjvm.collect_used_functional_interfaces cannot + // discover this conversion: a TNew has no callee subexpression, so the + // constructor's parameter types (where CtorOnly's TInst lives) are never + // visited — and the scan deliberately skips extern classes. Only + // AbstractCast's writeback to functional_interfaces_used carries this + // information from typing to codegen. If that writeback regresses, the + // emitted closure won't implement CtorOnly and this `new` call will + // ClassCastException at runtime. + new Holder().run(); +} + +class Holder { + public function new() {} + public function run() { + new Listeners_CtorSam(onCtor, 42); + } + function onCtor(n:Int):Void { + Sys.println("ctor=" + n); + } } diff --git a/tests/misc/jvm/projects/StructuralSam/Setup.hx b/tests/misc/jvm/projects/StructuralSam/Setup.hx index 67bda0192cb..290d405def0 100644 --- a/tests/misc/jvm/projects/StructuralSam/Setup.hx +++ b/tests/misc/jvm/projects/StructuralSam/Setup.hx @@ -14,5 +14,8 @@ function main() { "test/Listeners$NotSam.class", "test/Listeners$StringMaker.class", "test/Listeners$Unused.class", - "test/Listeners$UnaryStringFn.class"]); + "test/Listeners$UnaryStringFn.class", + "test/Listeners$ArgOnly.class", + "test/Listeners$CtorOnly.class", + "test/Listeners$CtorSam.class"]); } diff --git a/tests/misc/jvm/projects/StructuralSam/compile.hxml.stdout b/tests/misc/jvm/projects/StructuralSam/compile.hxml.stdout index 09bc5568e2a..063b4ec4a2d 100644 --- a/tests/misc/jvm/projects/StructuralSam/compile.hxml.stdout +++ b/tests/misc/jvm/projects/StructuralSam/compile.hxml.stdout @@ -10,3 +10,4 @@ Main.hx:28: hi! bound-click=99 Main.hx:36: true Main.hx:37: false +ctor=42 diff --git a/tests/misc/jvm/projects/StructuralSam/project/test/Listeners.java b/tests/misc/jvm/projects/StructuralSam/project/test/Listeners.java index 21c640d1933..cbdcb48f393 100644 --- a/tests/misc/jvm/projects/StructuralSam/project/test/Listeners.java +++ b/tests/misc/jvm/projects/StructuralSam/project/test/Listeners.java @@ -52,6 +52,40 @@ public interface Unused { void onUnused(int id); } + // SAM exercised ONLY in argument position from Haxe — no typed local, no + // field signature, no explicit cast. The genjvm AST scan can't see this + // case: AbstractCast's SAM branch leaves the closure expression with its + // original TFun type (no TCast wrapper), so the only place ArgOnly's + // TInst exists is the extern callee's parameter signature, which the + // scan deliberately skips. Without AbstractCast's writeback to + // functional_interfaces_used, the emitted closure does not implement + // ArgOnly and the call ClassCastExceptions at runtime. + public interface ArgOnly { + void onArg(int n); + } + + public static String runArgOnly(ArgOnly cb, int n) { + cb.onArg(n); + return "arg-ok"; + } + + // Constructor-position SAM: a Haxe `new CtorSam(closure)` is a TNew + // expression in the typed AST. Unlike TCall (where the callee field has a + // TFun etype that exposes the parameter types to the scan), TNew has no + // callee subexpression — the only place CtorOnly's TInst would appear is + // on this extern's cl_constructor.cf_type, which the AST scan never + // visits. This is the exact shape that crashed RideAssist: + // `new TextToSpeech(this, onTtsInit)`. + public interface CtorOnly { + void onCtor(int n); + } + + public static class CtorSam { + public CtorSam(CtorOnly cb, int n) { + cb.onCtor(n); + } + } + public static String runOnClick(OnClick cb, int id) { cb.onClick(id); return "ok"; From 85e55401d954ffff9238650679e76ab64e2c83c8 Mon Sep 17 00:00:00 2001 From: Rudy Ges Date: Sat, 16 May 2026 18:23:27 +0200 Subject: [PATCH 3/4] [tests] update traces positions --- .../projects/StructuralSam/compile.hxml.stdout | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/misc/jvm/projects/StructuralSam/compile.hxml.stdout b/tests/misc/jvm/projects/StructuralSam/compile.hxml.stdout index 063b4ec4a2d..b31346241c0 100644 --- a/tests/misc/jvm/projects/StructuralSam/compile.hxml.stdout +++ b/tests/misc/jvm/projects/StructuralSam/compile.hxml.stdout @@ -1,13 +1,13 @@ click=7 -Main.hx:11: ok -Main.hx:14: v=3 -Main.hx:17: 25 -Main.hx:20: 11 -Main.hx:23: made-2 +Main.hx:12: ok +Main.hx:15: v=3 +Main.hx:18: 25 +Main.hx:21: 11 +Main.hx:24: made-2 ovl-click=1 -Main.hx:27: click -Main.hx:28: hi! +Main.hx:28: click +Main.hx:29: hi! bound-click=99 -Main.hx:36: true -Main.hx:37: false +Main.hx:37: true +Main.hx:38: false ctor=42 From 48da2c2d56d672f6881f8102e7d14e4f2b8d9ab8 Mon Sep 17 00:00:00 2001 From: Rudy Ges Date: Sat, 16 May 2026 18:26:45 +0200 Subject: [PATCH 4/4] [jvm] AbstractCast: wrap SAM conversion with TCast to surface SAM type in AST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the functional_interfaces_used hashtbl plumbing (Common.context + Gctx.t + AbstractCast writeback + genjvm seeding) with a one-line change in AbstractCast: wrap the function expression in mk_cast — a TCast(eright, None) with the SAM type as the cast's etype. The SAM TInst now lives in the typed AST at every conversion site, so genjvm's existing collect_used_functional_interfaces scan finds it naturally via note_fi_in_type on the TCast's etype. No cross-module mutable state, no typing↔codegen back-channel, no @:native path-rewrite bridging. mk_cast is already the established pattern in this file — it's what the MultiType abstract path uses a few lines up. --- src/context/abstractCast.ml | 22 +++++++--------------- src/context/common.ml | 10 ---------- src/generators/gctx.ml | 6 ------ src/generators/genjvm.ml | 7 ------- 4 files changed, 7 insertions(+), 38 deletions(-) diff --git a/src/context/abstractCast.ml b/src/context/abstractCast.ml index 9618f895b59..51a3df0f39b 100644 --- a/src/context/abstractCast.ml +++ b/src/context/abstractCast.ml @@ -96,21 +96,13 @@ and do_check_cast ctx uctx tleft eright p = let monos = Monomorph.spawn_constrained_monos map cf.cf_params in unify_raise_custom native_unification_context eright.etype (map (apply_params cf.cf_params monos cf.cf_type)) p; if has_mono tright then raise_typing_error ("Cannot use this function as a functional interface because it has unknown types: " ^ (s_type (print_context()) tright)) p; - (* Record that this interface is genuinely used as a conversion - target. The genjvm AST scan can't see SAM conversions in - argument position — this path returns eright unchanged with - its original TFun type, so the SAM TInst never appears in - the AST. Key by the @:native path when present: - Native.apply_native_paths rewrites cl_path between typing - and generation, so storing the raw cl_path would not match - what the generator sees (e.g. View_OnClickListener vs - View$OnClickListener). *) - let fi_key = - try parse_path (fst (Native.get_native_name c.cl_meta)) - with Not_found -> c.cl_path - in - Hashtbl.replace ctx.com.functional_interfaces_used fi_key (); - eright + (* Wrap the function expression in a TCast to the SAM type so + the SAM TInst lives in the typed AST. Without this the + genjvm AST scan can't see SAM conversions in argument + position (TNew has no callee subexpression; the + constructor's parameter types are unreachable), and the + emitted closure would not implement the SAM interface. *) + mk_cast eright tleft p | _ -> raise Not_found end diff --git a/src/context/common.ml b/src/context/common.ml index 2b77eac4c89..ef36c86a4fe 100644 --- a/src/context/common.ml +++ b/src/context/common.ml @@ -345,10 +345,6 @@ and context = { mutable modules : Type.module_def list; mutable types : Type.module_type list; mutable resources : (string,string) Hashtbl.t; - (* Functional interfaces actually used as a conversion target somewhere in - the program (populated by AbstractCast). Read by the JVM generator to - avoid binding closures to incidental SAM interfaces — see Gctx.t. *) - functional_interfaces_used : (path,unit) Hashtbl.t; (* target-specific *) mutable flash_version : float; mutable neko_lib_paths : string list; @@ -382,7 +378,6 @@ let to_gctx com = { main = com.main; types = com.types; resources = com.resources; - functional_interfaces_used = com.functional_interfaces_used; native_libs = (match com.platform with | Jvm -> (com.native_libs.java_libs :> NativeLibraries.native_library_base list) | Flash -> (com.native_libs.swf_libs :> NativeLibraries.native_library_base list) @@ -770,7 +765,6 @@ let create sctx request_scope part_scope display_mode = fake_modules = Hashtbl.create 0; flash_version = 10.; resources = Hashtbl.create 0; - functional_interfaces_used = Hashtbl.create 0; native_libs = create_native_libs(); hxb_libs = []; neko_lib_paths = []; @@ -874,10 +868,6 @@ let clone com is_macro_context = global_metadata = com.global_metadata; flash_version = com.flash_version; resources = com.resources; - (* Shared with the parent context: a SAM conversion typed in a cloned - context (core-api check, macro context) must still be visible to the - JVM generator, which only ever sees the root context. *) - functional_interfaces_used = com.functional_interfaces_used; native_libs = com.native_libs; hxb_libs = com.hxb_libs; neko_lib_paths = com.neko_lib_paths; diff --git a/src/generators/gctx.ml b/src/generators/gctx.ml index ce5781079ea..08d234ffddb 100644 --- a/src/generators/gctx.ml +++ b/src/generators/gctx.ml @@ -30,12 +30,6 @@ type t = { main : context_main; types : Type.module_type list; resources : (string,string) Hashtbl.t; - (* Paths of functional interfaces that some expression is actually converted - to (populated by AbstractCast). Seeds the JVM generator's used-SAM set — - the AST scan alone misses implicit SAM conversions in argument position, - where AbstractCast unifies but leaves eright with its original TFun type - so the SAM TInst never appears in the AST. *) - functional_interfaces_used : (path,unit) Hashtbl.t; native_libs : NativeLibraries.native_library_base list; include_files : (string * string) list; std : tclass; (* TODO: I would prefer to not have this here, have to check default_cast *) diff --git a/src/generators/genjvm.ml b/src/generators/genjvm.ml index 2210b6576b3..742ae14fd31 100644 --- a/src/generators/genjvm.ml +++ b/src/generators/genjvm.ml @@ -3221,13 +3221,6 @@ module Preprocessor = struct it; scanning non-extern code only keeps classpath noise out. *) let collect_used_functional_interfaces gctx = let used = Hashtbl.create 0 in - (* Seed with interfaces AbstractCast recorded as implicit SAM-conversion - targets. The AST scan below misses these: when a function expression - is passed where a SAM is expected, AbstractCast unifies but returns - the original TFun-typed expression unchanged, so the SAM TInst never - appears in the typed AST. *) - Hashtbl.iter (fun path () -> Hashtbl.replace used path ()) - gctx.gctx.functional_interfaces_used; let rec note_fi_in_type depth t = if depth < 32 then match follow t with | TInst(c,tl) ->