Skip to content

Commit 2af7136

Browse files
jun0claude
andcommitted
[rustjava-java-lang-invoke-package-and-linkage-error-cause] feat: 레시피 불일치 상세를 StringConcatException cause 로
java.lang.invoke 패키지에 StringConcatException 하나만 둔다. concat_with_constants 는 BootstrapMethodError("bootstrap method initialization exception") 를 던지고 상세는 initCause 로 단 StringConcatException 에 싣는다 — OpenJDK 26 과 같은 모양. 시험은 Caused by 줄을 정확히 단언한다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 253cd0e commit 2af7136

9 files changed

Lines changed: 134 additions & 25 deletions

File tree

‎docs/next.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ for f in sorted(glob.glob('docs/worklog/*.json')):
1919
print(*[r for r in refs if r not in done],sep='\n')"
2020
```
2121

22-
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`). 다른 쌍은 이 사슬의 칸이 아니다. 필요한 게스트가 생기면 한 쌍씩 새 카드로 연다.
22+
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` 는 쓰는 게스트가 생길 때 카드로 연다.
2323
2. **카드 밖**: 없음. PR #81 은 2026-09-26 에 닫혔다 — #83(`750d30d4`)과 같은 변경이었다(`docs/worklog/2026-09-26-pr81-closed-duplicate-of-pr83.md`).
2424

2525
지난 판(⓪-사료·①~⑤)은 동결된 `STATE.md` `## 다음` 아래에 그대로 있다.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"date": "2026-09-29",
3+
"taskId": "rustjava-java-lang-invoke-package-and-linkage-error-cause",
4+
"summary": "Mismatched string concat recipes raise BootstrapMethodError caused by java.lang.invoke.StringConcatException, as on OpenJDK 26.",
5+
"proposals": [],
6+
"adoptedProposals": ["2026-09-17-string-concat-recipe-arity#p1"]
7+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
## [2026-09-29] 문자열 연결 레시피 불일치 — 상세를 cause(StringConcatException)로 싣는다 (rustjava-java-lang-invoke-package-and-linkage-error-cause)
2+
- 무엇을: `java.lang.invoke` 패키지를 만들고 `StringConcatException` 한 클래스만 넣었다(`<init>(String)` 만 — `Jvm::exception` 이 부르는 것). `concat_with_constants`(`jvm-bytecode/src/interpreter.rs`)가 불일치 시 `BootstrapMethodError: bootstrap method initialization exception` 을 던지고 상세는 `initCause` 로 단 `StringConcatException` 에 싣는다. 인자 → 상수 순으로 보고, 문구는 OpenJDK 26 것이다.
3+
- 왜: `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:`)은 이미 있었다.
4+
- 사용자 영향: 오류 출력이 실 JVM 과 같은 두 줄 모양이 된다. 판정 경계(무엇을 거부하나)는 그대로다.
5+
6+
### 실측 (OpenJDK 26.0.2.1 ↔ 이 런타임, `test-data/indy/RecipeWants*.class`)
7+
| 픽스처 | OpenJDK cause | 이 런타임 cause |
8+
|---|---|---|
9+
| `RecipeWantsFewerArguments` | arguments: wants 1, provides 2 | 같음 |
10+
| `RecipeWantsMoreArguments` | arguments: wants **1**, provides 1 | arguments: wants **2**, provides 1 |
11+
| `RecipeWantsAConstant` | constants: wants **0**, only 0 | constants: wants **1**, only 0 |
12+
13+
숫자가 다른 두 줄은 일부러 맞추지 않았다. OpenJDK 는 파서가 멈춘 시점의 계수를 찍어서 파일을 설명하지 못한다. 문구와 cause 타입은 같다.
14+
15+
### 시험
16+
- `test_a_recipe_that_contradicts_its_call_site_is_a_bootstrap_method_error` 가 부분문자열 대신 머리줄 3개(`Java Exception:` · 바깥 오류 · `Caused by:` 줄)를 정확히 단언한다.
17+
- 수정 전 런타임: red(cause 줄 없음, 상세가 바깥 메시지에 있음). 변이 `initCause` 호출 제거: red(cause 줄 없음).
18+
19+
### 옮기지 않은 것
20+
`lambda.rs`·`string_concat.rs` 의 `java/lang/invoke/LambdaMetafactory`·`StringConcatFactory` 는 부트스트랩 이름을 대조하는 상수이고, 클래스로 싣는 곳이 없다. 그래서 이 패키지로 옮길 것이 없다. `MethodHandle`·`CallSite` 는 쓰는 곳이 없어 만들지 않았다.

