From 0f51756738e5879ed0faddec90cec5cf1bffa875 Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 17 Sep 2026 08:18:15 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[rustjava-adopt-link-stringconcatfactory-p2?= =?UTF-8?q?]=20feat(jvm-bytecode):=20LambdaMetafactory.metafactory=20?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=81=ED=81=AC=ED=95=9C=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EB=9E=8C=EB=8B=A4=EC=99=80=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=EA=B0=80=20=EB=8F=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제안이 예고한 대로 java.lang.invoke 가 불가피한가는 재 보니 «아니다». 필요한 것은 핸들 사슬이 아니라 «객체»이고, 이 런타임엔 그것을 만들 축이 이미 있다 — MethodBody::Rust(JvmCallback) 와 jvm.register_class. 그래서 metafactory 가 스핀할 클래스를 직접 만든다: 인터페이스를 구현하고, 포획값을 필드(javac 의 arg$n)로 들고, SAM 이 impl 을 참조종류대로 호출하는 ClassDefinitionImpl. GC 는 이미 ClassDefinition::fields 를 걷으므로 포획 객체는 그대로 추적된다. 경계는 «어댑터»다. 실 팩토리는 박싱·언박싱·확대를 끼워 넣는데 여기엔 그럴 축이 없다 ⇒ 통과(동일 프리미티브이거나 양쪽 다 레퍼런스)가 아니면 «링크하지 않는다». 그 판정은 서술자만으로 되므로 lowering 시점에 끝난다 — 클래스는 로드되거나 안 되거나이고, 호출 «도중»에 실패하는 경로가 없다. 관측 가능성을 위해 픽스처를 넷 더했다. LambdaKinds(--release 21)는 포획·객체포획·생성자참조· 언바운드 수신자·인터페이스 메서드참조·void SAM 을 한 파일에 담고 «출력 9줄»을 OpenJDK 26.0.1 과 글자대로 맞춘다. LambdaCapturingThis 는 ★--release 8 이다 — REF_invokeSpecial 은 javac 이 11 부터 내지 않으므로(nestmates) 그 가지를 «어떤 테스트도 못 밟는» 상태를 끝내려면 그 릴리스가 유일한 길이다 (핀 테스트에 그 예외를 «면제»가 아니라 «핀»으로 등재했다). LambdaBoxing 은 반대쪽 경계 — OpenJDK 는 3을 찍고 우리는 거부한다. 그리고 신원 4축 근접실패 4종(Not*Metafactory*)은 손조립이고, 넷 다 OpenJDK 가 «로드해서 부트스트랩까지 간 뒤» 축마다 다른 오류로 거부한다(= 신원 검사까지 도달한다). --- classfile/src/attribute.rs | 20 +- classfile/src/lib.rs | 4 +- classfile/src/opcode.rs | 34 +- jvm-bytecode/src/class_definition.rs | 3 +- jvm-bytecode/src/interpreter.rs | 10 +- jvm-bytecode/src/lambda.rs | 368 ++++++++++++++++++ jvm-bytecode/src/lib.rs | 1 + test-data/indy/LambdaBoxing$Gen.class | Bin 0 -> 220 bytes test-data/indy/LambdaBoxing.class | Bin 0 -> 1059 bytes test-data/indy/LambdaCapturingThis$Op.class | Bin 0 -> 206 bytes test-data/indy/LambdaCapturingThis.class | Bin 0 -> 1205 bytes test-data/indy/LambdaKinds$Base.class | Bin 0 -> 360 bytes test-data/indy/LambdaKinds$Box.class | Bin 0 -> 877 bytes test-data/indy/LambdaKinds$Derived.class | Bin 0 -> 1078 bytes test-data/indy/LambdaKinds$Describer.class | Bin 0 -> 286 bytes test-data/indy/LambdaKinds$Getter.class | Bin 0 -> 223 bytes test-data/indy/LambdaKinds$IntOp.class | Bin 0 -> 207 bytes test-data/indy/LambdaKinds$Maker.class | Bin 0 -> 257 bytes test-data/indy/LambdaKinds$NameOf.class | Bin 0 -> 280 bytes test-data/indy/LambdaKinds$Named.class | Bin 0 -> 222 bytes test-data/indy/LambdaKinds$NamedBox.class | Bin 0 -> 406 bytes test-data/indy/LambdaKinds$Sink.class | Bin 0 -> 206 bytes test-data/indy/LambdaKinds.class | Bin 0 -> 3029 bytes .../indy/NotInvokeStaticMetafactory.class | Bin 0 -> 579 bytes test-data/indy/NotLambdaMetafactory.class | Bin 0 -> 576 bytes test-data/indy/NotMetafactory.class | Bin 0 -> 570 bytes test-data/indy/NotMetafactoryDescriptor.class | Bin 0 -> 507 bytes test-data/src/indy/LambdaBoxing.java | 27 ++ test-data/src/indy/LambdaCapturingThis.java | 31 ++ test-data/src/indy/LambdaKinds.java | 113 ++++++ test-data/src/indy/make_indy_fixtures.py | 107 +++++ tests/test_class_format.rs | 117 +++++- tests/test_fixture_pins.rs | 27 +- 33 files changed, 836 insertions(+), 26 deletions(-) create mode 100644 jvm-bytecode/src/lambda.rs create mode 100644 test-data/indy/LambdaBoxing$Gen.class create mode 100644 test-data/indy/LambdaBoxing.class create mode 100644 test-data/indy/LambdaCapturingThis$Op.class create mode 100644 test-data/indy/LambdaCapturingThis.class create mode 100644 test-data/indy/LambdaKinds$Base.class create mode 100644 test-data/indy/LambdaKinds$Box.class create mode 100644 test-data/indy/LambdaKinds$Derived.class create mode 100644 test-data/indy/LambdaKinds$Describer.class create mode 100644 test-data/indy/LambdaKinds$Getter.class create mode 100644 test-data/indy/LambdaKinds$IntOp.class create mode 100644 test-data/indy/LambdaKinds$Maker.class create mode 100644 test-data/indy/LambdaKinds$NameOf.class create mode 100644 test-data/indy/LambdaKinds$Named.class create mode 100644 test-data/indy/LambdaKinds$NamedBox.class create mode 100644 test-data/indy/LambdaKinds$Sink.class create mode 100644 test-data/indy/LambdaKinds.class create mode 100644 test-data/indy/NotInvokeStaticMetafactory.class create mode 100644 test-data/indy/NotLambdaMetafactory.class create mode 100644 test-data/indy/NotMetafactory.class create mode 100644 test-data/indy/NotMetafactoryDescriptor.class create mode 100644 test-data/src/indy/LambdaBoxing.java create mode 100644 test-data/src/indy/LambdaCapturingThis.java create mode 100644 test-data/src/indy/LambdaKinds.java diff --git a/classfile/src/attribute.rs b/classfile/src/attribute.rs index 5de11ac8..78ed5dbb 100644 --- a/classfile/src/attribute.rs +++ b/classfile/src/attribute.rs @@ -69,7 +69,12 @@ pub struct MethodHandleRef { } impl MethodHandleRef { - fn resolve(constant_pool: &BTreeMap, index: u16) -> Option { + /// Public because a bootstrap method's *static arguments* are method handles too, and the only + /// thing that can read them is a consumer outside this crate: `BootstrapMethod::arguments` is + /// raw indices by design (see below), so whoever links a call site resolves them itself. + /// `LambdaMetafactory.metafactory` is the case that made this necessary — its second static + /// argument is the implementation method, and it is a `CONSTANT_MethodHandle`. + pub fn resolve(constant_pool: &BTreeMap, index: u16) -> Option { let ConstantPoolItem::MethodHandle { reference_kind, reference_index, @@ -101,6 +106,19 @@ impl MethodHandleRef { } } +/// The descriptor a CONSTANT_MethodType names (JVMS 4.4.9). +/// +/// Here for the same reader as `MethodHandleRef::resolve`: two of `LambdaMetafactory`'s three +/// static arguments are method types. A method type *is* just its descriptor as far as anything +/// outside `java.lang.invoke` is concerned, so this hands back the string rather than a type. +pub fn method_type_descriptor(constant_pool: &BTreeMap, index: u16) -> Option> { + let ConstantPoolItem::MethodType { descriptor_index } = constant_pool.get(&index)? else { + return None; + }; + + constant_pool.get(descriptor_index)?.utf8() +} + /// One entry of the `BootstrapMethods` attribute (JVMS 4.7.23). pub struct BootstrapMethod { pub method: MethodHandleRef, diff --git a/classfile/src/lib.rs b/classfile/src/lib.rs index e152363f..afb1a38a 100644 --- a/classfile/src/lib.rs +++ b/classfile/src/lib.rs @@ -12,11 +12,11 @@ mod opcode; mod validation; pub use { - attribute::{AttributeInfo, AttributeInfoCode, BootstrapMethod, MethodHandleKind, MethodHandleRef}, + attribute::{AttributeInfo, AttributeInfoCode, BootstrapMethod, MethodHandleKind, MethodHandleRef, method_type_descriptor}, class::ClassInfo, constant_pool::{ConstantPoolReference, FieldMethodref}, error::ClassFileError, field::FieldInfo, method::MethodInfo, - opcode::{Opcode, StringConcatCallSite}, + opcode::{LambdaCallSite, Opcode, StringConcatCallSite}, }; diff --git a/classfile/src/opcode.rs b/classfile/src/opcode.rs index b83d9919..69ccd4d2 100644 --- a/classfile/src/opcode.rs +++ b/classfile/src/opcode.rs @@ -9,7 +9,35 @@ use nom::{ number::complete::{be_i16, be_i32, be_u16, i8, u8}, }; -use crate::constant_pool::{ConstantPoolItem, ConstantPoolReference}; +use crate::{ + attribute::MethodHandleRef, + constant_pool::{ConstantPoolItem, ConstantPoolReference}, +}; + +/// A `LambdaMetafactory.metafactory` call site, resolved against the class's `BootstrapMethods` +/// attribute. +/// +/// This is what javac lowers a lambda or a method reference to. Unlike the string concat call +/// site below, executing it does not compute a value — it *makes an object*: an instance of +/// `interface_name` whose `method_name`/`method_descriptor` runs `implementation`, with whatever +/// the call site descriptor leaves on the stack captured into it. +#[derive(Clone, Debug)] +pub struct LambdaCallSite { + /// The synthetic class implementing the interface, named at lowering time — the only place + /// that sees the whole class and can pick a name nothing else in it uses. Shaped like + /// OpenJDK's (`Host$$Lambda$0`), which is a convention; nothing parses it. + pub class_name: Arc, + pub interface_name: Arc, + /// The call site descriptor: its parameters are the captured values, in stack order, and its + /// return type is the interface. + pub descriptor: Arc, + /// The interface method to implement, with the *erased* descriptor (the bootstrap's + /// `samMethodType`) — that is the one callers invoke through the interface. + pub method_name: Arc, + pub method_descriptor: Arc, + /// The method the interface method delegates to (the bootstrap's `implMethod`). + pub implementation: MethodHandleRef, +} /// A `StringConcatFactory.makeConcatWithConstants` call site, resolved against the class's /// `BootstrapMethods` attribute. @@ -121,6 +149,10 @@ pub enum Opcode { Ineg, Instanceof(ConstantPoolReference), Invokedynamic(ConstantPoolReference), + /// An `invokedynamic` already resolved to `LambdaMetafactory.metafactory`. Written by + /// `jvm-bytecode` at class definition time, never by the parser — same reasoning as the + /// string concat variant below, which says it at length. + InvokedynamicLambda(LambdaCallSite), /// An `invokedynamic` already resolved to `StringConcatFactory.makeConcatWithConstants`. /// /// **The parser never produces this.** `parse_opcode` only ever emits `Invokedynamic`, because diff --git a/jvm-bytecode/src/class_definition.rs b/jvm-bytecode/src/class_definition.rs index 9dd341e8..d22aac8d 100644 --- a/jvm-bytecode/src/class_definition.rs +++ b/jvm-bytecode/src/class_definition.rs @@ -17,7 +17,7 @@ use jvm::{ClassDefinition, ClassInstance, Field, JavaType, JavaValue, Jvm, Metho use jvm_class_proto::JavaClassProto; use jvm_types::{ClassAccessFlags, FieldAccessFlags, MethodAccessFlags}; -use crate::{ClassDefinitionError, class_instance::ClassInstanceImpl, field::FieldImpl, method::MethodImpl, string_concat, verifier}; +use crate::{ClassDefinitionError, class_instance::ClassInstanceImpl, field::FieldImpl, lambda, method::MethodImpl, string_concat, verifier}; struct ClassDefinitionInner { name: String, @@ -102,6 +102,7 @@ impl ClassDefinitionImpl { // different opcode, so whatever is still `Invokedynamic` when `verify` runs is a bootstrap // we do not link — and that is exactly what it rejects. string_concat::lower(&mut class); + lambda::lower(&mut class); verifier::verify(&class)?; let mut constant_values = Vec::new(); diff --git a/jvm-bytecode/src/interpreter.rs b/jvm-bytecode/src/interpreter.rs index af758058..46de151e 100644 --- a/jvm-bytecode/src/interpreter.rs +++ b/jvm-bytecode/src/interpreter.rs @@ -6,7 +6,7 @@ use core::iter; use classfile::{AttributeInfoCode, ConstantPoolReference, Opcode, StringConcatCallSite}; use jvm::{ClassInstance, JavaChar, JavaError, JavaType, JavaValue, Jvm, Result, runtime::JavaLangString}; -use crate::stack_frame::StackFrame; +use crate::{lambda, stack_frame::StackFrame}; enum ExecuteNext { Continue, @@ -630,6 +630,14 @@ impl Interpreter { Opcode::Invokedynamic(_) => { todo!() } + Opcode::InvokedynamicLambda(call_site) => { + // The call site's parameters are the captured values, so this is the ordinary + // argument extraction — what differs is that they are stored rather than passed. + let captures = Self::extract_invoke_params(stack_frame, &call_site.descriptor); + let instance = lambda::instantiate(jvm, call_site, captures).await?; + + stack_frame.operand_stack.push(JavaValue::Object(Some(instance))); + } Opcode::InvokedynamicStringConcat(call_site) => { let params = Self::extract_invoke_params(stack_frame, &call_site.descriptor); let result = Self::concat_with_constants(jvm, call_site, params).await?; diff --git a/jvm-bytecode/src/lambda.rs b/jvm-bytecode/src/lambda.rs new file mode 100644 index 00000000..fe513599 --- /dev/null +++ b/jvm-bytecode/src/lambda.rs @@ -0,0 +1,368 @@ +//! Resolving the `invokedynamic` bootstrap that makes an *object*: +//! `java.lang.invoke.LambdaMetafactory.metafactory`. +//! +//! javac lowers every lambda and method reference to a call site bound to that factory, so this is +//! the other half of what a compiler emits routinely — `string_concat.rs` linked the first half. +//! +//! ## What a real JVM does, and what is done instead +//! +//! `metafactory` returns a `CallSite` holding a `MethodHandle` that yields an instance of the +//! functional interface; the instance's class is spun at runtime and its single method invokes the +//! `implMethod` handle. There is no `java.lang.invoke` package here — no `MethodHandle`, no +//! `CallSite` — so the spinning is done directly: a [`ClassDefinitionImpl`] whose interface method +//! is a Rust body that calls the implementation through `Jvm`. The captured values become fields, +//! which is what javac's own `arg$n` naming describes and what the GC already knows how to trace +//! (it walks `ClassDefinition::fields`). +//! +//! That is the whole of the shortcut: the object is real, the dispatch is real, and the thing that +//! does not exist is the handle chain that would normally deliver them. +//! +//! ## What is not linked, and why that is a refusal rather than a gap +//! +//! The implementation's signature has to line up with the interface method's, position by +//! position, as a **pass-through**: identical primitives, or a reference either way. Where the real +//! factory would insert an adapter — boxing an `int` into an `Object`, unboxing, widening — the +//! call site is left as `Opcode::Invokedynamic`, and the verifier refuses the class as an +//! unsupported feature. A refusal is a worse experience than an adapter and a much better one than +//! a wrong answer, and the boundary is checked where it is decidable: at lowering time, from the +//! descriptors alone, so a class either loads or does not. It never fails halfway through a call. +//! +//! `altMetafactory` — the entry point for serializable and multi-interface lambdas — is not this +//! method and is not linked. + +use alloc::{ + boxed::Box, + collections::BTreeMap, + format, + string::{String, ToString}, + sync::Arc, + vec, + vec::Vec, +}; + +use classfile::{AttributeInfo, ClassInfo, ConstantPoolReference, LambdaCallSite, MethodHandleKind, MethodHandleRef, Opcode, method_type_descriptor}; +use jvm::{ClassInstance, Field, JavaType, JavaValue, Jvm, JvmCallback, Result}; +use jvm_types::{ClassAccessFlags, FieldAccessFlags, MethodAccessFlags}; + +use crate::{ + class_definition::ClassDefinitionImpl, + field::FieldImpl, + method::{MethodBody, MethodImpl}, +}; + +/// The bootstrap method this module links, spelled out in full — all four axes, for the reason +/// `string_concat.rs` gives: matching on fewer would link call sites bound to someone else's +/// `metafactory`. +const FACTORY_CLASS: &str = "java/lang/invoke/LambdaMetafactory"; +const FACTORY_NAME: &str = "metafactory"; +const FACTORY_DESCRIPTOR: &str = "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;"; + +/// A bootstrap entry that is the factory, with its static arguments resolved. +/// +/// `instantiated` — the third static argument — is read but not used for dispatch: the interface +/// method this runtime defines carries the *erased* signature, which is the one callers invoke +/// through the interface. It is resolved anyway because its absence means the entry is not the +/// shape `metafactory` claims to be, and a bootstrap that is not that shape must not be linked. +struct LinkedFactory { + sam_descriptor: Arc, + implementation: MethodHandleRef, +} + +/// Rewrite every recognised `metafactory` call site in `class` into a resolved opcode. +/// +/// Called before the verifier, like `string_concat::lower` — whatever is still +/// `Opcode::Invokedynamic` afterwards is a bootstrap this runtime does not link. +pub(crate) fn lower(class: &mut ClassInfo) { + let resolved = resolve_bootstrap_methods(class); + if resolved.is_empty() { + return; + } + + // Numbered per class, not per bootstrap entry: two call sites may share one bootstrap entry + // while capturing different things, and each needs its own class. The name only has to be + // unique within the class — it is never parsed, and the registry is keyed by it. + let host = class.this_class.clone(); + let mut index = 0; + + for method in &mut class.methods { + for attribute in &mut method.attributes { + let AttributeInfo::Code(code) = attribute else { + continue; + }; + for opcode in code.code.values_mut() { + let Opcode::Invokedynamic(ConstantPoolReference::InvokeDynamic { + bootstrap_method_attr_index, + name, + descriptor, + }) = opcode + else { + continue; + }; + let Some(linked) = resolved.get(bootstrap_method_attr_index) else { + continue; + }; + // The call site's return type is the interface being implemented. Anything else + // is not a `metafactory` call site whatever its bootstrap says. + let call_site_type = JavaType::parse(descriptor); + let (captures, JavaType::Class(interface)) = call_site_type.as_method() else { + continue; + }; + if !adapts(captures, &linked.sam_descriptor, &linked.implementation) { + continue; + } + + *opcode = Opcode::InvokedynamicLambda(LambdaCallSite { + class_name: Arc::new(format!("{host}$$Lambda${index}")), + interface_name: Arc::new(interface.clone()), + descriptor: descriptor.clone(), + method_name: name.clone(), + method_descriptor: linked.sam_descriptor.clone(), + implementation: linked.implementation.clone(), + }); + index += 1; + } + } + } +} + +/// Bootstrap index -> what it links to, for the entries that are this factory and nothing else. +fn resolve_bootstrap_methods(class: &ClassInfo) -> BTreeMap { + let Some(bootstrap_methods) = class.attributes.iter().find_map(|attribute| match attribute { + AttributeInfo::BootstrapMethods(methods) => Some(methods), + _ => None, + }) else { + return BTreeMap::new(); + }; + + bootstrap_methods + .iter() + .enumerate() + .filter_map(|(index, bootstrap)| { + if bootstrap.method.kind != MethodHandleKind::InvokeStatic + || bootstrap.method.member.class.as_str() != FACTORY_CLASS + || bootstrap.method.member.name.as_str() != FACTORY_NAME + || bootstrap.method.member.descriptor.as_str() != FACTORY_DESCRIPTOR + { + return None; + } + + // `metafactory` takes exactly three static arguments, of exactly these kinds. A + // bootstrap naming it with anything else is not the shape it claims to be — the same + // rule `string_concat.rs` applies to its recipe, and the same consequence: not linked. + let [sam, implementation, instantiated] = bootstrap.arguments[..] else { + return None; + }; + let sam_descriptor = method_type_descriptor(&class.constant_pool, sam)?; + method_type_descriptor(&class.constant_pool, instantiated)?; + let implementation = MethodHandleRef::resolve(&class.constant_pool, implementation)?; + + Some(( + index as u16, + LinkedFactory { + sam_descriptor, + implementation, + }, + )) + }) + .collect() +} + +/// Whether the implementation can be called by passing the captured values and the interface +/// method's arguments straight through, with no conversion anywhere. +/// +/// This is the boundary named at the top of the file. It is deliberately an exact shape test +/// rather than an assignability test: `Jvm` values carry their own types, so a reference flows +/// into any reference parameter, but an `int` reaching an `Object` parameter needs a box that +/// nothing here would create. +fn adapts(captures: &[JavaType], sam_descriptor: &str, implementation: &MethodHandleRef) -> bool { + let Some(sam_type) = JavaType::try_parse(sam_descriptor) else { + return false; + }; + let JavaType::Method(sam_parameters, sam_return) = &sam_type else { + return false; + }; + let Some((parameters, returns)) = implementation_signature(implementation) else { + return false; + }; + + if captures.len() + sam_parameters.len() != parameters.len() { + return false; + } + if !captures.iter().chain(sam_parameters).zip(¶meters).all(|(from, to)| passes(from, to)) { + return false; + } + + // A `void` interface method discards whatever the implementation returned, which is what the + // real factory does too — `Runnable r = list::clear` is an ordinary method reference. + **sam_return == JavaType::Void || passes(&returns, sam_return) +} + +/// The signature the implementation is *called* with, which is not the descriptor written in the +/// class file: an instance method takes its receiver first, and a constructor returns its class. +fn implementation_signature(implementation: &MethodHandleRef) -> Option<(Vec, JavaType)> { + let descriptor = JavaType::try_parse(&implementation.member.descriptor)?; + let JavaType::Method(parameters, returns) = descriptor else { + return None; + }; + let receiver = JavaType::from_class_name(&implementation.member.class); + + Some(match implementation.kind { + MethodHandleKind::InvokeStatic => (parameters, *returns), + MethodHandleKind::InvokeVirtual | MethodHandleKind::InvokeSpecial | MethodHandleKind::InvokeInterface => { + ([receiver].into_iter().chain(parameters).collect(), *returns) + } + MethodHandleKind::NewInvokeSpecial => (parameters, receiver), + // Field kinds cannot be an `implMethod`: JVMS 5.4.3.5 resolves them to a handle that reads + // or writes a field, and `metafactory` documents a *method* handle. + _ => return None, + }) +} + +fn passes(from: &JavaType, to: &JavaType) -> bool { + match (from, to) { + (JavaType::Class(_) | JavaType::Array(_), JavaType::Class(_) | JavaType::Array(_)) => true, + _ => from == to, + } +} + +/// Execute a resolved call site: make the object it evaluates to. +/// +/// The class is registered on first execution rather than built at lowering time because lowering +/// has no `Jvm` — `ClassDefinitionImpl::from_classfile` is handed bytes and nothing else. Two +/// threads reaching the same call site at once both build one; `register_class` keeps the first +/// and drops the other, and the two are identical, so the loser costs a construction and nothing +/// else. +pub(crate) async fn instantiate(jvm: &Jvm, call_site: &LambdaCallSite, captures: Vec) -> Result> { + if !jvm.has_class(&call_site.class_name) { + jvm.register_class(Box::new(synthesise(call_site)), None).await?; + } + + let mut instance = jvm.instantiate_class(&call_site.class_name).await?; + for (index, (value, descriptor)) in captures.into_iter().zip(capture_descriptors(call_site)).enumerate() { + jvm.put_field(&mut instance, &capture_name(index), &descriptor, value).await?; + } + + Ok(instance) +} + +/// The class the call site evaluates an instance of: the interface, one field per captured value, +/// and one method — the interface's, implemented by [`LambdaBody`]. +fn synthesise(call_site: &LambdaCallSite) -> ClassDefinitionImpl { + let fields = capture_descriptors(call_site) + .into_iter() + .enumerate() + .map(|(index, descriptor)| FieldImpl::new(&capture_name(index), &descriptor, FieldAccessFlags::PRIVATE | FieldAccessFlags::FINAL)) + .collect::>(); + + let body = LambdaBody { + implementation: call_site.implementation.clone(), + captures: fields + .iter() + .enumerate() + .map(|(index, x)| (capture_name(index), x.descriptor())) + .collect(), + returns_void: matches!(JavaType::parse(&call_site.method_descriptor).as_method().1, JavaType::Void), + }; + let method = MethodImpl::new( + &call_site.method_name, + &call_site.method_descriptor, + MethodBody::from_rust(Box::new(body)), + MethodAccessFlags::PUBLIC, + ); + + ClassDefinitionImpl::new( + &call_site.class_name, + Some("java/lang/Object".to_string()), + vec![call_site.interface_name.to_string()], + ClassAccessFlags::FINAL | ClassAccessFlags::SYNTHETIC, + vec![method], + fields, + ) +} + +/// javac's own name for a captured value, kept because a stack trace or a debugger reading these +/// fields should see what it would see on a real JVM. +fn capture_name(index: usize) -> String { + format!("arg${index}") +} + +fn capture_descriptors(call_site: &LambdaCallSite) -> Vec { + let call_site_type = JavaType::parse(&call_site.descriptor); + + call_site_type.as_method().0.iter().map(descriptor_of).collect() +} + +fn descriptor_of(r#type: &JavaType) -> String { + match r#type { + JavaType::Void => "V".to_string(), + JavaType::Boolean => "Z".to_string(), + JavaType::Byte => "B".to_string(), + JavaType::Char => "C".to_string(), + JavaType::Short => "S".to_string(), + JavaType::Int => "I".to_string(), + JavaType::Long => "J".to_string(), + JavaType::Float => "F".to_string(), + JavaType::Double => "D".to_string(), + JavaType::Class(name) => format!("L{name};"), + JavaType::Array(element) => format!("[{}", descriptor_of(element)), + JavaType::Method(..) => unreachable!("a method type cannot be a captured value's type"), + } +} + +/// The interface method's body: read the captures back out, put the caller's arguments after them, +/// and invoke the implementation the way its reference kind says it is invoked. +struct LambdaBody { + implementation: MethodHandleRef, + captures: Vec<(String, String)>, + returns_void: bool, +} + +#[async_trait::async_trait] +impl JvmCallback for LambdaBody { + async fn call(&self, jvm: &Jvm, args: Box<[JavaValue]>) -> Result { + let JavaValue::Object(Some(this)) = &args[0] else { + // `this` is prepended by `Jvm::execute_method` for every non-static method, so a + // missing receiver is this runtime having called the wrong thing, not a guest error. + return Err(jvm.exception("java/lang/InternalError", "lambda body called without a receiver").await); + }; + let this = this.clone(); + + let mut arguments = Vec::with_capacity(self.captures.len() + args.len() - 1); + for (name, descriptor) in &self.captures { + arguments.push(jvm.get_field(&this, name, descriptor).await?); + } + arguments.extend(args.into_vec().into_iter().skip(1)); + + let member = &self.implementation.member; + let result: JavaValue = match self.implementation.kind { + MethodHandleKind::InvokeStatic => jvm.invoke_static(&member.class, &member.name, &member.descriptor, arguments).await?, + MethodHandleKind::NewInvokeSpecial => JavaValue::Object(Some(jvm.new_class(&member.class, &member.descriptor, arguments).await?)), + MethodHandleKind::InvokeVirtual | MethodHandleKind::InvokeInterface | MethodHandleKind::InvokeSpecial => { + let JavaValue::Object(Some(receiver)) = arguments.remove(0) else { + return Err(jvm + .exception( + "java/lang/NullPointerException", + &format!("Method {}::{}:{} is called on null", member.class, member.name, member.descriptor), + ) + .await); + }; + + if self.implementation.kind == MethodHandleKind::InvokeSpecial { + jvm.invoke_special(&receiver, &member.class, &member.name, &member.descriptor, arguments) + .await? + } else { + // Virtual, so the receiver's own class selects the method — a method reference + // is dispatched at the call, exactly as writing the call would have been. + let class_name = receiver.class_definition().name(); + jvm.invoke_virtual(&receiver, &class_name, &member.name, &member.descriptor, arguments) + .await? + } + } + // Refused at lowering time; reaching this would mean the opcode was written by + // something that did not run that check. + _ => return Err(jvm.exception("java/lang/InternalError", "lambda implementation is not a method").await), + }; + + Ok(if self.returns_void { JavaValue::Void } else { result }) + } +} diff --git a/jvm-bytecode/src/lib.rs b/jvm-bytecode/src/lib.rs index 728e4b20..f3432ee2 100644 --- a/jvm-bytecode/src/lib.rs +++ b/jvm-bytecode/src/lib.rs @@ -8,6 +8,7 @@ mod class_instance; mod error; mod field; mod interpreter; +mod lambda; mod method; mod stack_frame; mod string_concat; diff --git a/test-data/indy/LambdaBoxing$Gen.class b/test-data/indy/LambdaBoxing$Gen.class new file mode 100644 index 0000000000000000000000000000000000000000..99d7feb3147de2dd9b1632b12f2d30d19a20b369 GIT binary patch literal 220 zcmZ8bOA5k36w6fY)LKQsBe>9wJ%B3}{6Nu#Cz#fuwA3kds(3aR9>7D1(N%GiyyS)V z@_Ij>0G1d?I0C~+UE;G^mG{h~ldU!qZMwO-sjyHc4UciIlUksaYT|f0i~e^D0smB9 zt3+?ILJN$VC+FX^fOpWg-j%kN=m`YP1m)fsU9Agct+xEc7B0YnJ<06T0S#(Nbl{=; OV;=$8XOa!jcfAk9R4~&3 literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaBoxing.class b/test-data/indy/LambdaBoxing.class new file mode 100644 index 0000000000000000000000000000000000000000..ec8a0d032ce313f5480f92f8b2350ee695484ffe GIT binary patch literal 1059 zcma)*T~8B16o%hvOSf!STKa*s3JMl&L8*%0YfPkxO0u9y2{&F%+p#X}?likoV*O3t z=!Hfu`~k+_WIW4~u(X7Du{-C?obx_&&dmJ&d-MyyOFYw&KvF}>z!cI9`F(!Kn-2H- z&9|L>(NzrT=eB377YxaIW3ND283WVM$?pqVF4Y^B#obPiZ^}=$*RQ=6o;eKzmo&^6 z$RU45wLJ=ya4A_1DYWd2tkfV?0N)`6>B#3G%PR}aUw&iC3}J)XW5?E8M+g$nJa4Nn*rFAk*_5p@JhbQB4C5I_4f-}{ra(~zs^&pDgNJyGr>Oh`mZAbG literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaCapturingThis$Op.class b/test-data/indy/LambdaCapturingThis$Op.class new file mode 100644 index 0000000000000000000000000000000000000000..b6c44d6178ca20424127805b312edaa12886f7ef GIT binary patch literal 206 zcmZvWK?;IU7=-6H^_dw2L3Dr?waHC45CS1^Q5X2Nhbi`Y_%!HhEjmDlie9bV%)sz5 zGw;vy1z?A%fJ0au_?n+NVcqp@rOMN#GOJ7rxP)2Fca9rYWt`=;EINY6S~m}ZyG}PL zAw1stwvhX(k%Z+y)%I6Gh*G6wn>1{UGy)UCd;lgmnf3v~$Ud}gKX|YK9EiUL7+Vi5 JVYd3Krc~rKsT7q9{#_O{$o*Au+x?wiugCLY`(&ctY=47oa-Ezxs zNJ&@^eJR?eMtY}IZaI==REwL+F{9%$t|&>Zmh>5>jeoKaBaf>bb1I2>hU@_8Br-a? zs-b`dj_W#ZD8BIlf*81`V>ZS!V-g4cRT);d|@f{)Buf(N(vQk-F3qt^o93IhC6O6$Y6eyo zJsr1bmBfmM`*^_dP{$)YW|%)|3Cr1c_hdODpd-Wwu@$;L-Rf|=fA!nw6W#+wj4y@L zvSm=Hx$a)aGyAsn^6T@Q0yYjj*}wh&gAwKV*y35*im>gv6-tvquR4zOE4By%N$nlm z@Hpr_ESxFKpd2}#G0dL7l^$7C5X{j{#OT#9>Uw%%WF=@&`;x~Xt3**1*GflVrI~Nw zpJ~NVq`Hiko7JKK)oaKJ)L}}^cDR5$1{9!+$LWPizw4q cC)hH{U&0+4CV9pvmZxH@CR2EVH9SY|FCqyZZ2$lO literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds$Base.class b/test-data/indy/LambdaKinds$Base.class new file mode 100644 index 0000000000000000000000000000000000000000..d4295be03dd14e2164ee5cc430809e0f58500613 GIT binary patch literal 360 zcmYjM%}T>S7@Tctlg4UmtX@R$;Gy+kA3!N;K@>whRJ`vdOWBfcAe;JJ^(1)k0emQN z){9x#Z-)8i`}_X+`~q-`;}|UjA=*9cpd&C?$u~JEq+L#))k%bKLxJ7KY=PdRwYr)W(mCz8gFOfE5u#tu C;zD-- literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds$Box.class b/test-data/indy/LambdaKinds$Box.class new file mode 100644 index 0000000000000000000000000000000000000000..32dcf8f8efaee89f5d27fd853ef0b20a8954ea46 GIT binary patch literal 877 zcmaJD7r0ReelT|6KO(dtng5R4?In~6S{Oe)9g$e`6vDe zA2iXJ@Zb;dM;Y&SH?nHu!+f24?#H=vfBd}u4&XKRZKRO4kSQXI97Fk%PkF=V@}hCl zyA*xHkbCJ#&%9ztJGHX{@+erai&#LBq154{p3C2O($&=i^`0R+<^Du4u=WxqSS+H1 zGQ;YK4@FDKJ~!_?GoYI`TpG==;o1|S6c=DpjkRkkxj+SyMTB+knaXcCIL~zP` zJ|UK?CUkLUWwjaduF(C!>yc^MnH$mWs!7lnhhFGjP89w$OivMxg*Hb@8w(E@77_%a zA1GzC3HbP}Fazc4JT^!xDSp99IXtqlg>4JfQ0@*x^+u2*GN2%dt+)U8o1|^U`98a&tVh;U0!H@p6ne0&ls1qFSex= zLCfb_3;LxrDYZ~z*!f404f0}ySoJTkvV)JmopLky8NKgfH70M- y8ItXge2rVcr@#2mDak#;Qdq9s`mJSuoXyF;b#VeJ>FiOggG literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds$Derived.class b/test-data/indy/LambdaKinds$Derived.class new file mode 100644 index 0000000000000000000000000000000000000000..912a217aa9b5c3f824ecbaef85fe980edfb262a1 GIT binary patch literal 1078 zcma)5O>fgc5Ph4Z*^rth1ZXLNwgeZF6dJy6K}a-JX@%*5RNOc5Hkdkgw7ZGae+4I` z9{2%>--H;~me|m$;;^3G**9-yXP$rlKKlXSEnbz8L!P7HU;#yjswc(+U%U?@U%NX( zONQd>APUSIhJ3U2se}>(_oWJE(ytLk*})PQSi z@+|4;M5>SSSSlF}$+1k#Hbqtk!%BGmXm>uqea2Ak$5SL zaNJ{9wAcy06URmyB_3? z$WF>h6yjJ6O{`8S&)7cwYFhoug3^rpBJxA2T`!JLrjxD(%i3yr<^qpSC(?%h|7Du; z>R5Ze8FNpBVLvdk%itVDkyO1D6t^xmoy=pFzPX@LNxPAdj)!rfQ99D7n+}5dLz@gv<@>s(< ftrmG2gv?QiZItMxb!?G+LUy}YLI=D140`YdlVk{yIDwfczRiOV@KItbf^+HWE~>h# zpRfA^z#KgXCBh&O=P(w#RK><$NfYTbl-fas;GM)(Of#X9=^;GHs35rUPeB-t0^7RC zZ?kdmcN`0ys$@oJ9P^8g#hCpygW=0R^!UK3HW7UOh-b>rp z_ItkW4*+Y7Tyz8`vAm?YJeDdqvmIGW+C@)bT*;e^YN?9ooK}=sfo?&p)A=I)&l0Ok zRjdU3q`B&hwq;Gc+&a4agay148M|+cbrA>zt$;vyP>S@Xmd20~aDEsLz&{z4oq_t` W2G=fY2Ob7r_Yt!DoMZ$H9q$d&2{KLq literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds$IntOp.class b/test-data/indy/LambdaKinds$IntOp.class new file mode 100644 index 0000000000000000000000000000000000000000..65b907fce0868e295ec268ff555ce4a273fd2c30 GIT binary patch literal 207 zcmYk0K@NgI5CnS^SOhfs2M>CYHy9Hm(a6CUEU+<Tg(JTgvCkTvO*q9Q`luY4nretdey?2rl)# z&DFltisb{B&Ht6)pOx(nwe18#LO2i*qQn^0?zFU4S$6JoJ%BLgYD{k6f)Bh4o+J2} P^b8O&16FbaOg;YxA&4v# literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds$Maker.class b/test-data/indy/LambdaKinds$Maker.class new file mode 100644 index 0000000000000000000000000000000000000000..771112488588ab85ef840b64d17f1c854a252181 GIT binary patch literal 257 zcmYjMOAdli44f(mA}Sb-~5FSi&{XBDjIb!&vN+B39mBTx4ZHBRHwJiFqc9^ZXd5GO7t$&RxQI;!lI-(5k#I z2-c~*R*~E!8Ec$IfA-x(Fb`7I+fvmAdW2p>K(PIykkvX9N=Ze~|2Bq%VMFKfAwrvD j2jGOCU}Ud4nBdyr*n)}f4=vb?BWBnf`W+JkMhE5x_<=Zr literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds$NameOf.class b/test-data/indy/LambdaKinds$NameOf.class new file mode 100644 index 0000000000000000000000000000000000000000..c8340f4a6e77716627d6a79f5e77c1621833a4cd GIT binary patch literal 280 zcmZ8cNeaS15Uh@7(YWRff_gCz$VmiI4DsL#MrR_CIDt&`aUOhtj}lu!3|_kE>Z+#d z@x0vu%rSIf5rSBp_fl*!EzKwqXSGdTGzo)~xQZ|r`WSBaCv~U@R*@3Mllb2uqiOt8 z@2WD>$B5A06_@g$mRYX&UClImiwRz$OtmUZ<-#ZUH36Zs)>@T|To|Ja!TAR36NWY6 qC$J>g@4IvWLW8?8aswMo@b2=o;Gy-wHad&}Gn^1z$3u^C;CL?-2|(=t literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds$Named.class b/test-data/indy/LambdaKinds$Named.class new file mode 100644 index 0000000000000000000000000000000000000000..0cf21ada1600dfdf6052d71a1754a14782dfe067 GIT binary patch literal 222 zcmYk0%?g505QWc}f2oB*kI8hEfF3HE+~j5sGv{OY z-k;|Sz#el424Qg(O`3~KsdBxFMI&fg}}wmYxI;{s4cJ zI9mh*9(LZ_H*aQV=j;396TlVrTy$XAFgAO$XP?ME;5xhmjq)t zdUD_px=Iu>wc!%NNEAsb?lYCvN3oW#%NH9SVel_QFehay3H>NjGF}#mtR6*@vtsIw z+-*3D{sx#fRi@_W1b134tC_sXwAF9*J<(=@6U(}}E$ha{7GbSrAb595$!d~|x|X%J z{?5q1SWz6J$1C76-baTIuYqst%nleyE64AkHztG)jyesTa_qzBsIB{qS~UXum7#4s cgMk5S9R%21VTf(UfR!FX?7-o-L+o144{DZ8EdT%j literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds$Sink.class b/test-data/indy/LambdaKinds$Sink.class new file mode 100644 index 0000000000000000000000000000000000000000..68d18910a3f450d7068e0dd717505178b27a14ec GIT binary patch literal 206 zcmY*TK@NgI5bGkc2xxqS2ffG}jERwG^gzOWST+X4#jxn(Joo?~WgI-|WjbwU+P>b8 zCx8_u5{|%hPnW!)U1a9OSBPH|1<6d&_St4_cprSz2-cb+UVfgo*etK);)E-} literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds.class b/test-data/indy/LambdaKinds.class new file mode 100644 index 0000000000000000000000000000000000000000..20475153e3b743dd1a2181fcbdc7a9365d75d144 GIT binary patch literal 3029 zcmcIm`F9gl6#k}7GEFkjP}a08iUHaRR6r3M1Zfe7qzkl^tw1NqYZ;o%#AH&ayQqkY ziaV~jueg8;r{Nra_IUh7{~(Xwn*=&Zdic$r)7-aw_r80-d*6NY=fA}p0Jh<30ClKW z;nCoQPeJH}KB0G{b#ttvFL6R7Z3Vu~hH2Pa6x6r24f)Xkzlwl{AhZhjz+~PQ8KSHL zmFCACLx$DSpEFE*z|ILh6K&#|vo$m#q+nr29~a%0nbht5hJBo)ysevdp1Q5EwvxKz z2Xzf|EE+%)=Bk*dVLlcpm^*`g1!`8FmNuo4S`%$UO;E5%Lm15pym~g9o|MS8cwEmU zQu=PgOywgn)9%aC)jBMe$R!FENQY*E)MHT-Jh(%{omj5GBSWe+*u&Kv4SEsLumUR; zG^9j6nKKduhpSHCUfz)kKD26RLpyVzCzB#eUguT82MlwZNENF!tif6Zb=IhKxYFNV zJtO)?qblxE(C7}N%Q_W62iD8%Z(slnlpHnSJ{kE&1uI)C;wC&=X}LsJ5S#FTicYB& zRWQeWgo7xVj>#N8C=<9Lh^=@?#Wu-YSCxjtmkhOvb$Pnn5Sh-hVNO`%qN7ykrT)7^Puf*@4>VGGIS#Q;9VXDU9|@CCk9u)HRu zQlZdaee`BP!Hj$M*Z&{hZDX(4EHUb)refRS&D}1lDN&%sOjG2#(|SHH_-uO1oz`3K zpMF=@^T>znoun!uB;Kw-9B|T*$ANe46{t2Zv7qJlN zX(^%w=q+L?(CH~+8HKlqyC{4`tfEkhxSOKkIyw%9=3m3QPJh^6#JyY#@c7m3H>PlZ z9Ua)r*&2^)688AqGjB+p!WN0_E`h_ouzw0W<;ooDh6D9(I1h{;yoTN>^xZ^U#~@dR zu44Zb4l7bEtcGYpufsI6dohm@9zs1|4?m6|grkh~7y}$(FVe9ZNo+t0TbQt&zjJXKsz2E050LMVxVV&*CMVbw=m#D$Y5h^CaVZS@#`kd`0UC7S$$F_qDHq zX9|2nxr_2!$^_+il>L<7Q+81Pz`~7FUZOln`6Jf{DSzU8nDS@N3zWZbewn2Ij_`kZ CW#a_^ literal 0 HcmV?d00001 diff --git a/test-data/indy/NotInvokeStaticMetafactory.class b/test-data/indy/NotInvokeStaticMetafactory.class new file mode 100644 index 0000000000000000000000000000000000000000..10fd7bb6c55b131ac4b0ffbc025dd85bf6ebc27f GIT binary patch literal 579 zcma)3y-or_5dK!sOxYH<|hTo__$i!kIwzUYlDr)g#$4WW4^JH1tY+qobJxA>a&YN)4aXpz+Wf z%D%CX5=aN+DS=AurM>1mCi2vvS?@C6(ykP^Sd-qeqtT|I=h8+=Mj#)oTLr#qiO`jg z9dFvbA?5mVeA(7|Gzpst*{VB%Y#5(rp-jsEPsWvhd+WbzTjcv4&&Z~Y9P&A2VHYhF z1X8tn*Flj_6>O%3%xT;tuv1izdtCCUYMrN>R^W1XgfZ;`beGWbmi1wKiiD zQ5YXdw E14ZSS1ONa4 literal 0 HcmV?d00001 diff --git a/test-data/indy/NotLambdaMetafactory.class b/test-data/indy/NotLambdaMetafactory.class new file mode 100644 index 0000000000000000000000000000000000000000..4c80ba66cb4365cc110ae6ec269a9d5b29fd2263 GIT binary patch literal 576 zcma)3Jx>Br5S$hC_&DSQ#P7mH6R2qH=%legO~hbf_28a(aQEKjc#!b3Y$^N!#@}RI zI1-L%jMZjmW@k3LA7Ar#09QC8RPMBCi=gL!8S2@`BKhdmDQw<*jU5my?#f(=t2M_F@btD&AcJF^tOSK60#Hm5P8qP7+(ilw_ThX32`-kN literal 0 HcmV?d00001 diff --git a/test-data/indy/NotMetafactory.class b/test-data/indy/NotMetafactory.class new file mode 100644 index 0000000000000000000000000000000000000000..4ddc256c5ec454012aed41e7c448f927f8d94c13 GIT binary patch literal 570 zcma)3y-or_5dKz>13cse#s5NM0TqoMoirAxi5M)b25!ZJW%qI{Bs`Wag%4nSCgZ@7 zaKyw|P3HSeW;q=niDhI!FnmM-*s* zO7*3+=G!(3biYySFyA!36u4ZIfu5S7thZ>?^=YFd%f+OU#R6Z|MDEJUrZ?l?lJ=Dx zU$u-GPQpe)wyIAc8^)(uD3kL4lX2zW-rDclCMneptZcZ*V<(Rs++qd=fmF5D@lfO| z1*1^KyNf-P^4LO|HT#T3ljVJrxsMaAsjC)&6&#lMYeyRy{Hz;e?AS&W#>X1tf+N$c v1kW6{k%552TbZZe>CABqJijj#$l?U2D?wq203xOY&KPi++(il=4&eO&)GL%n literal 0 HcmV?d00001 diff --git a/test-data/indy/NotMetafactoryDescriptor.class b/test-data/indy/NotMetafactoryDescriptor.class new file mode 100644 index 0000000000000000000000000000000000000000..08cb512c271e693e019655a0a85797895864bc37 GIT binary patch literal 507 zcmZWly;8zJ5dO{};U`E0Mf@vdETE#XOD7faxlXS_&&B2K7bG9 zxDaLtW3{*6-`?)e@A4bK4K5j)k4irX&ENS*t9WrIl2PnO^fbX3>cH z>6!_yC^AP%P%UB~c`%sN)u}S7<`SpC>(@$w0?u&05meR)z*36gf&h!i0~oMz4Eql? CAbf%V literal 0 HcmV?d00001 diff --git a/test-data/src/indy/LambdaBoxing.java b/test-data/src/indy/LambdaBoxing.java new file mode 100644 index 00000000..48e4011f --- /dev/null +++ b/test-data/src/indy/LambdaBoxing.java @@ -0,0 +1,27 @@ +// The boundary: a call site this runtime refuses to link, and why that is a decision rather than +// an oversight. +// +// `LambdaMetafactory` is allowed to insert adapters. Here the interface method returns `Object` +// and the implementation returns `int`, so the real factory boxes on the way out — measured: +// OpenJDK 26.0.1 runs this and prints 3. This runtime has no boxing to insert at that point, so +// it does not link the call site at all and the class is refused as an unsupported feature. +// +// A refusal is worse than an adapter and much better than a wrong answer, and this fixture is what +// keeps that choice honest: delete the signature check in `jvm-bytecode/src/lambda.rs` and this +// class stops being refused. +// +// Compiled with: javac --release 21 -d test-data/indy test-data/src/indy/LambdaBoxing.java +public class LambdaBoxing { + interface Gen { + Object get(); + } + + static int size() { + return 3; + } + + public static void main(String[] args) { + Gen gen = LambdaBoxing::size; + System.out.println(gen.get()); + } +} diff --git a/test-data/src/indy/LambdaCapturingThis.java b/test-data/src/indy/LambdaCapturingThis.java new file mode 100644 index 00000000..ae9b5d3c --- /dev/null +++ b/test-data/src/indy/LambdaCapturingThis.java @@ -0,0 +1,31 @@ +// The one reference kind a modern javac will not give us: REF_invokeSpecial. +// +// A lambda whose body reads an instance field compiles to a private *instance* method, and before +// nestmates (JEP 181, Java 11) the only handle that could name a private method was +// REF_invokeSpecial. Java 11 onwards compiles the same source to REF_invokeVirtual on a synthetic +// method instead — measured on this file: `--release 8` emits kind 7, `--release 21` emits kind 5. +// +// So this fixture is pinned to 8 deliberately, and it is not a curiosity: class files that old are +// what this runtime exists to run. Without it the REF_invokeSpecial branch of the linker is code +// no test can reach. +// +// Compiled with: javac --release 8 -d test-data/indy test-data/src/indy/LambdaCapturingThis.java +public class LambdaCapturingThis { + interface Op { + int apply(int x); + } + + private final int base; + + LambdaCapturingThis(int base) { + this.base = base; + } + + Op adder() { + return x -> x + base; + } + + public static void main(String[] args) { + System.out.println(new LambdaCapturingThis(40).adder().apply(2)); + } +} diff --git a/test-data/src/indy/LambdaKinds.java b/test-data/src/indy/LambdaKinds.java new file mode 100644 index 00000000..479ae96e --- /dev/null +++ b/test-data/src/indy/LambdaKinds.java @@ -0,0 +1,113 @@ +// `Lambda.java` next door is the simplest possible metafactory call site: no capture, and an +// implementation method that is `REF_invokeStatic`. That single shape leaves the rest of the +// linker unobserved — the reference kind is a five-way branch, and a capture is the difference +// between "the object holds something" and "the object holds nothing". +// +// So each line below is a different one of those. What `javac --release 21` actually emitted for +// them is recorded in `tests/test_class_format.rs`, where the test reads the reference kinds back +// out of the class file rather than trusting this comment. +// +// Compiled with: javac --release 21 -d test-data/indy test-data/src/indy/LambdaKinds.java +public class LambdaKinds { + interface IntOp { + int apply(int x); + } + + interface Sink { + void accept(int x); + } + + interface Maker { + Box make(int x); + } + + interface Describer { + String describe(Box box); + } + + interface Getter { + String get(); + } + + interface Named { + String name(); + } + + interface NameOf { + String of(Named named); + } + + static class Box { + final int value; + + Box(int value) { + this.value = value; + } + + int doubled() { + return value * 2; + } + + String describe() { + return "Box:" + value; + } + } + + static class NamedBox implements Named { + public String name() { + return "named"; + } + } + + static class Base { + String describe() { + return "base"; + } + } + + static class Derived extends Base { + String describe() { + return "derived"; + } + + // `super::` is the only way to ask javac for REF_invokeSpecial: the reference has to name + // the method non-virtually, which is exactly what that kind means. + Getter superReference() { + return super::describe; + } + } + + static int twice(int x) { + return x + x; + } + + static int report(int x) { + System.out.println("sink:" + x); + return x; + } + + public static void main(String[] args) { + int base = 100; + + IntOp plain = x -> x + 1; // no capture, REF_invokeStatic + IntOp captured = x -> x + base; // captures `base` + IntOp staticRef = LambdaKinds::twice; // REF_invokeStatic, method reference + Maker constructorRef = Box::new; // REF_newInvokeSpecial + Describer unbound = Box::describe; // REF_invokeVirtual, receiver from the argument + Sink discarding = LambdaKinds::report; // the interface method is void, `report` is not + + NameOf named = Named::name; // REF_invokeInterface + Box bound = new Box(21); + IntOp boundRef = x -> bound.doubled() + x; // captures an object + + System.out.println(plain.apply(args.length)); + System.out.println(captured.apply(1)); + System.out.println(staticRef.apply(3)); + System.out.println(constructorRef.make(7).doubled()); + System.out.println(unbound.describe(bound)); + System.out.println(boundRef.apply(0)); + System.out.println(named.of(new NamedBox())); + System.out.println(new Derived().superReference().get()); + discarding.accept(9); + } +} diff --git a/test-data/src/indy/make_indy_fixtures.py b/test-data/src/indy/make_indy_fixtures.py index d8a414ef..e5a3e0e9 100644 --- a/test-data/src/indy/make_indy_fixtures.py +++ b/test-data/src/indy/make_indy_fixtures.py @@ -61,6 +61,12 @@ def name_and_type(self, name, descriptor): def methodref(self, class_index, nat_index): return self.add(u1(10) + u2(class_index) + u2(nat_index)) + def method_type(self, descriptor): + return self.add(u1(16) + u2(self.utf8(descriptor))) + + def method_handle(self, kind, reference_index): + return self.add(u1(15) + u1(kind) + u2(reference_index)) + def bytes(self): return u2(len(self.entries) + 1) + b"".join(self.entries) @@ -102,6 +108,64 @@ def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descrip ) +METAFACTORY_CLASS = "java/lang/invoke/LambdaMetafactory" +METAFACTORY_DESCRIPTOR = ( + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;" + "Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)" + "Ljava/lang/invoke/CallSite;" +) + + +def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor, bootstrap_kind=6): + """The same near-miss idea as `near_miss_call_site`, for the *other* linked factory: + `LambdaMetafactory.metafactory`. + + A separate builder rather than a parameter on that one, because the shapes differ in what the + identity check has to see past. A metafactory bootstrap carries three static arguments — + MethodType, MethodHandle, MethodType — and the call site returns the functional interface + rather than a String. A near miss has to carry all of that correctly, or it is refused for + the wrong reason and the identity check stays unobserved. + + `java/lang/Runnable` is the interface, and `()V` the method type, so that a fixture which + *does* get linked (because an identity comparison was deleted) links to something real and + runs to completion. Then the only difference a test can see is refusal versus no refusal, + which is the difference being measured.""" + cp = Pool() + this_class = cp.klass(name) + super_class = cp.klass("java/lang/Object") + main_name, main_desc, code_name = cp.utf8("main"), cp.utf8("([Ljava/lang/String;)V"), cp.utf8("Code") + + bootstrap = cp.method_handle( + bootstrap_kind, + cp.methodref(cp.klass(bootstrap_class), cp.name_and_type(bootstrap_name, bootstrap_descriptor)), + ) + # samMethodType, implMethod, instantiatedMethodType. The implementation is this class's own + # no-op static method, so a linked call site has something real to delegate to. + sam_type = cp.method_type("()V") + implementation = cp.method_handle(6, cp.methodref(this_class, cp.name_and_type("impl", "()V"))) + call_site = cp.add(u1(18) + u2(0) + u2(cp.name_and_type("run", "()Ljava/lang/Runnable;"))) + + body = u1(0xBA) + u2(call_site) + u2(0) + b"\x57" + b"\xb1" # invokedynamic; pop; return + code_attr = u2(1) + u2(1) + u4(len(body)) + body + u2(0) + u2(0) + main = u2(0x0009) + u2(main_name) + u2(main_desc) + u2(1) + u2(code_name) + u4(len(code_attr)) + code_attr + + impl_body = b"\xb1" # return + impl_code = u2(0) + u2(0) + u4(len(impl_body)) + impl_body + u2(0) + u2(0) + impl = ( + u2(0x0008) + u2(cp.utf8("impl")) + u2(cp.utf8("()V")) + u2(1) + u2(code_name) + u4(len(impl_code)) + impl_code + ) + + bootstrap_body = u2(1) + u2(bootstrap) + u2(3) + u2(sam_type) + u2(implementation) + u2(sam_type) + class_attributes = [u2(cp.utf8("BootstrapMethods")) + u4(len(bootstrap_body)) + bootstrap_body] + + return ( + b"\xca\xfe\xba\xbe" + u2(0) + u2(52) + cp.bytes() + + u2(0x0021) + u2(this_class) + u2(super_class) + + u2(0) + u2(0) + u2(2) + main + impl + + u2(len(class_attributes)) + b"".join(class_attributes) + ) + + FIXTURES = { # Differs from the real factory in the owning class alone. Everything else — kind 6, the # name, the descriptor, a String first static argument — matches, so only the identity @@ -145,8 +209,51 @@ def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descrip ), } +# One per axis of the metafactory identity, for the same reason the four above exist: with only +# some of them, deleting a single comparison leaves every test green. The linked counterpart is +# javac's own output (Lambda.class, LambdaKinds.class) — these are what it must *not* be confused +# with. +METAFACTORY_FIXTURES = { + # Differs in the owning class. + "NotLambdaMetafactory.class": ( + "NotLambdaMetafactory", + "java/lang/invoke/NotLambdaMetafactory", + "metafactory", + METAFACTORY_DESCRIPTOR, + ), + # Differs in the method name. `altMetafactory` is a real LambdaMetafactory bootstrap — the one + # javac emits for serializable and multi-interface lambdas — so this is the near miss a + # compiler could actually hand us. + "NotMetafactory.class": ( + "NotMetafactory", + METAFACTORY_CLASS, + "altMetafactory", + METAFACTORY_DESCRIPTOR, + ), + # Differs in the descriptor: `altMetafactory`'s, which is varargs where `metafactory`'s is + # three explicit types. Still a well-formed method descriptor. + "NotMetafactoryDescriptor.class": ( + "NotMetafactoryDescriptor", + METAFACTORY_CLASS, + "metafactory", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;" + "[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;", + ), + # Differs in the reference kind: 7 (REF_invokeSpecial) instead of 6 (REF_invokeStatic). + "NotInvokeStaticMetafactory.class": ( + "NotInvokeStaticMetafactory", + METAFACTORY_CLASS, + "metafactory", + METAFACTORY_DESCRIPTOR, + 7, + ), +} + if __name__ == "__main__": OUT.mkdir(parents=True, exist_ok=True) for filename, args in FIXTURES.items(): (OUT / filename).write_bytes(near_miss_call_site(*args)) print(f"wrote {OUT / filename}") + for filename, args in METAFACTORY_FIXTURES.items(): + (OUT / filename).write_bytes(lambda_near_miss(*args)) + print(f"wrote {OUT / filename}") diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 4551ac57..5731c148 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -68,23 +68,27 @@ async fn test_unsupported_constant_pool_tag_raises_class_format_error() { } } -// Exactly one `invokedynamic` bootstrap is linked: `StringConcatFactory.makeConcatWithConstants`, -// which is what javac 9+ lowers string `+` to. Every other bootstrap is still refused, and this -// asserts both halves — because a change that linked *everything* would satisfy the first half -// alone and read identically from the outside. +// Two `invokedynamic` bootstraps are linked, and only two: `StringConcatFactory +// .makeConcatWithConstants` and `LambdaMetafactory.metafactory` — what javac 9+ lowers string `+` +// and lambdas to. Every other bootstrap is still refused, and this asserts both halves, because a +// change that linked *everything* would satisfy the first half alone and read identically from the +// outside. // -// The linked half is asserted here as "it loads and runs"; what it actually prints is compared -// against `test-data/StringConcat.txt` by `tests/test_class.rs`, which is where output belongs. +// The linked half is asserted here as "it loads and runs"; what those two actually print is +// asserted by `tests/test_class.rs` (StringConcat) and by the lambda tests below. #[tokio::test] -async fn test_only_the_string_concat_bootstrap_is_linked() { +async fn test_only_the_two_recognised_bootstraps_are_linked() { let indy = Path::new("./test-data/indy/"); run_class(Path::new("test-data/indy/StringConcat.class"), &[indy], &[]) .await .expect("javac's string `+` call site should link and run"); + run_class(Path::new("test-data/indy/Lambda.class"), &[indy], &[]) + .await + .expect("javac's lambda call site should link and run"); - // LambdaMetafactory (Lambda) and ConstantBootstraps/condy (ConstantKinds) are not linked. - for name in ["Lambda", "ConstantKinds", "NotStringConcatFactory"] { + // ConstantBootstraps/condy (ConstantKinds) is not linked, nor is a near miss for either factory. + for name in ["ConstantKinds", "NotStringConcatFactory", "NotLambdaMetafactory"] { let path = PathBuf::from(format!("test-data/indy/{name}.class")); let err = run_class(&path, &[indy], &[]).await.unwrap_err().to_string(); @@ -323,13 +327,63 @@ async fn test_a_class_declaring_bootstrap_methods_twice_is_malformed() { ); } -// The same sentence, for the harder shape. A lambda's `BootstrapMethods` entry carries -// MethodType and MethodHandle constants as static arguments, which nothing here can resolve — -// so parsing the attribute is exactly where a lambda class could start being called corrupt -// again. The classfile-level test asserts the indices survive; this asserts what the user reads. +// `Lambda.class` is one lambda with nothing captured and a static implementation — the simplest +// call site javac emits, and by itself it leaves most of the linker unobserved. `LambdaKinds.class` +// is the rest: a capture, an object capture, a constructor reference, an unbound receiver, an +// interface method reference, and a `void` interface method dropping what its implementation +// returned. +// +// The output is asserted line by line rather than "it ran", and that is the load-bearing part: a +// lambda that captured the wrong value, dispatched to the wrong receiver or dropped an argument +// still links and still runs. The expected text is OpenJDK 26.0.1's, taken by running the same +// fixture there. #[tokio::test] -async fn test_lambda_class_reports_unsupported_feature_not_malformed() { - let path = Path::new("test-data/indy/Lambda.class"); +async fn test_every_reference_kind_a_lambda_implementation_can_have_runs() { + let indy = Path::new("./test-data/indy/"); + + let output = run_class(Path::new("test-data/indy/Lambda.class"), &[indy], &[]) + .await + .expect("a lambda should link and run"); + assert_eq!(output, "1\n", "x -> x + 1 applied to 0"); + + let output = run_class(Path::new("test-data/indy/LambdaKinds.class"), &[indy], &[]) + .await + .expect("every reference kind javac emits should link and run"); + + assert_eq!( + output.lines().collect::>(), + vec![ + "1", // no capture, REF_invokeStatic + "101", // captures an int + "6", // REF_invokeStatic, method reference + "14", // REF_newInvokeSpecial: Box::new, then doubled() + "Box:21", // REF_invokeVirtual, receiver from the interface method's argument + "42", // captures an object + "named", // REF_invokeInterface + "base", // super:: — javac routes it through a synthetic method, so still virtual + "sink:9", // the interface method is void; `report` returns int and it is dropped + ] + ); + + // REF_invokeSpecial, the kind the fixture above cannot have: javac stopped emitting it at + // Java 11, so this one is compiled at `--release 8` (see its source, and the pin in + // tests/test_fixture_pins.rs). Without it that branch of the linker is unreachable by any test. + let output = run_class(Path::new("test-data/indy/LambdaCapturingThis.class"), &[indy], &[]) + .await + .expect("a lambda capturing `this` should link and run"); + assert_eq!(output, "42\n", "40 captured through `this`, plus the argument"); +} + +// The other half of the sentence above: what is *not* linked. The interface method returns +// `Object` and the implementation returns `int`, so a real `LambdaMetafactory` boxes — OpenJDK +// 26.0.1 runs this fixture and prints 3. There is no boxing to insert here, so the call site is +// left alone and the class is refused. +// +// This is what makes the choice visible. Remove the signature check in `jvm-bytecode/src/lambda.rs` +// and the class links instead: the refusal is a decision, and deleting it is a change this notices. +#[tokio::test] +async fn test_a_call_site_needing_an_adapter_is_refused_rather_than_guessed_at() { + let path = Path::new("test-data/indy/LambdaBoxing.class"); let err = run_class(path, &[Path::new("./test-data/indy/")], &[]).await.unwrap_err().to_string(); @@ -339,6 +393,37 @@ async fn test_lambda_class_reports_unsupported_feature_not_malformed() { ); assert!( !err.contains("ClassFormatError"), - "a class javac emits for `x -> x + 1` is not malformed, got: {err}" + "a class OpenJDK runs to completion is not malformed, got: {err}" ); } + +// The identity check is four comparisons and the tests above can observe none of them: every +// other bootstrap on hand is refused for an unrelated reason first. So each axis gets a fixture +// differing in that axis alone, exactly as the string concat factory has (this is the second +// linked factory, so it needs its own set — a near miss for one is not a near miss for the other). +// +// Each is a valid class file that reaches the identity check: OpenJDK 26.0.1 loads all four and +// refuses them at linkage, one per axis — NoClassDefFoundError, NoSuchMethodError twice, and +// IncompatibleClassChangeError for the reference kind. +#[tokio::test] +async fn test_each_axis_of_the_metafactory_identity_is_observable() { + for (name, axis) in [ + ("NotLambdaMetafactory", "owning class"), + ("NotMetafactory", "method name (altMetafactory, a real LambdaMetafactory bootstrap)"), + ("NotMetafactoryDescriptor", "descriptor"), + ("NotInvokeStaticMetafactory", "reference kind"), + ] { + let path = PathBuf::from(format!("test-data/indy/{name}.class")); + + let err = run_class(&path, &[Path::new("./test-data/indy/")], &[]).await.unwrap_err().to_string(); + + assert!( + err.contains("java.lang.UnsupportedOperationException") && err.contains("invokedynamic"), + "{name}: a bootstrap differing in {axis} must not be linked, got: {err}" + ); + assert!( + !err.contains("ClassFormatError"), + "{name}: it has to reach the identity check, so it must be a readable file, got: {err}" + ); + } +} diff --git a/tests/test_fixture_pins.rs b/tests/test_fixture_pins.rs index bf6f8caf..a9ab0105 100644 --- a/tests/test_fixture_pins.rs +++ b/tests/test_fixture_pins.rs @@ -23,6 +23,20 @@ use std::{ffi::OsStr, fs, path::Path}; const PINNED_MAJOR: u16 = 65; const PINNED_MINOR: u16 = 0; +/// One fixture is compiled at a different release, and it is pinned rather than exempted. +/// +/// `LambdaCapturingThis` exists to carry a `REF_invokeSpecial` bootstrap argument, which javac +/// stopped emitting at Java 11 (nestmates, JEP 181) — `--release 8` produces kind 7 for that +/// source and `--release 21` produces kind 5, so the *release* is the fixture's whole point. Its +/// source says so too. Recording the pin here keeps the promise identical for it: regenerating it +/// on the wrong release is still caught. +fn pinned_version_of(stem: &str) -> (u16, u16, &'static str) { + match stem { + "LambdaCapturingThis" => (52, 0, "8"), + _ => (PINNED_MAJOR, PINNED_MINOR, "21"), + } +} + /// Synthetic fixtures — the ones a generator script writes byte by byte — are deliberately not /// pinned here: they are not javac output and pick their own version (`NotStringConcatFactory` /// targets 52.0). "Has a `.java` source" is the structural way to tell the two apart, and it @@ -50,12 +64,17 @@ fn indy_javac_fixtures_keep_the_pinned_class_file_version() { let major = u16::from_be_bytes([bytes[6], bytes[7]]); let minor = u16::from_be_bytes([bytes[4], bytes[5]]); + // `Outer$Inner.class` is compiled from `Outer.java`, so it inherits the outer name's pin. + let stem = path.file_stem().unwrap().to_str().unwrap(); + let (pinned_major, pinned_minor, release) = pinned_version_of(stem.split('$').next().unwrap()); + assert_eq!( (major, minor), - (PINNED_MAJOR, PINNED_MINOR), - "{} is class file {major}.{minor}, not the pinned {PINNED_MAJOR}.{PINNED_MINOR}. \ - Recompile it with: javac --release 21 -d test-data/indy test-data/src/indy/*.java", - path.display() + (pinned_major, pinned_minor), + "{} is class file {major}.{minor}, not the pinned {pinned_major}.{pinned_minor}. \ + Recompile it with: javac --release {release} -d test-data/indy test-data/src/indy/{}.java", + path.display(), + stem.split('$').next().unwrap() ); checked.push(path); From a20c4566571ee7d0ffa3cd186718420eda26a473 Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 17 Sep 2026 08:38:04 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[rustjava-adopt-link-stringconcatfactory-p2?= =?UTF-8?q?]=20test(indy):=20void=20SAM=20=EC=9D=98=20=C2=AB=EB=B2=84?= =?UTF-8?q?=EB=A6=BC=C2=BB=EA=B3=BC=20=EC=A0=95=EC=A0=81=20=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=202=EC=B6=95=EC=9D=84=20=EA=B4=80=EC=B8=A1=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 가지가 개악에 죽지 않았다(실측). ⑴returns_void 를 지워도 전 스위트 green — 남은 값이 오퍼랜드 스택 «아래»에 쌓일 뿐 정상 바이트코드가 다시 꺼내지 않기 때문이다. ⇒ 인터프리터 «밖»에서 그 값을 보는 경로를 픽스처에 넣었다: Thread.run() 이 Runnable.run()V 를 러스트에서 부르고 결과를 () 로 변환한다(From for () 는 Void 가 아니면 panic). 이제 그 개악은 «Expected void» 로 죽는다. ⑵⑶정적 인자의 «개수»와 «종류» 검사에 픽스처가 없었다 — metafactory 를 정확히 지목하면서 인자만 어긋난 두 클래스를 손조립했다(OpenJDK 도 둘 다 로드해 부트스트랩에서 거부한다). --- test-data/indy/LambdaKinds.class | Bin 3029 -> 3255 bytes .../indy/NotMetafactoryArgumentCount.class | Bin 0 -> 674 bytes .../indy/NotMetafactoryArgumentKinds.class | Bin 0 -> 649 bytes test-data/src/indy/LambdaKinds.java | 14 ++++++++ test-data/src/indy/make_indy_fixtures.py | 32 ++++++++++++++++-- tests/test_class_format.rs | 30 ++++++++++++++++ 6 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 test-data/indy/NotMetafactoryArgumentCount.class create mode 100644 test-data/indy/NotMetafactoryArgumentKinds.class diff --git a/test-data/indy/LambdaKinds.class b/test-data/indy/LambdaKinds.class index 20475153e3b743dd1a2181fcbdc7a9365d75d144..e7d3b38018f7ce903c4b65d332bb2b1d9ec929ce 100644 GIT binary patch literal 3255 zcmcImX?qh@6n>{oGFb*1%9?_pSfH&yMZ~2A6k35m7idedfXF17wnNjIIGGesaY5Wy zL=;rS4Od(Mr8X$;%j4r0zxf07Z}|A$$v`JbAHMk^IrrXq&$(xL&zbrC-~2@YYw&9r z6{wWqQ{YEHLUfNhqBf>gW1z7!u}4eV5&|oAL$_B;sI05+4Izk-jIe?Tlp=Wd{;aJH z6J_S8G`-C+q??VKE#0uYZA(*!n}e7v!Ds8qp)jJDBBNTtR7{gFbpo^mIU{t_2F=wK z>^Y9nm~IR-*Y{R4gc%BEVwQxN!|IUMVj4-+-lp4wTxD(5u(LupUSFffW8a~Wda4l*XqwuEO zU^`DO8uVkKf<>s45KL*=q@^baj+NZr>OK()0W>JM4vSe0HJQ{h%MT9I|E2BkZuC+wN$(IPVB#>1ENtwAsT1z<`jYqH%n`E?!6t}YoULP(eRl*PS#*i@kNe4lQ+Rt+t>+S3$2=^k7%hddQmC`?9Q#!GF>ai2Ir0Erdq+bczwJCw~QCn#mmpxd*N;~XC<|Dy70!Ex1}OGjH5%#+U=TfIVApae&Vbi`|O~MzAW1)~XYgGs*BRGUN z6}%;=M7<+W?<%+#_eBxl!TaLi0|k?@fxC~w_!y^Ud?KdwrxI$%$)Ot~=8)D{ zC_-UyuTzt@X>kxA^RmGO7NXst8mY9Ft!*>Sp}c}mv4A3wM?Hm{#}bO*1uW}}P9Mecrcf-D$4xv7^ZLaN7sqgG1szz; z-4dT?6Y~YUJAYIj!|eijM*$oQ#6n|;i<2tq#=@0eI4|tz8^z`^Y`KKEfL@;5c^-XZ zxJwdhF*!;T$qM8#K!+k1Fw_@|jAHmaGUFB6ju{MELnT3cP4Rm}=M36|xqPE6KpKl- zU^z^zMur~|7Pcab1nZb#eGjs#hnf15OyfBw@;A=6KRH1E!U6n^2clsy@7%J?V_8JO zC{vVq$}Y+al*=f;Cc8T*zo9%t bd6DP6l;3i{i}E|}4^n>5{SWvFzaaJ>zXKe7 delta 1500 zcmZWoYgd$26n@TJ-ZS&gk$FK;1{5>FxQdDyfl8326CETEaE5QH`J^QI*x3b0gSnL;6!&zx`c2^1-WP;4tvwtl5HR|zby2v@uv3gAXj0X(=(VGVA#2gO>WOrad>>@%X;s8FaxmHnM4>#k9# z#d-nP*s#v_-(>C4(L~2^lf(vrVD2leW5-O?VUvOv^#UGN(OUo??$l*%u{XG?w>98y ziAL==3FPMnXG&ZM3Tu_yw8Z)VnsJXri!Qj;zT~Pcufq<7`*6R2jE_yl4kzLQ?uyEA z0J{*DctGoEv;T0lirsdtJ7h)R4xkfzB=%}iU3Ra#WnGWJ(%i>)Bqm0t6LAyWaO=-L zg+A=JzjxOJ`Y|9esBi#7_Mh&}YhpMk5!bDvL@PH**GTe^#IT+A{4S>L3*JI;%)a8S zOg$lBO!BT#SZE3wc-lk)DTT)}-bwA9QW(QH@1E7(a|$D9vd|jzVWWk%pcgOb#=fYv z4Oyrs@w_HpPwb5^w8$ddHqN ze#o0O@h;wzcwgZIe0U=)Cxqv^K)HQVmKze^2$a$+wS9h-O2EZs1SA9}Z1DRqy;Vd( z^j6J5T%#fI2*3Io2ypz=LR(W;GJ!tz6c-O&CFU~skV!FFW>uD%cOA0W!4e>iRDSVd zkeR8a2MWFL;}8xn1d1(O?*LQ(YQ?uYSOM%VaZm!ZJ17MjJq}hAybjh93*bxCFil-#bWQx-WwtR zJa+1ne8z@McP^bTM*8N^K97!n7&p<)lioBQoX0~#hlQlY6j8pv*xbEXMveRVf9|LH z0azHM)&rC{#1)C61_x1(IJR?1cCz(797&9W7-x%5v0F3j*DROf29o%ZtM?1WEmJQ^ zw*9NwT|H}AMI|NKSv40e`;zeVV8*&PcE diff --git a/test-data/indy/NotMetafactoryArgumentCount.class b/test-data/indy/NotMetafactoryArgumentCount.class new file mode 100644 index 0000000000000000000000000000000000000000..2155f128a9fab08ad962b6adfdbd03d643a78e7e GIT binary patch literal 674 zcma)4O;5r=5Pd61`KEy42gX!_37oii13+--Gwj}&4uN?dV#@}Qd zCx~pYfK% z#i;c#7+XU^V+?VZ+k&CecItl<_9bTP{q;n%28gP6DqTk*#Y-kgde0xo1V?|0lzef9E!Tm+f%J>DkI^ zXOTuGjTEwl1ab_qMzgP@KqInU&k65Y6j4fJ4`tFE5atJ~;YNKQ10ndpSsGSwRHFSI zZ+S2jg;WMNXp}T5L(z+qINJ@M?}m-taE*S!jg)GteC~ydnBs!kjZ+t(n8H31V9@AR mrpPF23!DJY?@I-eI7M|W$gL27g%m@bfIN|T^3!ohNB;o;>$2AX literal 0 HcmV?d00001 diff --git a/test-data/indy/NotMetafactoryArgumentKinds.class b/test-data/indy/NotMetafactoryArgumentKinds.class new file mode 100644 index 0000000000000000000000000000000000000000..084c0b9c6a57d83bba11bc8c6fc2b8dd9c65e315 GIT binary patch literal 649 zcma)4%TB^T6g?woc@%jmJ}{=J3s|vn$4c3lh?+_LZkjNfFu zP!dWc#?{T7bM8HN&dlfc>;u3h&KaurQr%gKzw&{S{`ATphOQ;ltu4$z17j$R_=Gnd zE{4sA{>U0A4GD&%%Wc6>slRkqd{_Cl7`7Tc;@i@+7|vFtEhchoH9Oqxn>^;IA;pk& zS1T}l)nh}Kebn(L%^NOE#|kbwQjSBf6_YK!W5`1NoV=f0Lw16c@zZ|SE literal 0 HcmV?d00001 diff --git a/test-data/src/indy/LambdaKinds.java b/test-data/src/indy/LambdaKinds.java index 479ae96e..d078c9c7 100644 --- a/test-data/src/indy/LambdaKinds.java +++ b/test-data/src/indy/LambdaKinds.java @@ -81,6 +81,17 @@ static int twice(int x) { return x + x; } + // A `void` interface method whose implementation returns something is not a curiosity: the + // value has to be *dropped*, and if it is not, it stays on the operand stack. Nothing in + // ordinary bytecode ever pops it again, so the only way to see the mistake is to hand the + // object to something that calls the method from outside the interpreter — `Thread.run()` + // invokes `Runnable.run()V` from Rust and converts the result to `()`, which a stray value + // cannot be. + static int tick() { + System.out.println("tick"); + return 7; + } + static int report(int x) { System.out.println("sink:" + x); return x; @@ -109,5 +120,8 @@ public static void main(String[] args) { System.out.println(named.of(new NamedBox())); System.out.println(new Derived().superReference().get()); discarding.accept(9); + + Runnable ticking = LambdaKinds::tick; + new Thread(ticking).run(); } } diff --git a/test-data/src/indy/make_indy_fixtures.py b/test-data/src/indy/make_indy_fixtures.py index e5a3e0e9..35535bec 100644 --- a/test-data/src/indy/make_indy_fixtures.py +++ b/test-data/src/indy/make_indy_fixtures.py @@ -116,7 +116,7 @@ def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descrip ) -def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor, bootstrap_kind=6): +def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor, bootstrap_kind=6, arguments=None): """The same near-miss idea as `near_miss_call_site`, for the *other* linked factory: `LambdaMetafactory.metafactory`. @@ -155,7 +155,15 @@ def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor u2(0x0008) + u2(cp.utf8("impl")) + u2(cp.utf8("()V")) + u2(1) + u2(code_name) + u4(len(impl_code)) + impl_code ) - bootstrap_body = u2(1) + u2(bootstrap) + u2(3) + u2(sam_type) + u2(implementation) + u2(sam_type) + # `arguments` overrides the static argument list. `metafactory` is defined as taking exactly + # three, of exactly three kinds, so a bootstrap naming it with anything else is a near miss in + # the *arguments* rather than in the identity — a separate check, needing a separate fixture. + if arguments is None: + arguments = [sam_type, implementation, sam_type] + else: + arguments = [{"sam": sam_type, "impl": implementation, "string": cp.string("not a method type")}[x] for x in arguments] + + bootstrap_body = u2(1) + u2(bootstrap) + u2(len(arguments)) + b"".join(u2(x) for x in arguments) class_attributes = [u2(cp.utf8("BootstrapMethods")) + u4(len(bootstrap_body)) + bootstrap_body] return ( @@ -247,6 +255,26 @@ def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor METAFACTORY_DESCRIPTOR, 7, ), + # Identity correct, static arguments wrong. Two ways, because they are two checks: the count + # (`metafactory` takes three, this carries four) and the kinds (the third is a String where a + # MethodType belongs). Both are shapes no compiler emits and both would otherwise be read as + # if they were the real thing. + "NotMetafactoryArgumentCount.class": ( + "NotMetafactoryArgumentCount", + METAFACTORY_CLASS, + "metafactory", + METAFACTORY_DESCRIPTOR, + 6, + ["sam", "impl", "sam", "sam"], + ), + "NotMetafactoryArgumentKinds.class": ( + "NotMetafactoryArgumentKinds", + METAFACTORY_CLASS, + "metafactory", + METAFACTORY_DESCRIPTOR, + 6, + ["sam", "impl", "string"], + ), } if __name__ == "__main__": diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 5731c148..0b6e436d 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -136,6 +136,35 @@ async fn test_each_axis_of_the_factory_identity_is_observable() { } } +// The identity is four comparisons; the static arguments are two more checks, and they are not the +// same question. `metafactory` is defined as taking exactly three arguments of exactly three kinds, +// so a bootstrap that names it correctly and then carries four of them — or three where one is a +// String — is not the shape it claims to be. Reading it as if it were means linking a call site +// from constants that mean something else. +// +// Both fixtures are valid class files: OpenJDK 26.0.1 loads them and refuses at linkage with +// BootstrapMethodError. +#[tokio::test] +async fn test_a_metafactory_bootstrap_with_the_wrong_static_arguments_is_not_linked() { + for (name, wrong) in [ + ("NotMetafactoryArgumentCount", "four static arguments where there are three"), + ("NotMetafactoryArgumentKinds", "a String where the instantiated method type belongs"), + ] { + let path = PathBuf::from(format!("test-data/indy/{name}.class")); + + let err = run_class(&path, &[Path::new("./test-data/indy/")], &[]).await.unwrap_err().to_string(); + + assert!( + err.contains("java.lang.UnsupportedOperationException") && err.contains("invokedynamic"), + "{name}: {wrong} must not be linked, got: {err}" + ); + assert!( + !err.contains("ClassFormatError"), + "{name}: it has to reach the argument check, so it must be a readable file, got: {err}" + ); + } +} + #[tokio::test] async fn test_bad_magic_raises_class_format_error() { let mut bytes = hello_class(); @@ -362,6 +391,7 @@ async fn test_every_reference_kind_a_lambda_implementation_can_have_runs() { "named", // REF_invokeInterface "base", // super:: — javac routes it through a synthetic method, so still virtual "sink:9", // the interface method is void; `report` returns int and it is dropped + "tick", // and the drop is observed from outside: Thread.run() converts run()V to () ] ); From 87ef6a700f524f78266663cad3553ad6925d2555 Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 17 Sep 2026 09:06:57 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[rustjava-adopt-link-stringconcatfactory-p2?= =?UTF-8?q?]=20docs(state):=20=ED=9A=8C=EC=B0=A8=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20worklog=20=EC=8C=8D=20=C2=B7=20=EC=A0=9C=EC=95=88=20#p?= =?UTF-8?q?2=20=EC=B1=84=ED=83=9D=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- REPORT.md | 29 +++++++ STATE.md | 11 +++ .../2026-09-17-link-lambdametafactory.json | 50 +++++++++++ .../2026-09-17-link-lambdametafactory.md | 87 +++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 docs/worklog/2026-09-17-link-lambdametafactory.json create mode 100644 docs/worklog/2026-09-17-link-lambdametafactory.md diff --git a/REPORT.md b/REPORT.md index 5462d274..f42f34e4 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,33 @@ # REPORT +## [2026-09-17] `LambdaMetafactory.metafactory` 를 링크했다 — ★**람다와 메서드 참조가 «돈다»** (rustjava-adopt-link-stringconcatfactory-p2) +- 무엇을: 채택 제안 `2026-09-16-link-stringconcatfactory#p2`. ★**제품 동작이 바뀐다** — 람다·메서드 참조를 담은 클래스가 + **적재 거부**에서 **실행**으로 바뀐다. `jvm/` 은 **무접촉**, `java.lang.invoke` 는 **한 줄도 추가하지 않았다**. +- ★★**제안의 비용 추정이 틀렸다 — 그것이 이 회차의 요지다.** 제안은 「string concat 의 지름길을 쓸 수 없다 · + `java.lang.invoke` 가 **불가피**해진다 · 노력도 **L**」이라 했다. ★**호출 사이트가 «의미»하는 것은 핸들 사슬이 아니라 «객체»다.** + 그리고 그 객체를 만들 두 축이 ★**이미 있었다**: `MethodBody::Rust(JvmCallback)`(본문이 러스트인 메서드) · + `Jvm::register_class`(런타임에 만든 정의를 이름으로 등재). ⇒ 팩토리가 스핀할 클래스를 **직접** 만든다. +- ★**설계에서 «떨어져 나온» 것 둘**(만든 게 아니다): ⒜**GC 가 이미 추적한다** — `find_all_fields` 가 + `ClassDefinition::fields` 를 걷으므로 포획값을 «필드»로 두면 그대로 살아 있다(그래서 필드다) + ⒝**등재가 멱등** — `register_class_internal` 이 `.or_insert` 라 두 스레드가 같은 콜사이트에 닿아도 먼저 것이 이긴다(락 0). +- ★★**경계 = «어댑터»**: 실 팩토리는 박싱·언박싱·확대를 끼워 넣는다. 여기엔 그 축이 없으므로 **통과**(동일 프리미티브 · + 양쪽 레퍼런스)가 아니면 ★**링크하지 않는다**. 판정이 서술자만으로 되므로 **lowering 시점**에 끝난다 ⇒ 클래스는 + 로드되거나 안 되거나이고 ★**호출 «도중»에 실패하는 경로가 없다.** 그 경계는 `LambdaBoxing.class` 가 **잠근다** + (OpenJDK 26.0.1 은 3을 찍고 우리는 거부한다). +- ★★**관측 가능성이 어려웠던 자리 셋 — 전부 «처음엔 안 죽었다»**: + ⑴**void 버림**: 지워도 전 스위트 green 이었다 — 남은 값은 오퍼랜드 스택 «아래»에 쌓이고 정상 바이트코드가 다시 꺼내지 않는다. + ⇒ 인터프리터 «밖»에서만 보인다: `Thread.run()` 이 `Runnable.run()V` 를 러스트에서 부르고 `From for ()` 로 변환한다 + (Void 가 아니면 **panic**). 픽스처를 그 경로로 통과시키자 개악이 `Expected void, got Int(7)` 로 죽었다. + ⑵**REF_invokeSpecial**: javac 은 Java 11(nestmates)부터 그 종류를 «내지 않는다» — 같은 소스 실측으로 + `--release 8` 은 kind 7 · `--release 21` 은 kind 5. ★그렇게 오래된 클래스 파일이 이 런타임의 «대상»이므로 가지를 남기고 + 픽스처를 **8로 컴파일**했다. `test_fixture_pins.rs` 를 **픽스처별 핀**으로 바꿨다 — «면제»로 뺐으면 그 픽스처의 요지가 무검증이 된다. + ⑶**정적 인자 «개수·종류»**: 신원 4축엔 근접실패가 있었는데 이 둘엔 **없었다** ⇒ 손조립 2종 추가. +- ★★**개악 14종 전건 red**(정상 576 green): lowering 미호출 · 신원 4축 각각 · 정적인자 2축 · 통과검사 · 포획 저장 · + 수신자 처리 · void 버림 · REF_invokeSpecial · REF_newInvokeSpecial · 릴리스 8 핀. +- 검증: `cargo test --all` **573 → 576 passed / 0 failed / 1 ignored**(기준선 `origin/main` 워크트리 실측) · + `LambdaKinds` 출력 **10줄이 OpenJDK 26.0.1 과 글자대로 일치** · DoD 7명령 rc=0 · 픽스처 재생성 **멱등**. +- ★후속: **박싱 어댑터**(M · `LambdaBoxing` 이 이미 그 자리를 잠그고 있다) · **람다 클래스의 리플렉션 가시성 결정**(S) — + `docs/worklog/2026-09-17-link-lambdametafactory.json`. + ## [2026-09-17] 신원 4축을 «각각» 관측 가능하게 했다 — 감사의 「고칠 것이 없다」를 정정한다 (rustjava-adopt-cp-tag-passthrough-detectable-p0-fix) - 무엇을: `string_concat.rs` 의 부트스트랩 신원 **4축**(kind·class·name·descriptor) 중 ★**3축이 «관측되지 않고» 있었다** — 근접 실패 픽스처가 **class 축 하나**뿐이었기 때문이다. 나머지 3축의 픽스처를 만들었다. ★**제품 코드 무접촉.** diff --git a/STATE.md b/STATE.md index dbe798e2..64effa66 100644 --- a/STATE.md +++ b/STATE.md @@ -4,6 +4,17 @@ (없음 — 2026-09-16 실측: 착수 시 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 회차 PR 착지 시점 기준**이다 — 회신 시점에는 그 PR 자신이 열려 있다) ## 완료 +- [rustjava-adopt-link-stringconcatfactory-p2] ★★**`LambdaMetafactory.metafactory` 링크 — 람다·메서드 참조가 «돈다».** + 채택 제안 `2026-09-16-link-stringconcatfactory#p2`(worklog json `adoptedProposals` 기록). ★**제품 동작 변경 있음** + (람다 포함 클래스: 적재 거부 → 실행). ★`jvm/` 무접촉 · `java.lang.invoke` **0줄**. + ★★**제안의 「java.lang.invoke 가 불가피 · L」은 틀렸다** — 콜사이트가 의미하는 것은 핸들 사슬이 아니라 **객체**이고, + 그걸 만들 두 축이 **이미 있었다**(`MethodBody::Rust(JvmCallback)` · `Jvm::register_class`). ⇒ 팩토리가 스핀할 클래스를 직접 만든다. + ★**경계 = 어댑터**(박싱·언박싱·확대): 통과가 아니면 **링크하지 않는다** — 판정이 **lowering 시점**이라 「로드되거나 안 되거나」이고 + 호출 «도중» 실패 경로가 없다. `LambdaBoxing.class` 가 그 경계를 잠근다(OpenJDK 는 3을 찍는다). + ★★**관측 가능성 3종이 «처음엔 안 죽었다»** — ⑴void 버림(인터프리터 «밖» = `Thread.run()` 의 `()` 변환에서만 보인다) + ⑵REF_invokeSpecial(javac 11+ 는 안 낸다 ⇒ **--release 8** 픽스처 · 핀을 «픽스처별»로 바꿨다) ⑶정적 인자 개수·종류(손조립 2종). + ★**개악 14종 전건 red** · `cargo test --all` **573 → 576 / 0 failed / 1 ignored** · `LambdaKinds` 10줄이 OpenJDK 26.0.1 과 일치 · + DoD 7명령 rc=0 · 픽스처 재생성 멱등. - [rustjava-adopt-cp-tag-passthrough-detectable-p0-fix] ★★**신원 4축을 «각각» 관측 가능하게 했다 — 감사의 「고칠 것이 없다」를 정정한다.** 게이트② **request-changes** 승계(PR #55 · 핀 `ab13a3c7`). ★**제품 코드 무접촉** — 없던 것은 **픽스처**다. ★★**무엇이 틀렸나**: 직전 감사의 **M7**(「신원 4축 검사 제거」)은 네 비교를 ★**한꺼번에** 지운다 ⇒ 그 red 가 증명하는 것은 diff --git a/docs/worklog/2026-09-17-link-lambdametafactory.json b/docs/worklog/2026-09-17-link-lambdametafactory.json new file mode 100644 index 00000000..b5533fae --- /dev/null +++ b/docs/worklog/2026-09-17-link-lambdametafactory.json @@ -0,0 +1,50 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-17", + "taskId": "rustjava-adopt-link-stringconcatfactory-p2", + "summary": "Link LambdaMetafactory.metafactory, so lambdas and method references run. The proposal expected java.lang.invoke to become unavoidable; it did not — what a metafactory call site needs is an object, and this runtime already has the two pieces that make one: a Rust method body and a class registry.", + "changes": [ + "classfile/src/opcode.rs: LambdaCallSite + Opcode::InvokedynamicLambda (the parser never emits it; jvm-bytecode writes it)", + "classfile/src/attribute.rs: MethodHandleRef::resolve made public, plus method_type_descriptor — a bootstrap's static arguments are method handles and method types, and only their consumer can resolve them", + "jvm-bytecode/src/lambda.rs: four-axis recogniser, the pass-through signature check, the synthetic class, and the interface method body that dispatches by reference kind", + "jvm-bytecode/src/class_definition.rs, interpreter.rs: run the lowering pass, execute the opcode", + "test-data/src/indy/LambdaKinds.java: capture, object capture, constructor reference, unbound receiver, interface method reference, void interface method", + "test-data/src/indy/LambdaCapturingThis.java: --release 8, the only way to get a REF_invokeSpecial implementation out of javac", + "test-data/src/indy/LambdaBoxing.java: the boundary — an adapter this runtime will not insert", + "test-data/src/indy/make_indy_fixtures.py: six hand-assembled near misses for the metafactory (four identity axes, two static-argument shapes)", + "tests/test_fixture_pins.rs: pin per fixture rather than one release for all, so the --release 8 fixture is pinned rather than exempt" + ], + "verification": [ + "LambdaKinds output matches OpenJDK 26.0.1 line for line (10 lines); Lambda prints 1; LambdaCapturingThis prints 42", + "mutation: 14 product-side mutations, all red — lowering not called, each of the four identity axes, each of the two static-argument checks, the signature check, the capture store, the receiver handling, the void drop, REF_invokeSpecial, REF_newInvokeSpecial, and the fixture pin", + "cargo test --all: 576 passed / 0 failed / 1 ignored (origin/main baseline 573 / 0 / 1)", + "DoD 7 commands all rc=0; indy fixture regeneration idempotent" + ], + "issues": [ + "The void-drop mutation survived at first: a stray value left on the operand stack is never popped again by well-formed bytecode, so nothing in the interpreter can see it. It is observable only from outside — Thread.run() invokes Runnable.run()V from Rust and converts the result to (), which panics on anything but Void. The fixture now goes through that path.", + "Call sites needing an adapter (boxing, unboxing, widening) are not linked, so a class containing one is refused whole. That is a deliberate boundary, not a gap — but it means `Supplier s = list::size` still does not load." + ], + "adoptedProposals": [ + "2026-09-16-link-stringconcatfactory#p2" + ], + "proposals": [ + { + "title": "Insert the adapters LambdaMetafactory would, starting with boxing", + "plainSummary": "A method reference whose interface returns Object and whose implementation returns int is still refused.", + "userBenefit": "The generic functional interfaces are exactly where this bites: anything shaped like Supplier or Function reaches an implementation that returns a primitive.", + "why": "The refusal is one predicate — `passes` in jvm-bytecode/src/lambda.rs — and the conversion it declines is `Integer.valueOf` on the way out and `intValue` on the way in, both of which this runtime already has. test-data/indy/LambdaBoxing.class is the fixture, already committed and already asserting the refusal, so the change is visible the moment it is made.", + "tradeoff": "Adapters are where a linker stops being a recogniser: every conversion pair is a decision about what is allowed, and getting one wrong turns a refusal into a wrong answer. The pass-through case has to stay exactly as it is, which argues for adding conversions one measured pair at a time rather than a general rule.", + "effort": "M", + "target": "jvm-bytecode/src/lambda.rs" + }, + { + "title": "Decide whether a lambda class should be visible to reflection", + "plainSummary": "The class made for each lambda is registered under a name like Host$$Lambda$0, and getClass().getName() will say so.", + "userBenefit": "Code that prints or logs a lambda sees a name; whether it is the right one is a question nobody has asked yet here.", + "why": "OpenJDK's spun classes are hidden classes — not findable by name, not in the registry — and this one is an ordinary registered class. Nothing in the tests looks, so the difference is invisible today, which is exactly why it is worth deciding before something depends on it.", + "tradeoff": "Hiding it means a second kind of class registration, which is machinery bought for a property nobody has needed. Leaving it visible costs one registry entry per call site, for the life of the JVM.", + "effort": "S", + "target": "jvm-bytecode/src/lambda.rs, jvm/src/jvm.rs" + } + ] +} diff --git a/docs/worklog/2026-09-17-link-lambdametafactory.md b/docs/worklog/2026-09-17-link-lambdametafactory.md new file mode 100644 index 00000000..12552dcd --- /dev/null +++ b/docs/worklog/2026-09-17-link-lambdametafactory.md @@ -0,0 +1,87 @@ +# 2026-09-17 — Linking the second call site: `LambdaMetafactory.metafactory` + +`taskId: rustjava-adopt-link-stringconcatfactory-p2` · +adopts `2026-09-16-link-stringconcatfactory#p2` + +## The proposal's cost estimate was wrong, and that is the whole shape of this round + +> "unlike string concatenation it genuinely needs invoke machinery … the `java.lang.invoke` package +> this round avoided becomes unavoidable." — the proposal, effort **L** + +What a `metafactory` call site produces is an **object**: an instance of the functional interface +whose single method runs the implementation. A `MethodHandle` is how a real JVM delivers that; it +is not what the call site means. And this runtime already has both halves of the delivery: + +* `MethodBody::Rust(Box)` — a method whose body is Rust rather than bytecode, +* `Jvm::register_class` — a class definition made at runtime and registered by name. + +So the class the factory would spin is built directly (`jvm-bytecode/src/lambda.rs`): interface +from the call site's return type, one field per captured value (javac's own `arg$n`), one method — +the interface's — implemented by a Rust body that invokes the implementation. No `java.lang.invoke` +was added. Nothing in `jvm/` changed at all. + +Two things fell out of the existing design rather than being built: + +* **The GC already traces it.** `find_all_fields` walks `ClassDefinition::fields`, so captured + objects are reachable because they are fields — which is also why they are fields. +* **Registration is idempotent.** `register_class_internal` ends in `.or_insert(class)`, so two + threads reaching the same call site both build a class and the first one wins. No lock. + +## What is not linked + +The implementation's signature has to line up with the interface method's as a pass-through: +identical primitives, or a reference either way. Where a real `LambdaMetafactory` would insert an +adapter — box an `int` into an `Object`, unbox, widen — this refuses the call site, and because the +check runs at lowering time from descriptors alone, the class either loads or does not. There is no +path that fails halfway through a call. + +`test-data/indy/LambdaBoxing.class` is that boundary, committed and asserted: OpenJDK 26.0.1 runs +it and prints `3`; here it is refused. The follow-up proposal in the `.json` is to close it. + +## Where the observability was hard + +Three checks did not die when they were broken, and each needed something different. + +**The void drop.** A `void` interface method whose implementation returns a value has to discard +it. Delete that and every test still passed: the stray value lands on the operand stack *below* +everything the following instructions push, and well-formed bytecode never pops it again. Nothing +inside the interpreter can see it. It is visible only from outside — `Thread.run()` invokes +`Runnable.run()V` from Rust and converts the result with `From for ()`, which panics on +anything but `Void`. The fixture now hands a lambda to a `Thread`, and the mutation dies with +`Expected void, got Int(7)`. + +**REF_invokeSpecial.** javac stopped emitting kind 7 for lambda bodies at Java 11 (nestmates), so +no `--release 21` fixture can reach that branch — measured on the same source: `--release 8` gives +kind 7, `--release 21` gives kind 5. Class files that old are what this runtime is for, so the +branch stays and `LambdaCapturingThis` is compiled at 8. `tests/test_fixture_pins.rs` now pins per +fixture instead of pinning one release for all — an exemption would have made the fixture's whole +point unchecked. + +**The static arguments.** The identity check has four axes and each has a near miss; the argument +*count* and *kinds* are two further checks and had none, so a bootstrap naming `metafactory` +correctly while carrying four arguments, or a String where the instantiated type belongs, would +have been read as if it were the real thing. Two more hand-assembled fixtures. All six are valid +class files that OpenJDK loads and refuses at linkage — which is what makes them near misses rather +than corrupt files. + +## Mutation matrix + +Product-side, each reverted after measuring. Unmutated: **576 passed / 0 failed**. + +| | mutation | result | +|---|---|---| +| M1 | `lambda::lower` not called | **red** — nothing links | +| M2–M5 | identity: owning class / method name / descriptor / reference kind, one at a time | **red** ×4, each by its own near miss | +| M6 | the pass-through signature check ignored | **red** — `LambdaBoxing` links | +| M7 | `REF_invokeSpecial` dropped from the dispatch | **red** — `LambdaCapturingThis` | +| M8 | receiver left in the argument list as well as used | **red** | +| M9 | the `void` drop removed | **red** — `Expected void, got Int(7)` (survived before the fixture reached `Thread.run()`) | +| M10 | captured values not stored | **red** | +| M11 | `REF_newInvokeSpecial` yields null | **red** | +| M12 | the `--release 8` pin entry removed | **red** — the pin test | +| M13 | the instantiated-argument kind check removed | **red** | +| M14 | four static arguments accepted | **red** | + +14 of 14. `cargo test --all`: **573 → 576** passed / 0 failed / 1 ignored (baseline measured on +`origin/main` in a separate worktree). DoD's seven commands rc=0; regenerating the indy fixtures +leaves every pre-existing one byte-identical. From 8d96f3f5181dfc13e1bcc77dc3340c48f8e6d6dc Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 17 Sep 2026 13:04:31 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[rustjava-adopt-link-stringconcatfactory-p2?= =?UTF-8?q?-fix]=20fix(jvm-bytecode):=20=ED=8F=AC=ED=9A=8D=20=C2=AB?= =?UTF-8?q?=EC=88=9C=EC=84=9C=C2=BB=EB=A5=BC=20=EA=B0=92=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9E=A0=EA=B7=B8=EA=B3=A0=20=EC=A0=81=EC=9E=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20abort=20=EB=A5=BC=20?= =?UTF-8?q?=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게이트② 반려 2건(F1·F2)을 받는다. 둘 다 검수자 실측이 옳았다. F1 — 포획 순서가 전 스위트에 무관측이었다. 픽스처 전건이 포획 1개 이하라 `LambdaBody::call` 의 읽기 순서를 뒤집어도 576 green 이었다(검수자 RM4). 조용히 틀린 답이다. ⇒ LambdaKinds 에 포획 2개 람다 «둘»을 넣었다: (String,int) 는 a:7 로 순서가 «글자»에 보이고, (int,int) 는 120 으로 «값»에만 보인다 — 후자는 어떤 타입 검사로도 못 잡는 축이다. 개악 대조: RM4 → a:7→7:a · 120→2001 로 red. F2 — 적재 시점 «호스트 프로세스 abort»(jvm/src/type.rs:74 "Invalid type"). 게스트 예외가 아니다. ★고친 자리를 «골랐고 왜인지 적는다»: 검수자 제안(lambda.rs 2줄)만 하면 진단이 UnsupportedOperationException 이 되는데, ★OpenJDK 26 은 같은 파일을 ClassFormatError 로 거부한다 (Method "run" ... has illegal signature "I"). 즉 그 파일은 «미지원»이 아니라 «파손»이다. 근인은 JVMS 4.4.10 의 규칙이 «사용 지점»에 있다는 것이다 — NameAndType 자체는 필드/메서드 서술자 «둘 다» 적법해야 하고(Fieldref·Methodref 가 같은 항목을 쓴다), 어느 쪽인지는 그것을 «참조하는 태그»가 정한다. ⇒ validation.rs 의 일반 NameAndType 팔은 그대로 두고, InvokeDynamic=메서드 서술자 · Dynamic=필드 서술자를 «그 팔에서» 요구한다. 비용을 먼저 쟀다: 커밋된 클래스 175개 · indy/condy 참조 44건 중 새로 위법이 되는 것은 이 회차가 만든 픽스처 «1건»뿐이다. lambda.rs 의 try_parse 도 함께 넣었다 — 검증이 이미 막지만, 틀렸을 때의 대가가 게스트 예외가 아니라 «프로세스 죽음»이라 두 겹으로 둔다(verifier.rs 가 ldc 에 대해 쓴 그 문장). 덤으로 origin/main 의 선행 입구(string concat 의 extract_invoke_params 경로)도 같은 규칙에 막힌다 — 검수자가 「별 티켓 권장」이라 한 자리다. 넓힌 것이 아니라 규칙을 옳은 자리에 둔 결과다. --- classfile/src/validation.rs | 26 ++++++++++-- jvm-bytecode/src/lambda.rs | 16 +++++++- test-data/indy/LambdaKinds$Base.class | Bin 360 -> 360 bytes test-data/indy/LambdaKinds$Box.class | Bin 877 -> 877 bytes test-data/indy/LambdaKinds$Derived.class | Bin 1078 -> 1078 bytes test-data/indy/LambdaKinds$NamedBox.class | Bin 406 -> 406 bytes test-data/indy/LambdaKinds$Pair.class | Bin 0 -> 220 bytes test-data/indy/LambdaKinds$Weighted.class | Bin 0 -> 212 bytes test-data/indy/LambdaKinds.class | Bin 3255 -> 3813 bytes .../MetafactoryFieldDescriptorCallSite.class | Bin 0 -> 566 bytes test-data/src/indy/LambdaKinds.java | 22 ++++++++++ test-data/src/indy/make_indy_fixtures.py | 22 +++++++++- tests/test_class_format.rs | 38 ++++++++++++++++++ 13 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 test-data/indy/LambdaKinds$Pair.class create mode 100644 test-data/indy/LambdaKinds$Weighted.class create mode 100644 test-data/indy/MetafactoryFieldDescriptorCallSite.class diff --git a/classfile/src/validation.rs b/classfile/src/validation.rs index 941de2da..5712d8b6 100644 --- a/classfile/src/validation.rs +++ b/classfile/src/validation.rs @@ -231,9 +231,29 @@ fn validate_constant_pool(constant_pool: &BTreeMap) -> bo // The bootstrap method index is bounded by `bootstrap_method_indices_resolve`, not here: // it needs the class attributes, and this function only gets the pool. What is left for // this arm is the half that the pool alone can answer. - ConstantPoolItem::Dynamic { name_and_type_index, .. } | ConstantPoolItem::InvokeDynamic { name_and_type_index, .. } => { - constant_pool.get(name_and_type_index).and_then(ConstantPoolItem::name_and_type).is_some() - } + // + // JVMS 4.4.10 makes the *kind* of descriptor part of that half, and it differs by tag: a + // `CONSTANT_InvokeDynamic` names a method, a `CONSTANT_Dynamic` names a field type. The + // `NameAndType` arm above cannot say this — one entry is shared by Fieldref and Methodref, + // so "a field *or* method descriptor" is the strongest rule available *there*. The usage + // site is where the choice is decided, which is why the check lives here and why the arm + // above stays as it is. + // + // Measured before tightening: of 175 committed class files and 44 invokedynamic/dynamic + // references, exactly one is rejected by this — `MetafactoryFieldDescriptorCallSite`, the + // fixture written for it. OpenJDK 26 refuses that same file + // (`ClassFormatError: Method "run" ... has illegal signature "I"`), so this moves us onto + // the real JVM's answer rather than away from it. + ConstantPoolItem::InvokeDynamic { name_and_type_index, .. } => constant_pool + .get(name_and_type_index) + .and_then(ConstantPoolItem::name_and_type) + .and_then(|(_, descriptor_index)| constant_pool.get(&descriptor_index).and_then(ConstantPoolItem::utf8)) + .is_some_and(|descriptor| is_method_descriptor(&descriptor)), + ConstantPoolItem::Dynamic { name_and_type_index, .. } => constant_pool + .get(name_and_type_index) + .and_then(ConstantPoolItem::name_and_type) + .and_then(|(_, descriptor_index)| constant_pool.get(&descriptor_index).and_then(ConstantPoolItem::utf8)) + .is_some_and(|descriptor| is_field_descriptor(&descriptor)), _ => true, }) } diff --git a/jvm-bytecode/src/lambda.rs b/jvm-bytecode/src/lambda.rs index fe513599..05c5d12a 100644 --- a/jvm-bytecode/src/lambda.rs +++ b/jvm-bytecode/src/lambda.rs @@ -103,10 +103,22 @@ pub(crate) fn lower(class: &mut ClassInfo) { }; // The call site's return type is the interface being implemented. Anything else // is not a `metafactory` call site whatever its bootstrap says. - let call_site_type = JavaType::parse(descriptor); - let (captures, JavaType::Class(interface)) = call_site_type.as_method() else { + // + // Parsed with `try_parse`, not `parse`, and that is not a style choice: a call + // site's descriptor is a string out of the class file. `validation.rs` now requires + // it to be a method descriptor at this usage site (JVMS 4.4.10), so a bad one is + // refused before reaching here — but this stays `try_parse` anyway, because the + // cost of being wrong is not a guest exception, it is a host abort: + // `JavaType::parse` panics with "Invalid type". `verifier.rs` writes the same + // sentence about `ldc` — "reaching it would abort the host, not the guest". Two + // checks for one rule is cheap; one missing check is a crashed process. + let Some(JavaType::Method(captures, returns)) = JavaType::try_parse(descriptor) else { continue; }; + let JavaType::Class(interface) = &*returns else { + continue; + }; + let captures = &captures[..]; if !adapts(captures, &linked.sam_descriptor, &linked.implementation) { continue; } diff --git a/test-data/indy/LambdaKinds$Base.class b/test-data/indy/LambdaKinds$Base.class index d4295be03dd14e2164ee5cc430809e0f58500613..4cbed4666c7ef5a663f0355f423acc9249a63e0d 100644 GIT binary patch delta 19 acmaFC^nz)F3?rlWWLZWHM&HS9j7k7J%>@wv delta 19 acmaFC^nz)F3?rl6WLZWHMu*96j7k7I{RH;_ diff --git a/test-data/indy/LambdaKinds$Box.class b/test-data/indy/LambdaKinds$Box.class index 32dcf8f8efaee89f5d27fd853ef0b20a8954ea46..0622bdaf9f38931a5b4e5098045d4089d163cdfa 100644 GIT binary patch delta 33 pcmaFM_LgnKc_vN^1{MZO22KX6$v2pE7;PuBGV3!sPBvw>005pU2oC@N delta 33 pcmaFM_LgnKc_vP61{MY#22KXu$v2pE7!4+~GV3#%Og3e<005dN2hIQh diff --git a/test-data/indy/LambdaKinds$Derived.class b/test-data/indy/LambdaKinds$Derived.class index 912a217aa9b5c3f824ecbaef85fe980edfb262a1..f8954101981da9dd062103defa860659f0cebc4d 100644 GIT binary patch delta 31 lcmdnSv5jLx1~X&ejd2Z delta 19 acmbQnJdJsS6CIlfZgcB5KNC}pttsc&W2k=nF!Hv3^pZW7M z|2<##2Y@xk96g3fDlT~`4oa7Hb{5KTc;wdNCXza< z|Mh-60jw~V@C2rNbIc7EGPQZ7KQ(B-mU!(%z zskyZ|Z7NMncYK!LPl4b-)@>W>BqD*R0|>;aG1RWLvX(5P|KtI{_S}z^JNV!O?~xdcXo-~+S?E?fWr literal 0 HcmV?d00001 diff --git a/test-data/indy/LambdaKinds.class b/test-data/indy/LambdaKinds.class index e7d3b38018f7ce903c4b65d332bb2b1d9ec929ce..6861c70c35b6f0ab31ccebe02d5172d2d98fd148 100644 GIT binary patch literal 3813 zcmcIm>3VgtWA7W1F;2X$WAY4Yl2*BDNE3lhibXj-|0Zu{5Jd zBPR|?o3rH=FYCY5rh#@ z5LFR_S_2=QD>+7N;J0Rl}&vw5C#BdlPhFjf%B+iNM;tK4bLTRz`RBna(sdVV6S?=r1ysUvMUy9=oVBeX#xFBMoLb*%(xMtcGQ zT(05@bkIj>O{2mga*I?L#8oObqEjH8HAhe2sw66Hu^O#kF_^O*v?rjNrxOO(1I%@(=c&b*jo1GX2aFc=^QfjBbD*q8xgsVC&3ob3IZF>y6FsNWirfRrO zqM9HX+d6;c>C)OgDqfEfR%X^NPvnejwJFjujNuIm_R5^@W7hpPT+E`tl`DC)+;3i4 z$1sjJD!5gqZ$CTCuS{mh&HM^t2k>SEx5?Dez}0@SrEDpfsQV(bldWb#M{^7YCKXJ{ zbWRH-{3_fuR?DcKon>Ac%u#TVUG0Ys7)5i|$VOqJS^ni!Sg-|JOXY%595E)1qG4s& z2lW-S({LPiO$>)nR8Ugk$hg#4=R%l_DZ`0j7Karak^REDs)?WMkpw5#Q5A2&G2)A5 zwuU@#*@2O=W$DZ7+RICx~Yuj#zi z=hDT2DbSLu&bJN;>)0YER701gyV7;I>v)hH0rh-Nml}Q7*6lY7L`Us%F=Om7rM3QC zTqFIapTkDU88W0xB?XVxVmkWtlA+-7x+bj_Es=e;?UbCNUKlc*X**jA;{%LDi8<~S z8g|A*_;3`b@evj8uI@f2cOO^r5YF)1C*nY9@fEqF>MD zMoq^^(WU33_%VK>;HN5nhMx;u-r$m3?zC5*TyiLu_wSzj|GW31zSn$oBkB)L&F5;| z{4=FKBm!#MvW#MXPA`=VzEOhSRTcCupos6<2*|0$GIEE1cxjlk)R%^GGbcEo64kz_ zE`wf+DBjQ|c4gUxvc8 z>ok5vI!5|y(pl2qkdBl7mh=GW???^O7f4g2zh@Wgq<o1}l@{t)S(xqpQ8 NFWmnXf5$(O{4X%st$P3f delta 1533 zcmZWp`&U#|6#mXU?q%-1I(LCeBZ(`MU=YHQutG5*DpABhnM913X@)m5Gzi%PC&?c6 zwv&~;$jTl@8d6LHMfRXt{m@$dRDVGJ4gJ>YJ22_2{Ndj3?7hEz_Bnfh_g-3)@)^JW zGdmBU7C%oJzlr_1&~c-M3TzZ`#JdDmmG~PQLxbT+XjgA6GQ7SiG#K5}wMEA!0dIDo zE`HcR05@51qe{S4(%=u~A_q5HD8Mc9v}1!=4d!iCc8&bQu{Q5E3wfwAS-eii?G~;_ zz4SQCivqaQ!d=)Qphw~(;elvGz**vNFtHQ6bTnEh#%|f}++L<*kHCuTW2o~*7SGZI zr->Hq)v-@qs8z0W)!uNAKwdVzKAPwqjYbRv;Z&~!7TR%89(R?Q9k@?Nr-cy0@}jFi z&N+Q05kz%#S=gYiXlHMQ?G3lDlM^rBC?-Pf%fAf+!f8mbrw#kwo@^(3Mp7V1Y# zwJ0u-Um|Vy3a>w?oDZlxO~ zib)-xS;)sZc~)Oo{<(oET+lIX;R}2@J(jas$OX?<*AD_ka-&gbtZ$6R`$vX!EXZ=B zJeh!l@&tt>*fovsL$uZq1<_raf|%k$V1#cK227H9L9P|`T5h1cuGOnMCyCk2W-@uw z46`^>%DezQci9ZQwmw;SIm1|twlm`(ANECM$-j-YMXK^rv=d4$9vsF)OM8lDxab2e z$t`ACau(%4b3kZuIxvH6w8~vshR>yCTW(uVW4mJB zu|)Q1K2I7A%Hw6O&v0hbS+J=sg}ak!H2+7LLy)fhGiXcWUZL`Qy3IZAcVHIXJdrtv z{x+YP!r%;suC(|7R`EJfI7y<~VNjRwYuRr#dcBmlEVycPLnKqv8L}F(BD)H% z9&!(v{1(U*hvdYvmB}kg>E?8jOmpBd93@^u?F2E7CVmYq)O!biIJJnN8c#AB=9E9- zHc#O^uYVlR@L5_te|hy%W+m|o4{+2yz7tTz*xu-vc6COp;mwFEcvd9R4{Bcdd#=AD>-CIs$iMv>s2wR!X+WP9ZDC`&lT{^8b@bW-OX;xpZTrhTRHZY;Q%*N4e12#3mA9 z(o2jJc4t`$o_VZe0|bk=Do^5R&9M(WynHE;!vPK#g3>nvP(l%o7_gb#L label + ":" + count; // captures (String, int) — order is visible in the text + int hundreds = 1; + int ones = 20; + Weighted weighted = () -> hundreds * 100 + ones; // captures (int, int) — only the value shows a swap + NameOf named = Named::name; // REF_invokeInterface Box bound = new Box(21); IntOp boundRef = x -> bound.doubled() + x; // captures an object @@ -117,6 +137,8 @@ public static void main(String[] args) { System.out.println(constructorRef.make(7).doubled()); System.out.println(unbound.describe(bound)); System.out.println(boundRef.apply(0)); + System.out.println(pair.join()); + System.out.println(weighted.total()); System.out.println(named.of(new NamedBox())); System.out.println(new Derived().superReference().get()); discarding.accept(9); diff --git a/test-data/src/indy/make_indy_fixtures.py b/test-data/src/indy/make_indy_fixtures.py index 35535bec..ffe48fe7 100644 --- a/test-data/src/indy/make_indy_fixtures.py +++ b/test-data/src/indy/make_indy_fixtures.py @@ -116,7 +116,7 @@ def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descrip ) -def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor, bootstrap_kind=6, arguments=None): +def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor, bootstrap_kind=6, arguments=None, call_site_descriptor=None): """The same near-miss idea as `near_miss_call_site`, for the *other* linked factory: `LambdaMetafactory.metafactory`. @@ -143,7 +143,11 @@ def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor # no-op static method, so a linked call site has something real to delegate to. sam_type = cp.method_type("()V") implementation = cp.method_handle(6, cp.methodref(this_class, cp.name_and_type("impl", "()V"))) - call_site = cp.add(u1(18) + u2(0) + u2(cp.name_and_type("run", "()Ljava/lang/Runnable;"))) + # `call_site_descriptor` overrides what the `invokedynamic` claims to evaluate to. JVMS 4.4.6 + # lets a NameAndType descriptor be *either* a field or a method descriptor — it has to, because + # Fieldref and Methodref share the entry kind — so a file whose call site says `I` is one + # nothing upstream rejects, and it reaches the linker. That is the shape this parameter builds. + call_site = cp.add(u1(18) + u2(0) + u2(cp.name_and_type("run", call_site_descriptor or "()Ljava/lang/Runnable;"))) body = u1(0xBA) + u2(call_site) + u2(0) + b"\x57" + b"\xb1" # invokedynamic; pop; return code_attr = u2(1) + u2(1) + u4(len(body)) + body + u2(0) + u2(0) @@ -275,6 +279,20 @@ def lambda_near_miss(name, bootstrap_class, bootstrap_name, bootstrap_descriptor 6, ["sam", "impl", "string"], ), + # Identity and static arguments both correct; the *call site* descriptor is a field descriptor + # (`I`) rather than a method descriptor. Legal by JVMS 4.4.6 and accepted by `validation.rs`, + # so it reaches the linker — where reading it as a method type used to abort the host process + # instead of refusing the file. The linker now declines to lower it and the verifier refuses + # the class, which is what this fixture asserts. + "MetafactoryFieldDescriptorCallSite.class": ( + "MetafactoryFieldDescriptorCallSite", + METAFACTORY_CLASS, + "metafactory", + METAFACTORY_DESCRIPTOR, + 6, + None, + "I", + ), } if __name__ == "__main__": diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 0037e586..4edc47ee 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -180,6 +180,42 @@ async fn test_each_axis_of_the_factory_identity_is_observable() { // // Both fixtures are valid class files: OpenJDK 26.0.1 loads them and refuses at linkage with // BootstrapMethodError. +// A call site whose *descriptor* is a field descriptor (`I`), bound to an otherwise perfect +// metafactory bootstrap. JVMS 4.4.6 lets a NameAndType descriptor be a field *or* a method +// descriptor — it has to, because Fieldref and Methodref share the entry kind — so the shape gets +// past a reader that checks the entry in isolation, and then the linker reads it as a method type. +// +// It used to abort the host process there (`JavaType::parse` panics: "Invalid type"), which is the +// one failure a JVM must not have — the same sentence `verifier.rs` writes about `ldc`. JVMS 4.4.10 +// puts the rule at the *usage* site, so that is where the check went, and the answer now matches +// the real JVM's: OpenJDK 26 refuses this file with +// `ClassFormatError: Method "run" in class ... has illegal signature "I"`. +#[tokio::test] +async fn test_a_call_site_whose_descriptor_is_a_field_descriptor_is_malformed_not_a_host_abort() { + let path = Path::new("test-data/indy/MetafactoryFieldDescriptorCallSite.class"); + + let err = run_class(path, &[Path::new("./test-data/indy/")], &[]).await.unwrap_err().to_string(); + + assert!( + err.contains("java.lang.ClassFormatError"), + "a descriptor JVMS 4.4.10 forbids at this site makes the file malformed, got: {err}" + ); + // The control the reviewer built: identical bytes but a bootstrap we do not link. It must keep + // its old, different diagnosis — otherwise this check is just refusing everything. + let control = run_class( + Path::new("test-data/indy/NotLambdaMetafactory.class"), + &[Path::new("./test-data/indy/")], + &[], + ) + .await + .unwrap_err() + .to_string(); + assert!( + control.contains("java.lang.UnsupportedOperationException") && !control.contains("ClassFormatError"), + "the near miss must stay 'unsupported', not become 'malformed', got: {control}" + ); +} + #[tokio::test] async fn test_a_metafactory_bootstrap_with_the_wrong_static_arguments_is_not_linked() { for (name, wrong) in [ @@ -424,6 +460,8 @@ async fn test_every_reference_kind_a_lambda_implementation_can_have_runs() { "14", // REF_newInvokeSpecial: Box::new, then doubled() "Box:21", // REF_invokeVirtual, receiver from the interface method's argument "42", // captures an object + "a:7", // ★two captures (String, int) — the assertion is the *order*, not the presence + "120", // ★two captures (int, int) — a swap is invisible to any type check; only 120 vs 2001 shows it "named", // REF_invokeInterface "base", // super:: — javac routes it through a synthetic method, so still virtual "sink:9", // the interface method is void; `report` returns int and it is dropped From 85cf0fba72ac68a96bd494f959eeebfe456ecbe5 Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 17 Sep 2026 13:09:16 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[rustjava-adopt-link-stringconcatfactory-p2?= =?UTF-8?q?-fix]=20docs(state):=20=EC=8A=B9=EA=B3=84=20=ED=9A=8C=EC=B0=A8?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20=C2=B7=20worklog=20=EC=8C=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- REPORT.md | 21 +++++ STATE.md | 9 ++ ...7-lambda-capture-order-and-host-abort.json | 36 +++++++ ...-17-lambda-capture-order-and-host-abort.md | 94 +++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 docs/worklog/2026-09-17-lambda-capture-order-and-host-abort.json create mode 100644 docs/worklog/2026-09-17-lambda-capture-order-and-host-abort.md diff --git a/REPORT.md b/REPORT.md index d59a95bb..7edb7671 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,25 @@ # REPORT +## [2026-09-17] 포획 «순서»를 값으로 잠그고, 클래스 파일이 «프로세스를 죽이는» 자리를 닫는다 (rustjava-adopt-link-stringconcatfactory-p2-fix) +- 무엇을: 게이트② **request-changes** 승계(PR #61 · 핀 `87ef6a70`). ★검수자 지적 **F1·F2 둘 다 옳았고 둘 다 받았다.** +- ★**F1 — 포획 «순서»가 전 스위트에 무관측이었다**: `LambdaBody::call` 의 읽기 순서를 뒤집어도(검수자 RM4) **576 green**. + 거부가 아니라 ★**조용히 틀린 답**이다. 근인은 단언 방식이 아니라 **픽스처**다 — 전건이 포획 **1개 이하**라 «순서»라는 축이 존재하지 않았다. + ⇒ 포획 2개 람다 **둘**을 넣었다: `(String,int)` → `a:7`(순서가 **글자**에 보인다) · `(int,int)` → `120`(★**값에만** 보인다 — 어떤 타입 검사로도 못 잡는 축). + ★**RM4 가 이제 죽는다**: `a:7→7:a` · `120→2001`. ★`(a, b) -> a + b` 는 javac 이 내는 «가장 평범한» 람다다. +- ★★**F2 — 적법한 클래스 파일이 «호스트 프로세스»를 죽였다**(`jvm/src/type.rs:74` panic · 게스트 예외가 아니다). + ★**고친 자리를 «골랐고 왜인지 적는다»**: 검수자 제안(`lambda.rs` 2줄)만 쓰면 진단이 `UnsupportedOperationException` 이 되는데, + ★**OpenJDK 26 은 같은 파일을 `ClassFormatError` 로 거부한다**(`Method "run" … has illegal signature "I"`) — 그 파일은 «미지원»이 아니라 **«파손»**이다. + JVMS 4.4.6 상 `NameAndType` 은 필드·메서드 서술자 **둘 다** 적법해야 하고(Fieldref·Methodref 가 같은 항목을 공유한다), + ★**어느 쪽인지는 «참조하는 태그»가 정한다(4.4.10)** ⇒ 일반 `NameAndType` 팔은 **그대로 두고** + `InvokeDynamic`=메서드 서술자 · `Dynamic`=필드 서술자를 **그 팔에서** 요구하게 했다(`Methodref` 는 이미 그렇게 한다). +- ★**조이기 «비용»을 먼저 쟀다**(티켓 요구): 커밋된 클래스 **175개** · indy/condy 참조 **44건** 중 새로 위법이 되는 것은 ★**이 회차가 만든 픽스처 1건**뿐. +- ★**`lower()` 의 `try_parse` 는 «둘째 층»이고, 독립 관측이 «안 된다»는 것을 숨기지 않는다** — 검증을 통과하면서 `try_parse` 가 실패하는 입력을 만들지 못했다. + 남긴 이유는 측정이 아니라 **비용의 비대칭**이다: 바깥 층이 틀리면 게스트 예외, 안쪽이 없으면 **프로세스 사망**. +- ★**덤**: 같은 panic 이 `origin/main` 의 string concat 경로(`extract_invoke_params`)에도 있었는데 **같은 규칙에 함께 막힌다**(검수자가 「별 티켓」이라 한 자리). + 넓힌 것이 아니라 **규칙을 옳은 자리에 둔 결과**다. +- 검증: 개악 **2종 전건 red**(RM4 · F2 규칙 되돌리기) · `cargo test --all` **578 passed / 0 failed / 1 ignored** · + 픽스처 재생성 **멱등**(★형제 #59 의 생성기와 **한 파일로 합친 뒤**에도 기존 픽스처 바이트 불변). +- ★후속: `ClassFormatError` 에 **사유를 싣자**(M) — 이 회차가 세운 「미지원 ↔ 파손」 구분이 정작 파손 쪽에서 「Invalid class file」 한 줄로 뭉개진다. + ## [2026-09-17] `LambdaMetafactory.metafactory` 를 링크했다 — ★**람다와 메서드 참조가 «돈다»** (rustjava-adopt-link-stringconcatfactory-p2) - 무엇을: 채택 제안 `2026-09-16-link-stringconcatfactory#p2`. ★**제품 동작이 바뀐다** — 람다·메서드 참조를 담은 클래스가 **적재 거부**에서 **실행**으로 바뀐다. `jvm/` 은 **무접촉**, `java.lang.invoke` 는 **한 줄도 추가하지 않았다**. diff --git a/STATE.md b/STATE.md index e25a26a0..fdab916e 100644 --- a/STATE.md +++ b/STATE.md @@ -4,6 +4,15 @@ (없음 — 2026-09-16 실측: 착수 시 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 회차 PR 착지 시점 기준**이다 — 회신 시점에는 그 PR 자신이 열려 있다) ## 완료 +- [rustjava-adopt-link-stringconcatfactory-p2-fix] ★★**포획 «순서»를 값으로 잠그고 «호스트 abort»를 없앤다 — 게이트② 반려 승계(PR #61).** + ★**검수자 F1·F2 둘 다 옳았다.** F1: 픽스처 전건이 포획 1개 이하라 **순서 축이 무관측**이었고 RM4(읽기 순서 역전)가 **576 green** 이었다 + ⇒ 포획 2개 람다 둘 추가(`(String,int)`=`a:7` 글자로 · `(int,int)`=`120` ★값으로만) ⇒ **RM4 red**(`7:a`·`2001`). + F2: 서술자가 `I` 인 콜사이트가 **호스트 프로세스를 죽였다**. ★**고친 자리 = `validation.rs` 의 «사용 지점»**(JVMS 4.4.10: + InvokeDynamic=메서드 · Dynamic=필드) — 일반 `NameAndType` 팔의 `||` 는 **옳으므로 두었다**(Fieldref·Methodref 공유 항목). + ★근거는 실측이다: **OpenJDK 26 도 같은 파일을 `ClassFormatError`** 로 거부한다 ⇒ 「미지원」이 아니라 「파손」이 옳은 진단. + ★**조이기 비용 선측정**: 클래스 175 · indy/condy 44건 중 새로 위법 **1건**(이 회차 픽스처)뿐. + ★`lower()` 의 `try_parse` 는 둘째 층이고 ★**독립 관측 불가임을 명시**했다(남긴 근거 = 비용 비대칭). + ★개악 2종 전건 red · `--all` **578/0/1** · 픽스처 재생성 멱등(형제 #59 생성기와 합친 뒤에도 바이트 불변). - [rustjava-adopt-link-stringconcatfactory-p2] ★★**`LambdaMetafactory.metafactory` 링크 — 람다·메서드 참조가 «돈다».** 채택 제안 `2026-09-16-link-stringconcatfactory#p2`(worklog json `adoptedProposals` 기록). ★**제품 동작 변경 있음** (람다 포함 클래스: 적재 거부 → 실행). ★`jvm/` 무접촉 · `java.lang.invoke` **0줄**. diff --git a/docs/worklog/2026-09-17-lambda-capture-order-and-host-abort.json b/docs/worklog/2026-09-17-lambda-capture-order-and-host-abort.json new file mode 100644 index 00000000..c7d250b6 --- /dev/null +++ b/docs/worklog/2026-09-17-lambda-capture-order-and-host-abort.json @@ -0,0 +1,36 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-17", + "taskId": "rustjava-adopt-link-stringconcatfactory-p2-fix", + "summary": "Gate-2 request-changes follow-up: make capture *order* observable (it was not, so reversing it stayed green), and stop a class file from aborting the host process at load time — fixed at the usage site JVMS 4.4.10 names, which also makes the diagnosis match OpenJDK's.", + "changes": [ + "test-data/src/indy/LambdaKinds.java: two two-capture lambdas — (String,int) shows a swap in the text, (int,int) shows it only in the value", + "tests/test_class_format.rs: those two lines added to the asserted output; new test for the field-descriptor call site with the reviewer's control", + "classfile/src/validation.rs: InvokeDynamic requires a method descriptor, Dynamic a field descriptor (JVMS 4.4.10) — the shared NameAndType arm is left as it is", + "jvm-bytecode/src/lambda.rs: lower() parses the call site descriptor with try_parse instead of the panicking parse/as_method", + "test-data/src/indy/make_indy_fixtures.py: call_site_descriptor parameter + MetafactoryFieldDescriptorCallSite fixture; merged with the sibling generator that landed as #59" + ], + "verification": [ + "RM4 (reviewer's mutation: capture read order reversed) — was green at 576, now red: a:7 -> 7:a and 120 -> 2001", + "F2 mutation (revert the InvokeDynamic descriptor rule) — the new test fails; with the fix the file is ClassFormatError, matching OpenJDK 26", + "cost of tightening measured before doing it: 175 committed class files, 44 invokedynamic/dynamic references, exactly 1 newly rejected — the fixture written for it", + "cargo test --all: 578 passed / 0 failed / 1 ignored", + "regenerating the indy fixtures leaves every pre-existing one byte-identical, after merging two siblings' generators into one file" + ], + "issues": [ + "The try_parse in lower() is a second layer and I could not construct an input that passes the tightened validation and still fails it — so it is not independently observable. It stays because the cost of being wrong there is a host abort rather than a guest exception, and that asymmetry is the justification, not a measurement.", + "The same host abort exists on origin/main through the string-concat path (extract_invoke_params). The validation rule closes that entrance too, which was not this round's goal but follows from putting the rule where JVMS puts it." + ], + "adoptedProposals": [], + "proposals": [ + { + "title": "Give ClassFormatError the reason it was rejected for", + "plainSummary": "Every malformed class file reports the same flat 'Invalid class file'.", + "userBenefit": "A file rejected for an illegal call-site descriptor reads the same as one with a truncated constant pool; the message cannot tell you which.", + "why": "This round added a rule whose whole value is the distinction between 'unsupported' and 'malformed', and the malformed side still cannot say why. OpenJDK names the member and the signature. The parser already knows which predicate failed at the moment it fails.", + "tradeoff": "ClassFileError is a flat enum by an upstream cut (822504b); adding variants means touching the error type every validator shares, and the tests that assert on the kind rather than the text would need to keep working.", + "effort": "M", + "target": "classfile/src/error.rs, classfile/src/validation.rs" + } + ] +} diff --git a/docs/worklog/2026-09-17-lambda-capture-order-and-host-abort.md b/docs/worklog/2026-09-17-lambda-capture-order-and-host-abort.md new file mode 100644 index 00000000..07f87b64 --- /dev/null +++ b/docs/worklog/2026-09-17-lambda-capture-order-and-host-abort.md @@ -0,0 +1,94 @@ +# 2026-09-17 — Capture order, and a class file that killed the process + +`taskId: rustjava-adopt-link-stringconcatfactory-p2-fix` · +gate-2 request-changes follow-up for PR #61 (pin `87ef6a70`) + +Two findings, both the reviewer's, both correct. + +## F1 — the capture *order* was unobservable + +The reviewer reversed one line in `LambdaBody::call` (`&self.captures` → `.iter().rev()`) and the +whole suite stayed green at **576 / 0**. Not a refusal — a **wrong answer**, silently. + +The cause is in the fixtures, not the assertion style: every one of them captures **at most one +value**. `LambdaKinds` captures `base` in one lambda and `bound` in another; `Lambda` and +`LambdaCapturingThis` capture one apiece. With one capture there is no order to get wrong, so the +line that orders them is not covered by anything. The round's own mutation M10 ("captures not +stored") tested *presence*, which is a different axis and reads deceptively like the same one. + +Two lambdas now capture two values each, and they were chosen to fail differently: + +| lambda | captures | prints | why this one | +|---|---|---|---| +| `pair` | `(String, int)` | `a:7` | a swap shows up in the **text** | +| `weighted` | `(int, int)` | `120` | a swap shows up **only in the value** — no type check could catch it | + +RM4 now dies on both: `a:7 → 7:a` and `120 → 2001`. + +This is not an exotic shape. `(a, b) -> a + b` is what javac emits for the most ordinary lambda +there is, and until this round it would have silently returned the arguments backwards. + +## F2 — a valid class file aborted the host process + +``` +thread 'main' panicked at jvm/src/type.rs:74:13: Invalid type +``` + +A call site whose descriptor is `I` — a *field* descriptor — reaches `lower()`, which read it with +the panicking `JavaType::parse` / `as_method`. A panic is not a guest exception: the process dies. +`verifier.rs` already writes that exact sentence about `ldc` ("reaching it would abort the host, +not the guest"), so the repo's own doctrine names this a defect. + +### Where to fix it, and why not where it was suggested + +The review proposed two lines in `lambda.rs` (`try_parse` + `let … else`). That removes the abort, +but it answers **`UnsupportedOperationException`** — and that answer is wrong: + +``` +$ java -cp . MetafactoryFieldDescriptorCallSite # OpenJDK 26.0.1 +java.lang.ClassFormatError: Method "run" in class MetafactoryFieldDescriptorCallSite + has illegal signature "I" +``` + +The file is not *unsupported*. It is **malformed**, and this repository has spent several rounds on +keeping those two words apart. So the fix belongs where JVMS puts the rule. + +JVMS 4.4.6 says a `NameAndType` descriptor is "a valid field descriptor or method descriptor" — +which it must be, because `Fieldref` and `Methodref` share that entry kind. *Which* one is decided +by the entry that refers to it, and 4.4.10 states it: `CONSTANT_InvokeDynamic` names a method, +`CONSTANT_Dynamic` names a field type. The existing `NameAndType` arm is therefore **right as it +stands** and was left alone; the missing check was on the referring arm, which the codebase already +does for `Methodref` via `validate_member_reference(..., MemberKind::Method)`. + +**Cost measured before tightening**, as the ticket required: 175 committed class files, 44 +invokedynamic/dynamic references, **exactly one** newly rejected — the fixture written for this +finding. Nothing else in the tree moves. + +`lower()` still uses `try_parse`. That is a second layer, and I could not construct an input that +passes the tightened validation and still fails it — so **it is not independently observable**, and +I am not claiming a mutation kills it. It stays because the two layers fail differently: the outer +one produces a guest exception, and the missing inner one produces a dead process. + +### A side effect worth naming + +The same panic exists on `origin/main` through the string-concat path +(`Interpreter::extract_invoke_params`), which the review scoped out as a separate ticket. Putting +the rule at the usage site closes that entrance too — not by widening this round, but because a +rule in the right place covers everything that reads through it. + +## Mutations + +| | mutation | result | +|---|---|---| +| **RM4** | capture read order reversed (the reviewer's) | **red** — `7:a` / `2001` | +| **F2-M** | the `InvokeDynamic` descriptor rule reverted | **red** — the file is no longer `ClassFormatError` | + +`cargo test --all`: **578 passed / 0 failed / 1 ignored**. Regenerating the indy fixtures leaves +every pre-existing one byte-identical — including after merging this branch's generator with the +sibling one that landed as #59, which is the check that the merge kept both. + +## One process note + +`git checkout --` to undo a mutation also reverted an **uncommitted** fix sitting in the same file, +and the next command reported the fix as missing. Commit before mutating; the mutation matrix in +this round was re-run on a committed tree. From b085591d87fff310434def548b87864c8c07e05e Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 17 Sep 2026 17:33:53 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[rustjava-adopt-link-stringconcatfactory-p2?= =?UTF-8?q?-fix2]=20test(fixtures):=20#57=20=EC=9D=B4=20=EC=84=B8=EC=9A=B4?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=ED=91=9C=EC=97=90=20=EC=9D=B4=20PR=20?= =?UTF-8?q?=EC=9D=98=20=ED=94=BD=EC=8A=A4=EC=B2=98=2025=ED=96=89=EC=9D=84?= =?UTF-8?q?=20=EB=93=B1=EC=9E=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-data/class-file-versions.txt | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test-data/class-file-versions.txt b/test-data/class-file-versions.txt index 64a4ef67..02352e97 100644 --- a/test-data/class-file-versions.txt +++ b/test-data/class-file-versions.txt @@ -147,12 +147,37 @@ 65.0 indy/ConstantKinds.class 65.0 indy/Lambda$Op.class 65.0 indy/Lambda.class +65.0 indy/LambdaBoxing$Gen.class +65.0 indy/LambdaBoxing.class +52.0 indy/LambdaCapturingThis$Op.class +52.0 indy/LambdaCapturingThis.class +65.0 indy/LambdaKinds$Base.class +65.0 indy/LambdaKinds$Box.class +65.0 indy/LambdaKinds$Derived.class +65.0 indy/LambdaKinds$Describer.class +65.0 indy/LambdaKinds$Getter.class +65.0 indy/LambdaKinds$IntOp.class +65.0 indy/LambdaKinds$Maker.class +65.0 indy/LambdaKinds$NameOf.class +65.0 indy/LambdaKinds$Named.class +65.0 indy/LambdaKinds$NamedBox.class +65.0 indy/LambdaKinds$Pair.class +65.0 indy/LambdaKinds$Sink.class +65.0 indy/LambdaKinds$Weighted.class +65.0 indy/LambdaKinds.class 52.0 indy/MakeConcat.class 52.0 indy/MakeConcatWithArgument.class 52.0 indy/MakeConcatWrongDescriptor.class +52.0 indy/MetafactoryFieldDescriptorCallSite.class 52.0 indy/NotFactoryDescriptor.class 52.0 indy/NotInvokeStaticFactory.class +52.0 indy/NotInvokeStaticMetafactory.class +52.0 indy/NotLambdaMetafactory.class 52.0 indy/NotMakeConcatWithConstants.class +52.0 indy/NotMetafactory.class +52.0 indy/NotMetafactoryArgumentCount.class +52.0 indy/NotMetafactoryArgumentKinds.class +52.0 indy/NotMetafactoryDescriptor.class 52.0 indy/NotStringConcatFactory.class 65.0 indy/StringConcat.class 55.0 ldc/Ldc2WDynamic.class From 42fb6054cff3a94b1aaa04de3ddb019bbc58afed Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 17 Sep 2026 18:03:47 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[rustjava-adopt-link-stringconcatfactory-p2?= =?UTF-8?q?-fix2]=20docs(state):=20=ED=9A=8C=EC=B0=A8=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=C2=B7=20worklog=20=EC=8C=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- REPORT.md | 11 ++++ STATE.md | 7 +++ ...-09-17-fixture-version-table-backfill.json | 37 ++++++++++++ ...26-09-17-fixture-version-table-backfill.md | 57 +++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 docs/worklog/2026-09-17-fixture-version-table-backfill.json create mode 100644 docs/worklog/2026-09-17-fixture-version-table-backfill.md diff --git a/REPORT.md b/REPORT.md index 9e14553c..3063123a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,15 @@ # REPORT +## [2026-09-17] #57 의 버전 표에 이 PR 의 픽스처 25행을 등재한다 — ★**착지 «순서»가 만든 부채** (rustjava-adopt-link-stringconcatfactory-p2-fix2) +- 무엇을: `test-data/class-file-versions.txt` 에 **25행 추가**(생성기 실행 · 손편집 0) + base 당김. ★제품 Rust **0줄** · 픽스처 재생성 **0** · 테스트 코드 **무접촉**. +- 왜: PR **#57** 이 「미등재 픽스처는 핀을 실패시킨다」를 **의도적으로** 세우고 06:37 에 착지했다. #61 의 25개 픽스처는 그보다 **먼저** 만들어졌으므로, 착지한 그 순간부터 표에 25행을 빚졌다. ★**CI 도 충돌도 아니다** — 핀에서 rc=0 CI_GREEN · `git merge origin/main` 코드 충돌 0인데 **합친 결과**가 규율을 어긴다. +- 사용자 영향: **없다**(테스트 데이터 표). 있는 것은 게이트③ 해금. +- ★**안전선 = 삭제행 0**: `git diff --numstat` → **`25 0`**. 기존 156행 무변경 = 픽스처가 재생성되지 않았다는 뜻이다(삭제행이 있었으면 «다른 사건»이라 멈췄을 자리). +- ★**추가 25행 = 이 PR 이 만든 25개 `.class` 와 집합이 «정확히» 같다**(파일명 대조 · 남의 픽스처 혼입 0). +- ★**양방향으로 쟀다**: 표에서 `65.0 indy/LambdaKinds.class` 한 행을 지우면 **red**(그 파일명을 정확히 지목) · 되돌리면 **green** ⇒ 표가 실제로 규율을 집행한다(빈 표로 통과하지 않는다). +- ★**base 당김의 원장 충돌 2건은 «합집합»으로 풀었다** — `REPORT.md`·`STATE.md` 최상단 삽입 충돌. 한쪽 통째 채택 0 · 줄 단위 양방향 보존 증명(양측 고유줄 결손 **0** · 결과에만 있는 줄 **0**). ★코드 충돌은 **0**이었고, 같은 파일(`classfile/src/validation.rs`)을 다투던 #62 의 기여는 자동 병합 뒤에도 **전건 잔존**(loadable 집합 · 서술자 팔 «둘 다» 살아 있다). +- 검증: `cargo test --test test_fixture_pins` **3 passed / 0 failed** · `cargo test --all` **581 / 0 / 1**(#62 착지분 +3) · DoD **7명령 전건 rc=0**. +- ★후속: 「착지한 규율이 진행 중 PR 을 소급으로 빚지게 하는데 아무도 말해 주지 않는다」 — `docs/worklog/2026-09-17-fixture-version-table-backfill.json`. + ## [2026-09-17] 부트스트랩 정적 인자는 «적재 가능 상수»여야 한다 — 경계에서 «종류»로 (rustjava-adopt-bound-bootstrap-static-arguments-p0) - 무엇을: 채택 제안 `2026-09-16-bound-bootstrap-static-arguments#p0`. ★**제품 동작이 바뀐다** — 인자가 적재 불가 상수를 가리키는 클래스 파일이 **`ClassFormatError`** 로 거부된다. - 왜: JVMS 4.7.23 이 요구하는 것은 «인덱스가 어딘가에 닿는다»가 아니라 ★**「적재 가능 상수」**다(Integer·Float·Long·Double·Class·String·MethodHandle·MethodType·Dynamic). diff --git a/STATE.md b/STATE.md index e3aa24d3..646a924e 100644 --- a/STATE.md +++ b/STATE.md @@ -4,6 +4,13 @@ (없음 — 2026-09-16 실측: 착수 시 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 회차 PR 착지 시점 기준**이다 — 회신 시점에는 그 PR 자신이 열려 있다) ## 완료 +- [rustjava-adopt-link-stringconcatfactory-p2-fix2] ★★**#57 의 버전 표에 25행 등재 — 「착지 순서」가 만든 부채를 갚는다(PR #61).** + ★**막힌 것은 CI 도 충돌도 아니었다**: 핀 `85cf0fba` 에서 rc=0 CI_GREEN · `git merge origin/main` **코드 충돌 0** 인데 + ★**합친 결과**가 #57 이 세운 「미등재 픽스처는 핀을 실패시킨다」를 어겼다(미등재 **25건** 재현). + ★생성기(`record-class-file-versions.py`)를 **돌려서** 채웠다 — 손편집 0. ★**삭제행 0**(`25 0`) = 기존 픽스처 재생성 0 이 이 회차의 안전선. + ★추가 25행이 이 PR 의 25개 `.class` 와 **집합 동일**(혼입 0) · ★**양방향**(한 행 제거 → red · 되돌림 → green). + ★원장 충돌 2건은 **합집합**(줄 단위 양방향 보존 · 한쪽 통째 채택 0) · #62 기여 전건 잔존. + ★`test_fixture_pins` **3/0** · `--all` **581/0/1** · DoD 7명령 rc=0. - [rustjava-adopt-bound-bootstrap-static-arguments-p0] ★★**부트스트랩 정적 인자 = «적재 가능 상수» — 경계에서 «종류»로.** 채택 제안 `2026-09-16-bound-bootstrap-static-arguments#p0`(worklog json `adoptedProposals` 기록). ★**제품 동작 변경 있음.** ★**제안이 적은 위험을 먼저 쟀다**(「틀리면 람다가 전부 corrupt」): 태그 검사는 payload 를 읽지 않으므로 `attribute.rs` 설계와 충돌하지 않고, diff --git a/docs/worklog/2026-09-17-fixture-version-table-backfill.json b/docs/worklog/2026-09-17-fixture-version-table-backfill.json new file mode 100644 index 00000000..ae1196dd --- /dev/null +++ b/docs/worklog/2026-09-17-fixture-version-table-backfill.json @@ -0,0 +1,37 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-17", + "taskId": "rustjava-adopt-link-stringconcatfactory-p2-fix2", + "summary": "PR #61 was blocked at gate 3 by something that is neither CI nor a conflict: at its reviewed pin CI was green and `git merge origin/main` produced zero code conflicts, but the merged result violated a rule that landed while the PR was open. PR #57 landed the class-file-version table plus its deliberate 'a fixture with no row fails the pin' direction at 06:37; the 25 fixtures in #61 predate that, so from that instant the PR owed the table 25 rows. This round ran the recorder and committed those rows.", + "changes": [ + "test-data/class-file-versions.txt: +25 rows, written by test-data/src/record-class-file-versions.py (no hand edit) — 156 existing rows untouched", + "merge origin/main: resolved the two ledger conflicts (REPORT.md, STATE.md top-of-file inserts) as a union; zero code conflicts" + ], + "verification": [ + "reproduced the block first rather than trusting the previous round's number: cargo test --test test_fixture_pins named exactly 25 unrecorded fixtures, all under test-data/indy", + "git diff --numstat test-data/class-file-versions.txt: `25\t0` — zero deletions is the safety line here, since a deletion would mean an existing fixture was regenerated, which is a different event and a stop", + "the 25 added rows are set-identical to the 25 .class files this PR adds (git diff --diff-filter=A 06fa865c 85cf0fba -- test-data), so no sibling's fixture was swept in", + "bidirectional: removing `65.0 indy/LambdaKinds.class` from the table turns committed_fixtures_keep_their_recorded_class_file_version red and names that file; restoring it returns 3 passed / 0 failed — the table is enforcing, not decorative", + "cargo test --test test_fixture_pins after the merge: 3 passed / 0 failed", + "cargo test --all: 581 passed / 0 failed / 1 ignored (578 on the branch alone; +3 from PR #62 landing)", + "DoD all 7 commands rc=0, including `cargo +beta clippy` and the wasm32 target, which the shorthand form of the block used to omit", + "semantic-merge check on the file both PRs touch: classfile/src/validation.rs auto-merged, and diffing the result against 06fa865c shows only this PR's addition — #62's loadable-constant set and this PR's descriptor arms are both present", + "ledger conflict resolution proved line-wise in both directions: zero lines unique to either side missing from the result, zero lines in the result that came from neither side" + ], + "issues": [ + "The table is a freeze of what is on disk, so running the recorder would launder a corrupted fixture into the 'correct' value. That is why the zero-deletions check is the gate rather than an afterthought.", + "This is a debt payment, not a fix: nothing prevents the next in-flight PR from acquiring the same debt the next time a pin-style rule lands." + ], + "adoptedProposals": [], + "proposals": [ + { + "title": "Say which open PRs a newly landed repo-wide rule has just put in debt", + "plainSummary": "A rule landed on main can silently make other people's open pull requests fail, and nobody finds out until someone tries to merge one.", + "userBenefit": "None directly. It turns a surprise discovered at merge time into a note made at landing time, so the round that created the debt is the one that sees it.", + "why": "PR #57 deliberately made unlisted fixtures fail the pin — correct, and it said so. But #61 had already built 25 fixtures, so at 06:37 it became red without anyone touching it, and the redness was invisible: #61's own CI was green at its pin, and the merge had no conflicts. It surfaced only when a gate-3 round pulled base, which cost that round its slot and this whole follow-up round. The generalisation is cheap to state and not cheap to notice: any rule of the form 'everything of kind X must also appear in Y' indebts every open PR that adds an X.", + "tradeoff": "Doing this properly means checking out each open PR merged with main and running the new check, which is a full CI matrix per PR for a signal that is often empty. A cheap approximation — 'this PR adds files matching the new rule's glob' — has false positives and would need a person to read it anyway. There is also a real chance the honest answer is that the merge-ticket template already covers it, since pulling base is where this surfaced and that step is already mandatory.", + "effort": "M", + "target": "tests/test_fixture_pins.rs, .github/workflows/rust.yml" + } + ] +} diff --git a/docs/worklog/2026-09-17-fixture-version-table-backfill.md b/docs/worklog/2026-09-17-fixture-version-table-backfill.md new file mode 100644 index 00000000..0a435108 --- /dev/null +++ b/docs/worklog/2026-09-17-fixture-version-table-backfill.md @@ -0,0 +1,57 @@ +# 2026-09-17 — 버전 표 25행 백필 (rustjava-adopt-link-stringconcatfactory-p2-fix2) + +PR **#61** 이 게이트③에서 막혔는데, 막은 것은 **CI 도 충돌도 아니었다**. + +- 검수 핀 `85cf0fba` 에서 `ci-presence` → **rc=0 CI_GREEN** +- `git merge origin/main` → **코드 충돌 0**(원장 2파일만 충돌) +- 그런데 **합친 결과**가 `test_fixture_pins` 의 + `committed_fixtures_keep_their_recorded_class_file_version` 을 **red** 로 만든다 — 미등재 **25건**. + +## 원인은 형상이 아니라 «시점»이다 + +PR **#57** 이 `test-data/class-file-versions.txt` 와 함께 +「**미등재 픽스처는 핀을 실패시킨다**」를 **의도적으로** 세우고 06:37 에 착지했다 +(그 자신의 주석이 「이미 아는 것만 검사하는 핀은 새로 추가된 것을 덮지 못한다」고 적었다 — 옳다). + +#61 의 25개 픽스처는 그보다 **먼저** 만들어졌다. +⇒ **#57 이 착지한 그 순간부터 #61 은 표에 25행을 빚졌고, 아무도 그 사실을 말해 주지 않았다.** + +## 한 일 + +``` +python3 test-data/src/record-class-file-versions.py → recorded 181 fixtures +git diff --numstat test-data/class-file-versions.txt → 25 0 +``` + +★**`25 0` 의 「0」이 이 회차의 안전선이다.** 생성기는 «지금 디스크에 있는 것»을 기록하므로, +픽스처가 그 사이 오염됐다면 **오염된 값을 정답으로 굳힌다**. 삭제행이 하나라도 있었으면 +그것은 「기존 픽스처가 재생성됐다」는 뜻이고 **다른 사건**이라 멈췄을 자리다. + +추가된 25행은 이 PR 이 만든 25개 `.class` 파일과 **집합이 정확히 같다**(남의 픽스처 혼입 0). + +## 표가 실제로 규율을 집행하는가 — 양방향 + +| | 결과 | +|---|---| +| 표에서 `65.0 indy/LambdaKinds.class` 한 행 제거 | ★**red** — 그 파일명을 정확히 지목 | +| 되돌림 | ★**green** 3 passed / 0 failed | + +한 방향만 쟀으면 「상수 pass 로 바꿔도 통과」를 못 가른다. + +## base 당김 — 원장 2파일 합집합 + +`REPORT.md`·`STATE.md` 의 최상단 삽입 충돌 2건. 한쪽 통째 채택 **0**, 시간순 합집합. +줄 단위 양방향으로 증명했다 — 양측 고유줄 중 결과에 없는 것 **0** · 결과에만 있는 줄 **0**. + +★같은 파일(`classfile/src/validation.rs`)을 다투던 **#62** 의 기여는 자동 병합 뒤에도 **전건 잔존**했다 +(적재 가능 상수 집합 · 이 PR 의 서술자 팔 — **둘 다** 살아 있고, `06fa865c` 대비 diff 는 이 PR 의 추가분뿐이다). + +## 검증 + +`test_fixture_pins` **3/0** · `cargo test --all` **581 / 0 / 1**(브랜치 단독 578 → #62 착지분 +3) · +DoD **7명령 전건 rc=0**(`+beta` clippy · wasm32 clippy 포함). + +## 남는 것 + +이건 **부채 상환이지 수리가 아니다** — 다음에 같은 형태의 규율이 착지하면 그때 열려 있는 PR 이 +똑같이 조용히 빚을 진다. 그 축은 제안 `#p0` 으로 남겼다. From 58d23676e4c975595898d9cb071415c93ea9b983 Mon Sep 17 00:00:00 2001 From: jun0 Date: Fri, 18 Sep 2026 05:49:05 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[rustjava-adopt-link-stringconcatfactory-p2?= =?UTF-8?q?-fix3]=20docs(state):=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=E2=91=A2=20?= =?UTF-8?q?=EC=B0=A9=EC=A7=80=20=EA=B8=B0=EB=A1=9D=20=EB=8F=99=EB=B4=89=20?= =?UTF-8?q?(PR=20#61=20=C2=B7=20--merge=20=C2=B7=20=ED=95=80=205f7ce1a8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- STATE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/STATE.md b/STATE.md index 8fa23c10..ab8991d0 100644 --- a/STATE.md +++ b/STATE.md @@ -18,7 +18,11 @@ ★선행 머지 둘(`e53b2142`·`514d5b08`)이 부모2의 makeconcat 가족(`fieldref`·`make_concat_call_site`·`LINKED`)을 **결과에서 떨어뜨렸다** ⇒ **되살렸다**. ★반면 `.rs` 의 16줄은 **진짜 ours 의도**(metafactory 를 링크하니 「링크 안 된다」 단언이 거짓) ⇒ **되살리지 않았다**. 회계: `.py` ↔main **156/0** · `.rs` ↔HEAD **86/0** · ↔main **182/29**. ★**복원분이 산 코드임을 실행으로 증명** — MakeConcat 3장을 지우고 재생성 → **바이트 동일 복구**(복원 전 생성기로는 **불가**). - ★양방향 개악 ours 4 red / theirs 1 red · `--all` **583/0/1** · DoD 7명령 rc=0. ★착지 금지 — 핀이 움직였으니 게이트② 재검이 먼저다. + ★양방향 개악 ours 4 red / theirs 1 red · `--all` **583/0/1** · DoD 7명령 rc=0. ★그 회차는 「착지 금지 — 게이트② 재검이 먼저」로 끝났고, ★**그 재검이 approve 로 닫혔다**(아래). + ★★**게이트③ 착지 — PR #61 · `--merge`**(등재 repo `contracts/upstream-sync-repos.conf:22` · 티켓 `merge_strategy: merge` 선언분 ⇒ ★**계보 보존**). ★한 PR 이 `-p2`·`-fix`·`-fix2`·`-fix3` **네 회차**를 함께 싣는다. + 게이트② **approve**(리니지 최신 회신 `…-p2-fix3.review.md`) · 핀 **`5f7ce1a8`** ↔ 착수 시 PR head **동일**(불이동) · ★**`MERGEABLE/CLEAN` · base 뒤처짐 «0»** ⇒ 충돌 해소·base 당김 **둘 다 불요**(`-fix3` 이 이미 당겼다). + ★핀에서 `ci-presence` **rc=0 CI_GREEN**(3건 전건) · 자식 PR **0건** · 배포 **0**(배포 워크플로 없음) · 주기 자동 커밋 **0건** · 라이브 실행 주체 **없음**. + ★**선행 `-merge` 두 건은 흡수할 것이 없었다** — `…-fix-merge`(`needs-fix-ticket`)·`…-fix2-merge`(`code-conflict-out-of-scope`) 둘 다 **머지 0·커밋 0·푸시 0** 으로 멈췄다. - [rustjava-adopt-class-format-mutation-audit-p0-fix] ★★**판정식을 `given` 에서 파생시킨다 — 게이트② 반려 승계(PR #63).** ★**급소 한 줄**: `repaired` 를 정본 인자로 **다시 짓고** 있어 둘째 결함을 버렸다 ⇒ 「single-defect 인가」를 묻는데 **입력이 이미 single-defect** 였다(순환 · 18중 **14건**). ★처방은 발명이 아니라 **옮겨오기** — 이미 옳던 `indy` 근접실패 형태를 `add()` 한 곳으로 모아 **4족 전건**이 지나게 했고,