diff --git a/docs/next.md b/docs/next.md index 8f1a3b42..60b84536 100644 --- a/docs/next.md +++ b/docs/next.md @@ -19,7 +19,7 @@ for f in sorted(glob.glob('docs/worklog/*.json')): print(*[r for r in refs if r not in done],sep='\n')" ``` -1. **선행 사슬**: `java.lang.invoke` 패키지(카드 없음 · 근거 = `rustjava-runtime/src/classes/java/lang/invoke` **부재** · ★다음 칸) → `2026-09-17-string-concat-recipe-arity#p1`. 앞 칸 `2026-09-17-link-lambdametafactory#p0`(어댑터)은 2026-09-27 에 `int`↔`Integer` 한 쌍으로 닫혔다(`docs/worklog/2026-09-27-lambda-int-integer-adapter.md`). 다른 쌍은 이 사슬의 칸이 아니다. 필요한 게스트가 생기면 한 쌍씩 새 카드로 연다. +1. **선행 사슬**: 없음. 마지막 칸 `java.lang.invoke` 패키지 → `2026-09-17-string-concat-recipe-arity#p1` 은 2026-09-29 에 닫혔다(`docs/worklog/2026-09-29-string-concat-exception-as-cause.md`). 패키지에는 `StringConcatException` 하나만 있다 — `MethodHandle`·`CallSite` 는 쓰는 게스트가 생길 때 카드로 연다. 2. **카드 밖**: 없음. PR #81 은 2026-09-26 에 닫혔다 — #83(`750d30d4`)과 같은 변경이었다(`docs/worklog/2026-09-26-pr81-closed-duplicate-of-pr83.md`). 지난 판(⓪-사료·①~⑤)은 동결된 `STATE.md` `## 다음` 아래에 그대로 있다. diff --git a/docs/worklog/2026-09-29-string-concat-exception-as-cause.json b/docs/worklog/2026-09-29-string-concat-exception-as-cause.json new file mode 100644 index 00000000..3024893d --- /dev/null +++ b/docs/worklog/2026-09-29-string-concat-exception-as-cause.json @@ -0,0 +1,7 @@ +{ + "date": "2026-09-29", + "taskId": "rustjava-java-lang-invoke-package-and-linkage-error-cause", + "summary": "Mismatched string concat recipes raise BootstrapMethodError caused by java.lang.invoke.StringConcatException, as on OpenJDK 26.", + "proposals": [], + "adoptedProposals": ["2026-09-17-string-concat-recipe-arity#p1"] +} diff --git a/docs/worklog/2026-09-29-string-concat-exception-as-cause.md b/docs/worklog/2026-09-29-string-concat-exception-as-cause.md new file mode 100644 index 00000000..3327b343 --- /dev/null +++ b/docs/worklog/2026-09-29-string-concat-exception-as-cause.md @@ -0,0 +1,20 @@ +## [2026-09-29] 문자열 연결 레시피 불일치 — 상세를 cause(StringConcatException)로 싣는다 (rustjava-java-lang-invoke-package-and-linkage-error-cause) +- 무엇을: `java.lang.invoke` 패키지를 만들고 `StringConcatException` 한 클래스만 넣었다(`(String)` 만 — `Jvm::exception` 이 부르는 것). `concat_with_constants`(`jvm-bytecode/src/interpreter.rs`)가 불일치 시 `BootstrapMethodError: bootstrap method initialization exception` 을 던지고 상세는 `initCause` 로 단 `StringConcatException` 에 싣는다. 인자 → 상수 순으로 보고, 문구는 OpenJDK 26 것이다. +- 왜: `2026-09-17-string-concat-recipe-arity#p1` 채택. 선행 회차(`rustjava-linkage-errors-carry-their-cause`, 2026-09-18 blocked)가 막힌 이유였던 «cause 로 지목할 클래스 부재» 를 풀었다. cause 배관(`Throwable.initCause`·`printStackTrace` 의 `Caused by:`)은 이미 있었다. +- 사용자 영향: 오류 출력이 실 JVM 과 같은 두 줄 모양이 된다. 판정 경계(무엇을 거부하나)는 그대로다. + +### 실측 (OpenJDK 26.0.2.1 ↔ 이 런타임, `test-data/indy/RecipeWants*.class`) +| 픽스처 | OpenJDK cause | 이 런타임 cause | +|---|---|---| +| `RecipeWantsFewerArguments` | arguments: wants 1, provides 2 | 같음 | +| `RecipeWantsMoreArguments` | arguments: wants **1**, provides 1 | arguments: wants **2**, provides 1 | +| `RecipeWantsAConstant` | constants: wants **0**, only 0 | constants: wants **1**, only 0 | + +숫자가 다른 두 줄은 일부러 맞추지 않았다. OpenJDK 는 파서가 멈춘 시점의 계수를 찍어서 파일을 설명하지 못한다. 문구와 cause 타입은 같다. + +### 시험 +- `test_a_recipe_that_contradicts_its_call_site_is_a_bootstrap_method_error` 가 부분문자열 대신 머리줄 3개(`Java Exception:` · 바깥 오류 · `Caused by:` 줄)를 정확히 단언한다. +- 수정 전 런타임: red(cause 줄 없음, 상세가 바깥 메시지에 있음). 변이 `initCause` 호출 제거: red(cause 줄 없음). + +### 옮기지 않은 것 +`lambda.rs`·`string_concat.rs` 의 `java/lang/invoke/LambdaMetafactory`·`StringConcatFactory` 는 부트스트랩 이름을 대조하는 상수이고, 클래스로 싣는 곳이 없다. 그래서 이 패키지로 옮길 것이 없다. `MethodHandle`·`CallSite` 는 쓰는 곳이 없어 만들지 않았다. diff --git a/jvm-bytecode/src/interpreter.rs b/jvm-bytecode/src/interpreter.rs index 04252c28..860d3a35 100644 --- a/jvm-bytecode/src/interpreter.rs +++ b/jvm-bytecode/src/interpreter.rs @@ -1018,7 +1018,7 @@ impl Interpreter { /// Execute a `StringConcatFactory.makeConcatWithConstants` call site. /// /// A real JVM asks the factory for a `CallSite` holding a `MethodHandle` chain and invokes it. - /// There is no `java.lang.invoke` package here — no `MethodHandle`, no `CallSite` — so the + /// `java.lang.invoke` here holds only `StringConcatException` — no `MethodHandle`, no `CallSite` — so the /// recipe is walked directly instead. That is the whole reason this is *one* linked bootstrap /// rather than a linkage mechanism: the factory's contract is a string template, and a string /// template can be honoured without the machinery that would normally deliver it. @@ -1047,17 +1047,41 @@ impl Interpreter { async fn concat_with_constants(jvm: &Jvm, call_site: &StringConcatCallSite, params: Vec) -> Result> { let wanted_params = call_site.recipe.chars().filter(|&x| x == '\u{1}').count(); let wanted_constants = call_site.recipe.chars().filter(|&x| x == '\u{2}').count(); - if wanted_params != params.len() || wanted_constants != call_site.constants.len() { - return Err(jvm - .exception( - "java/lang/BootstrapMethodError", - &format!( - "string concat recipe wants {wanted_params} arguments and {wanted_constants} constants, but the call site provides {} and the bootstrap {}", - params.len(), - call_site.constants.len() - ), - ) - .await); + // OpenJDK 26's wording, arguments checked first. Its numbers are the ones this counted; OpenJDK's + // own counts come from a parser that stops at the first surplus, so they can differ. + let detail = if wanted_params != params.len() { + format!( + "Mismatched number of concat arguments: recipe wants {wanted_params} arguments, but signature provides {}", + params.len() + ) + } else if wanted_constants != call_site.constants.len() { + format!( + "Mismatched number of concat constants: recipe wants {wanted_constants} constants, but only {} are passed", + call_site.constants.len() + ) + } else { + String::new() + }; + if !detail.is_empty() { + let cause = match jvm.exception("java/lang/invoke/StringConcatException", &detail).await { + JavaError::JavaException(x) => x, + e => return Err(e), + }; + let error = jvm + .exception("java/lang/BootstrapMethodError", "bootstrap method initialization exception") + .await; + if let JavaError::JavaException(instance) = &error { + let _: Box = jvm + .invoke_virtual( + instance, + "java/lang/Throwable", + "initCause", + "(Ljava/lang/Throwable;)Ljava/lang/Throwable;", + (cause,), + ) + .await?; + } + return Err(error); } let mut result = String::new(); diff --git a/rustjava-runtime/src/classes/java/lang.rs b/rustjava-runtime/src/classes/java/lang.rs index 921884a4..7aaa4c4f 100644 --- a/rustjava-runtime/src/classes/java/lang.rs +++ b/rustjava-runtime/src/classes/java/lang.rs @@ -1,3 +1,5 @@ +pub mod invoke; + mod abstract_method_error; mod abstract_string_builder; mod appendable; diff --git a/rustjava-runtime/src/classes/java/lang/invoke.rs b/rustjava-runtime/src/classes/java/lang/invoke.rs new file mode 100644 index 00000000..b43dc281 --- /dev/null +++ b/rustjava-runtime/src/classes/java/lang/invoke.rs @@ -0,0 +1,3 @@ +mod string_concat_exception; + +pub use self::string_concat_exception::StringConcatException; diff --git a/rustjava-runtime/src/classes/java/lang/invoke/string_concat_exception.rs b/rustjava-runtime/src/classes/java/lang/invoke/string_concat_exception.rs new file mode 100644 index 00000000..6999eab7 --- /dev/null +++ b/rustjava-runtime/src/classes/java/lang/invoke/string_concat_exception.rs @@ -0,0 +1,37 @@ +use alloc::vec; + +use jvm::{ClassInstanceRef, Jvm, Result}; +use jvm_class_proto::JavaMethodProto; +use jvm_types::{ClassAccessFlags, MethodAccessFlags}; + +use crate::{RuntimeClassProto, RuntimeContext, classes::java::lang::String}; + +// class java.lang.invoke.StringConcatException +// Only the constructor `Jvm::exception` calls: this class exists to be the cause of the +// BootstrapMethodError a mismatched string concat recipe raises (jvm-bytecode/src/interpreter.rs). +pub struct StringConcatException; + +impl StringConcatException { + pub fn as_proto() -> RuntimeClassProto { + RuntimeClassProto { + name: "java/lang/invoke/StringConcatException", + parent_class: Some("java/lang/Exception"), + interfaces: vec![], + methods: vec![JavaMethodProto::new( + "", + "(Ljava/lang/String;)V", + Self::init_with_message, + MethodAccessFlags::PUBLIC, + )], + fields: vec![], + access_flags: ClassAccessFlags::PUBLIC, + } + } + + async fn init_with_message(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, message: ClassInstanceRef) -> Result<()> { + tracing::debug!("java.lang.invoke.StringConcatException::({this:?}, {message:?})"); + + jvm.invoke_special(&this, "java/lang/Exception", "", "(Ljava/lang/String;)V", (message,)) + .await + } +} diff --git a/rustjava-runtime/src/loader.rs b/rustjava-runtime/src/loader.rs index abd25d5f..45fe66b6 100644 --- a/rustjava-runtime/src/loader.rs +++ b/rustjava-runtime/src/loader.rs @@ -54,6 +54,7 @@ pub fn get_runtime_class_proto(name: &str) -> Option { crate::classes::java::lang::ArrayStoreException::as_proto(), crate::classes::java::lang::Boolean::as_proto(), crate::classes::java::lang::BootstrapMethodError::as_proto(), + crate::classes::java::lang::invoke::StringConcatException::as_proto(), crate::classes::java::lang::Class::as_proto(), crate::classes::java::lang::ClassCastException::as_proto(), crate::classes::java::lang::ClassCircularityError::as_proto(), diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 557bde55..128abc05 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -189,22 +189,37 @@ async fn test_each_axis_of_the_factory_identity_is_observable() { // and printed a quietly wrong "a" instead of refusing. #[tokio::test] async fn test_a_recipe_that_contradicts_its_call_site_is_a_bootstrap_method_error() { - for (name, disagreement) in [ - ("RecipeWantsMoreArguments", "recipe wants two arguments, the call site provides one"), - ("RecipeWantsFewerArguments", "recipe wants one argument, the call site provides two"), - ("RecipeWantsAConstant", "recipe wants a constant the bootstrap did not carry"), + // The detail rides on the cause, as on OpenJDK 26. The numbers are this runtime's own counts: for + // RecipeWantsMoreArguments and RecipeWantsAConstant OpenJDK prints the counts its parser had reached + // when it stopped ("wants 1 ..., provides 1" / "wants 0 constants, but only 0"), which describe + // nothing about the file. + for (name, cause) in [ + ( + "RecipeWantsMoreArguments", + "java.lang.invoke.StringConcatException: Mismatched number of concat arguments: recipe wants 2 arguments, but signature provides 1", + ), + ( + "RecipeWantsFewerArguments", + "java.lang.invoke.StringConcatException: Mismatched number of concat arguments: recipe wants 1 arguments, but signature provides 2", + ), + ( + "RecipeWantsAConstant", + "java.lang.invoke.StringConcatException: Mismatched number of concat constants: recipe wants 1 constants, but only 0 are passed", + ), ] { 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.BootstrapMethodError"), - "{name} ({disagreement}): expected the linkage diagnosis, got: {err}" - ); - assert!( - !err.contains("ClassFormatError") && !err.contains("UnsupportedOperationException"), - "{name}: the file parses and the bootstrap is one we link, got: {err}" + let headers: Vec<&str> = err.lines().filter(|x| !x.starts_with('\t')).collect(); + + assert_eq!( + headers, + [ + "Java Exception:", + "java.lang.BootstrapMethodError: bootstrap method initialization exception", + &format!("Caused by: {cause}"), + ], + "{name}" ); } }