‎jvm-bytecode/src/interpreter.rs‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,7 @@ impl Interpreter {
10181018
/// Execute a `StringConcatFactory.makeConcatWithConstants` call site.
10191019
///
10201020
/// A real JVM asks the factory for a `CallSite` holding a `MethodHandle` chain and invokes it.
1021-
/// There is no `java.lang.invoke` package here — no `MethodHandle`, no `CallSite` — so the
1021+
/// `java.lang.invoke` here holds only `StringConcatException` — no `MethodHandle`, no `CallSite` — so the
10221022
/// recipe is walked directly instead. That is the whole reason this is *one* linked bootstrap
10231023
/// rather than a linkage mechanism: the factory's contract is a string template, and a string
10241024
/// template can be honoured without the machinery that would normally deliver it.
@@ -1047,17 +1047,41 @@ impl Interpreter {
10471047
async fn concat_with_constants(jvm: &Jvm, call_site: &StringConcatCallSite, params: Vec<JavaValue>) -> Result<Box<dyn ClassInstance>> {
10481048
let wanted_params = call_site.recipe.chars().filter(|&x| x == '\u{1}').count();
10491049
let wanted_constants = call_site.recipe.chars().filter(|&x| x == '\u{2}').count();
1050-
if wanted_params != params.len() || wanted_constants != call_site.constants.len() {
1051-
return Err(jvm
1052-
.exception(
1053-
"java/lang/BootstrapMethodError",
1054-
&format!(
1055-
"string concat recipe wants {wanted_params} arguments and {wanted_constants} constants, but the call site provides {} and the bootstrap {}",
1056-
params.len(),
1057-
call_site.constants.len()
1058-
),
1059-
)
1060-
.await);
1050+
// OpenJDK 26's wording, arguments checked first. Its numbers are the ones this counted; OpenJDK's
1051+
// own counts come from a parser that stops at the first surplus, so they can differ.
1052+
let detail = if wanted_params != params.len() {
1053+
format!(
1054+
"Mismatched number of concat arguments: recipe wants {wanted_params} arguments, but signature provides {}",
1055+
params.len()
1056+
)
1057+
} else if wanted_constants != call_site.constants.len() {
1058+
format!(
1059+
"Mismatched number of concat constants: recipe wants {wanted_constants} constants, but only {} are passed",
1060+
call_site.constants.len()
1061+
)
1062+
} else {
1063+
String::new()
1064+
};
1065+
if !detail.is_empty() {
1066+
let cause = match jvm.exception("java/lang/invoke/StringConcatException", &detail).await {
1067+
JavaError::JavaException(x) => x,
1068+
e => return Err(e),
1069+
};
1070+
let error = jvm
1071+
.exception("java/lang/BootstrapMethodError", "bootstrap method initialization exception")
1072+
.await;
1073+
if let JavaError::JavaException(instance) = &error {
1074+
let _: Box<dyn ClassInstance> = jvm
1075+
.invoke_virtual(
1076+
instance,
1077+
"java/lang/Throwable",
1078+
"initCause",
1079+
"(Ljava/lang/Throwable;)Ljava/lang/Throwable;",
1080+
(cause,),
1081+
)
1082+
.await?;
1083+
}
1084+
return Err(error);
10611085
}
10621086

10631087
let mut result = String::new();

‎rustjava-runtime/src/classes/java/lang.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
pub mod invoke;
2+
13
mod abstract_method_error;
24
mod abstract_string_builder;
35
mod appendable;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mod string_concat_exception;
2+
3+
pub use self::string_concat_exception::StringConcatException;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
use alloc::vec;
2+
3+
use jvm::{ClassInstanceRef, Jvm, Result};
4+
use jvm_class_proto::JavaMethodProto;
5+
use jvm_types::{ClassAccessFlags, MethodAccessFlags};
6+
7+
use crate::{RuntimeClassProto, RuntimeContext, classes::java::lang::String};
8+
9+
// class java.lang.invoke.StringConcatException
10+
// Only the constructor `Jvm::exception` calls: this class exists to be the cause of the
11+
// BootstrapMethodError a mismatched string concat recipe raises (jvm-bytecode/src/interpreter.rs).
12+
pub struct StringConcatException;
13+
14+
impl StringConcatException {
15+
pub fn as_proto() -> RuntimeClassProto {
16+
RuntimeClassProto {
17+
name: "java/lang/invoke/StringConcatException",
18+
parent_class: Some("java/lang/Exception"),
19+
interfaces: vec![],
20+
methods: vec![JavaMethodProto::new(
21+
"<init>",
22+
"(Ljava/lang/String;)V",
23+
Self::init_with_message,
24+
MethodAccessFlags::PUBLIC,
25+
)],
26+
fields: vec![],
27+
access_flags: ClassAccessFlags::PUBLIC,
28+
}
29+
}
30+
31+
async fn init_with_message(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef<Self>, message: ClassInstanceRef<String>) -> Result<()> {
32+
tracing::debug!("java.lang.invoke.StringConcatException::<init>({this:?}, {message:?})");
33+
34+
jvm.invoke_special(&this, "java/lang/Exception", "<init>", "(Ljava/lang/String;)V", (message,))
35+
.await
36+
}
37+
}

‎rustjava-runtime/src/loader.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ pub fn get_runtime_class_proto(name: &str) -> Option<RuntimeClassProto> {
5454
crate::classes::java::lang::ArrayStoreException::as_proto(),
5555
crate::classes::java::lang::Boolean::as_proto(),
5656
crate::classes::java::lang::BootstrapMethodError::as_proto(),
57+
crate::classes::java::lang::invoke::StringConcatException::as_proto(),
5758
crate::classes::java::lang::Class::as_proto(),
5859
crate::classes::java::lang::ClassCastException::as_proto(),
5960
crate::classes::java::lang::ClassCircularityError::as_proto(),

‎tests/test_class_format.rs‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -189,22 +189,37 @@ async fn test_each_axis_of_the_factory_identity_is_observable() {
189189
// and printed a quietly wrong "a" instead of refusing.
190190
#[tokio::test]
191191
async fn test_a_recipe_that_contradicts_its_call_site_is_a_bootstrap_method_error() {
192-
for (name, disagreement) in [
193-
("RecipeWantsMoreArguments", "recipe wants two arguments, the call site provides one"),
194-
("RecipeWantsFewerArguments", "recipe wants one argument, the call site provides two"),
195-
("RecipeWantsAConstant", "recipe wants a constant the bootstrap did not carry"),
192+
// The detail rides on the cause, as on OpenJDK 26. The numbers are this runtime's own counts: for
193+
// RecipeWantsMoreArguments and RecipeWantsAConstant OpenJDK prints the counts its parser had reached
194+
// when it stopped ("wants 1 ..., provides 1" / "wants 0 constants, but only 0"), which describe
195+
// nothing about the file.
196+
for (name, cause) in [
197+
(
198+
"RecipeWantsMoreArguments",
199+
"java.lang.invoke.StringConcatException: Mismatched number of concat arguments: recipe wants 2 arguments, but signature provides 1",
200+
),
201+
(
202+
"RecipeWantsFewerArguments",
203+
"java.lang.invoke.StringConcatException: Mismatched number of concat arguments: recipe wants 1 arguments, but signature provides 2",
204+
),
205+
(
206+
"RecipeWantsAConstant",
207+
"java.lang.invoke.StringConcatException: Mismatched number of concat constants: recipe wants 1 constants, but only 0 are passed",
208+
),
196209
] {
197210
let path = PathBuf::from(format!("test-data/indy/{name}.class"));
198211

199212
let err = run_class(&path, &[Path::new("./test-data/indy/")], &[]).await.unwrap_err().to_string();
200-
201-
assert!(
202-
err.contains("java.lang.BootstrapMethodError"),
203-
"{name} ({disagreement}): expected the linkage diagnosis, got: {err}"
204-
);
205-
assert!(
206-
!err.contains("ClassFormatError") && !err.contains("UnsupportedOperationException"),
207-
"{name}: the file parses and the bootstrap is one we link, got: {err}"
213+
let headers: Vec<&str> = err.lines().filter(|x| !x.starts_with('\t')).collect();
214+
215+
assert_eq!(
216+
headers,
217+
[
218+
"Java Exception:",
219+
"java.lang.BootstrapMethodError: bootstrap method initialization exception",
220+
&format!("Caused by: {cause}"),
221+
],
222+
"{name}"
208223
);
209224
}
210225
}

0 commit comments

Comments
 (0)