From 72678f581ccff2046dfd405f392f5cbf26c3bc16 Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 24 Sep 2026 01:08:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[rustjava-2026-09-20-name-the-missing-boots?= =?UTF-8?q?trap-class-adopt-p0]=20feat(jvm)!:=20JavaError::Unraisable=20?= =?UTF-8?q?=E2=80=94=20Jvm::new=20=EB=8A=94=20=ED=8C=A8=EB=8B=89=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20Err,=20Jvm::exception=20=EC=9E=AC=EA=B7=80?= =?UTF-8?q?=EC=97=90=20=EB=B0=94=EB=8B=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `JavaError::Unraisable(String)`: Java 예외로 만들 수 없는 실패(공개 enum 확장 · breaking). - `Jvm::new` 패닉 2곳(bootstrap_classes · 오류 경로 closure) → 빠진 클래스 이름을 담은 `Err`. - `Jvm::exception`: 같은 스레드에서 이미 만들고 있는 예외(타입+메시지)를 다시 만들거나 깊이 8 → `Unraisable`, 첫 실패 명명. 티켓의 「깊이 1」은 정상 1단 중첩(NoClassDefFoundError)을 막아 기존 테스트가 red 였다 — 순환 판정으로 바꿨다. - `[Ljava/lang/String;` 숨김(cap 20): stack overflow → loader 질문 2회 · `Unraisable`. - 깨진 irrefutable 3곳 + exhaustive match 2곳 수정. 스윕 0·12·0·25 불변. Co-Authored-By: Claude Opus 5.5 --- REPORT.md | 4 ++ STATE.md | 3 +- .../2026-09-24-unraisable-error-variant.json | 21 ++++++ .../2026-09-24-unraisable-error-variant.md | 36 ++++++++++ jvm-bytecode/src/interpreter.rs | 2 + jvm/src/error.rs | 11 ++++ jvm/src/jvm.rs | 66 ++++++++++++++++--- jvm/tests/test_error_path_class_sweep.rs | 26 +++++--- jvm/tests/test_exception_construction.rs | 4 +- .../test_exception_fallback_recursion.rs | 55 +++++++++++++--- .../java/util/logging/stream_handler.rs | 1 + .../tests/classes/java/lang/test_throwable.rs | 5 +- 12 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 docs/worklog/2026-09-24-unraisable-error-variant.json create mode 100644 docs/worklog/2026-09-24-unraisable-error-variant.md diff --git a/REPORT.md b/REPORT.md index 88a17a90..492dba48 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,8 @@ # REPORT +## [2026-09-24] `JavaError` 에 «Java 예외로 만들 수 없는 실패»를 뒀다 — `Jvm::new` 는 패닉 대신 `Err`, 예외 생성의 재귀에는 바닥 (rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0) +- 무엇을: `JavaError::Unraisable(String)` 변종 추가. `Jvm::new` 의 패닉 2곳(부트스트랩 클래스·오류 경로 closure)이 빠진 클래스 이름을 담은 `Err` 를 돌려준다. `Jvm::exception` 은 같은 스레드에서 이미 만들고 있는 예외를 다시 만들려 하면(또는 깊이 8) `Unraisable` 로 첫 실패를 명명한다. +- 왜: 채택 제안 2건(`…name-the-missing-bootstrap-class#p0` · `…string-array-hiding-overflows-stack#p0`)이 같은 장애물 — 단일 변종 `JavaError` — 에 닿았고, 하류 임베더 wie 가 같은 변종을 요청했다(타이틀 2건이 호스트를 죽였다). AGENTS.md 「라이브러리 코드는 패닉하지 않는다」. +- 사용자 영향: 불완전한 클래스 집합을 받은 호스트가 프로세스 abort 대신 처리 가능한 오류를 받는다. ★공개 enum 확장이라 외부 소비자의 irrefutable 구조분해는 깨진다. 후속 추천 1건(GC 순회 `Result` 전파) — `docs/worklog/2026-09-24-unraisable-error-variant.{md,json}`. ## [2026-09-23] `## 다음` 은 «ref 목록»으로 남긴다 — 카드로 옮기면 할 일이 0으로 보인다 (rustjava-2026-09-23-stale-next-pointer-and-euc-kr-boundary-adopt-p1) - 무엇을: `STATE.md` `## 다음` 머리에 규칙 3줄을 박고 절을 한 번 재작성했다 — 항목은 카드 ref·PR 번호만, 선행 사슬 1개, 카드 밖 항목(PR #81) 1개. 종전 ⓪ 블록은 사료로 내렸다. - 왜: 세 번 낡았고 세 번 다 «지목을 쓴 회차 ≠ 닫은 회차»였다(규칙 「닫히는 즉시」는 08-27 부터 있었다). 카드 전면 이전(⒞)은 tower 술어로 이 레인 열린 카드가 **0**(32건 전건 injected)이라 정본이 「할 일 없음」을 말하게 돼 기각. diff --git a/STATE.md b/STATE.md index 2462b7dc..07b0a111 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,7 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0] ★**공개 API 변경(breaking)**: `JavaError::Unraisable(String)` 신설 — Java 예외로 만들 수 없는 실패. `Jvm::new` 패닉 2곳 → `Err(Unraisable)`(빠진 클래스 이름 포함) · `Jvm::exception` 재귀 바닥(같은 스레드에서 «같은 예외»를 다시 만들거나 깊이 8 → `Unraisable`, 첫 실패 명명) · `[Ljava/lang/String;` 숨김 재현이 stack overflow → loader 질문 2회. ★외부 소비자: `let JavaError::JavaException(..) = ..` irrefutable 구조분해가 컴파일 에러가 된다(이 repo 3곳 수정 · wie 는 crates.io 0.1.1 소비라 판올림 때 발생). 채택 `2026-09-20-name-the-missing-bootstrap-class#p0` · `2026-09-20-string-array-hiding-overflows-stack#p0`. 상세 `docs/worklog/2026-09-24-unraisable-error-variant.md`. - [rustjava-2026-09-23-stale-next-pointer-and-euc-kr-boundary-adopt-p1] `## 다음` 정본 결정 = ⒝ 얇은 층(ref + 선행 사슬 + 카드 밖 항목만 · 산문 지목 금지). ⒞ 기각 근거 = tower 술어로 열린 카드 0(32건 전건 injected). 채택 `2026-09-23-stale-next-pointer-and-euc-kr-boundary#p1`. 상세 `docs/worklog/2026-09-23-next-section-canon-decision.md`. - [rustjava-prune-declined-followup-proposals-2026-09-21] ★**추천 후속작업 2건 기각** — 운영자 지시(2026-09-21 우선순위 정리). ★제품 코드 **0줄** · 새 제안 **0** · 검사기/CI 신설 **0**. ★**닫은 둘**(검사기 다듬기 축): `2026-09-19-nonliteral-blind-spot-is-reported-not-gated#p0` · `2026-09-20-lock-script-output-order#p0`. @@ -1518,7 +1519,7 @@ ★**카드 «열림»은 tower 술어(`… − injected`)로 세지 마라** — 이 레인은 그 술어로 **0**이다(주입 = 발권 요청일 뿐 착지가 아니다). 여기 술어는 `전체 − adopted − declined` 다. 1. **선행 사슬**(카드가 표현 못 하는 유일한 것): `2026-09-17-link-lambdametafactory#p1`(결정) → `#p0`(어댑터) → `java.lang.invoke` 패키지(카드 없음 · 근거 = `rustjava-runtime/src/classes/java/lang/invoke` **부재**) → `2026-09-17-string-concat-recipe-arity#p1`. -2. **순서 없음**: `2026-09-18-bootstrap-argument-index-and-tag#p0` · `2026-09-20-name-the-missing-bootstrap-class#p0`(둘 다 `queue/rustjava` 발권됨) · `2026-09-20-string-array-hiding-overflows-stack#p0` · `2026-09-12-zip-getinputstream-guard-lock#p0` · `2026-09-12-test-class-scratch-premise#p0`. +2. **순서 없음**: `2026-09-18-bootstrap-argument-index-and-tag#p0`(`queue/rustjava` 발권됨) · `2026-09-24-unraisable-error-variant#p0` · `2026-09-12-zip-getinputstream-guard-lock#p0` · `2026-09-12-test-class-scratch-premise#p0`. 3. **카드 밖**: PR **#81** — `CONFLICTING`(2026-09-23 워밍 후 재조회) · 충돌 해소 선행. ---- 이하 ⓪(2026-09-23 전수 재측 판)·①~⑤ 는 사료다. ★**「다음」으로 읽지 마라** ---- diff --git a/docs/worklog/2026-09-24-unraisable-error-variant.json b/docs/worklog/2026-09-24-unraisable-error-variant.json new file mode 100644 index 00000000..da722cbd --- /dev/null +++ b/docs/worklog/2026-09-24-unraisable-error-variant.json @@ -0,0 +1,21 @@ +{ + "schema": "rustjava-worklog-v1", + "date": "2026-09-24", + "taskId": "rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0", + "summary": "JavaError::Unraisable(String) added; Jvm::new returns it instead of panicking (2 sites); Jvm::exception refuses to rebuild an exception already being built on the same thread (or depth 8) and names the first failure.", + "adoptedProposals": [ + "2026-09-20-name-the-missing-bootstrap-class#p0", + "2026-09-20-string-array-hiding-overflows-stack#p0" + ], + "proposals": [ + { + "title": "Let the GC reachability walk return errors instead of unwrapping them", + "plainSummary": "Garbage collection still kills the process if reading an object's fields fails, even though there is now an error type that can say so.", + "userBenefit": "An embedder (wie) whose guest hands over a broken object gets an error for that program instead of losing the whole emulator.", + "why": "wie's 2026-09-22-lgt-object-reference-gate#p0 asked for two halves: a non-Java JavaError variant and a Result-propagating reachability walk. This round delivered the first (JavaError::Unraisable). jvm::garbage_collector::find_reachable_objects returns () and unwraps get_field/load/get_static_field, so a failure there is still a panic; Jvm::collect_garbage already returns Result, so the propagation point exists.", + "tradeoff": "Touches the GC path every allocation-heavy run goes through; needs a regression fixture that makes a field read fail during collection, which does not exist yet.", + "effort": "M", + "target": "jvm/src/garbage_collector.rs" + } + ] +} diff --git a/docs/worklog/2026-09-24-unraisable-error-variant.md b/docs/worklog/2026-09-24-unraisable-error-variant.md new file mode 100644 index 00000000..387baa81 --- /dev/null +++ b/docs/worklog/2026-09-24-unraisable-error-variant.md @@ -0,0 +1,36 @@ +# 2026-09-24 — `JavaError::Unraisable` : `Jvm::new` 는 `Err`, `Jvm::exception` 에는 바닥 + +티켓 `rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0` · 채택 +`2026-09-20-name-the-missing-bootstrap-class#p0` · `2026-09-20-string-array-hiding-overflows-stack#p0`. + +## 반증 먼저 (@origin/main `d5a1d2f0`) +- ⒜ `AGENTS.md:15` 「never panic in library code」 — 문면 그대로. +- ⒝ `JavaError` 단일 변종 · irrefutable `let JavaError::JavaException(..) = ..` **3곳** + (`jvm/src/jvm.rs` `` 래핑 · `jvm/tests/test_exception_construction.rs` · `rustjava-runtime/tests/.../test_throwable.rs`) — 수 일치. + 추가로 exhaustive `match` 2곳(`jvm-bytecode/src/interpreter.rs` · `.../logging/stream_handler.rs`)이 깨졌다. +- ⒞ 재귀: `Jvm::exception` 에 가드 0 — 순환은 닫혀 있다. + +## 결정 = ⒜ 비-Java 변종 추가 +- 근거: ⑴AGENTS.md 조항 ⑵`Jvm::new` 는 이미 `Result` 를 돌려준다 — 패닉은 서명과 어긋난다 ⑶하류 임베더 실재 — + wie `2026-09-22-lgt-object-reference-gate#p0` 이 같은 변종을 요청(타이틀 2건이 호스트를 죽였다) ⑷breaking 범위: + 이 repo 3곳 + match 2곳 · wie 는 `jvm` 을 crates.io 0.1.1 로 소비하므로 **판올림 때까지 깨지지 않는다**(wie irrefutable 다수 — 그때 치를 비용). +- 이름 `Unraisable(String)`: 「Java 예외로 만들 수 없다」는 **사실**을 이름으로 — 원인이 호스트(클래스 집합)든 런타임(재귀)이든 같다. +- `#[non_exhaustive]` 는 **안 붙였다** — 소비자에게 `_` 팔을 강제해 새 변종이 조용해진다. 다음 변종이 생기면 그때 판단. + +## 티켓과 다르게 한 것 — 「깊이 1」 가드는 틀렸다 (실측) +깊이 1로 넣자 `test_exception_reports_unloadable_class_instead_of_aborting` 이 red: +`jvm.exception("java/lang/NoSuchClassAnywhere", …)` 는 **정상적으로** 안쪽에서 NoClassDefFoundError 를 만든다(1단 중첩 = JVM 동작). +⇒ 규칙: **같은 스레드에서 «같은 예외(타입+메시지)»를 이미 만들고 있으면** 거부(= 순환) + 반복하지 않는 순환의 바닥으로 **깊이 8**. +정상 중첩은 2단이다. + +## 측정 +- `[Ljava/lang/String;` 숨김 cap 20: 종전 2 MiB 스택 overflow(rc 134) → `Unraisable`, loader 질문 **2회**, 메시지가 첫 실패 + `java/lang/NoClassDefFoundError ([Ljava/lang/String;)` 명명. +- 스윕: `37 candidate(s): 0 recursed · 12 refused by name · 0 refused anonymously · 25 failed cleanly · 0 not needed` — 종전과 동일 + (거부를 패닉이 아니라 `Unraisable` 메시지에서 읽게만 바꿨다). +- 변이(전건 원복 `cmp`): 가드 무력화 → `has overflowed its stack` · 부트스트랩 루프에 익명 패닉 복원 → `…bootstrap_class_is_an_error_naming_it` FAILED · + 변종 삭제 → `jvm` 컴파일 에러 8. +- `cargo test --all` **594 passed · 0 failed**(592 + 신규 2) · clippy stable/beta/wasm32 rc=0 · fmt rc=0 · python 5종 rc=0. + +## 후속 +- p0: GC 도달성 순회(`garbage_collector::find_reachable_objects`)의 `.unwrap()` 들을 `Result` 로 — wie 제안의 둘째 반. 변종이 생겼으니 이제 전파할 곳이 있다. diff --git a/jvm-bytecode/src/interpreter.rs b/jvm-bytecode/src/interpreter.rs index ce95ddf1..04252c28 100644 --- a/jvm-bytecode/src/interpreter.rs +++ b/jvm-bytecode/src/interpreter.rs @@ -60,6 +60,8 @@ impl Interpreter { return Err(JavaError::JavaException(e)); } } + // No instance, so no handler can match it: it goes to the host as it is. + Err(e @ JavaError::Unraisable(_)) => return Err(e), } } diff --git a/jvm/src/error.rs b/jvm/src/error.rs index b7a0a26f..0dda7ba9 100644 --- a/jvm/src/error.rs +++ b/jvm/src/error.rs @@ -1,6 +1,7 @@ use alloc::{ boxed::Box, fmt::{self, Display, Formatter}, + string::String, }; use crate::ClassInstance; @@ -8,12 +9,22 @@ use crate::ClassInstance; #[derive(Debug)] pub enum JavaError { JavaException(Box), + /// A failure that could not be raised as a Java exception, with a message saying what failed. + /// + /// A Java exception is an instance of a Java class, so it can only exist once the classes it is + /// made of are loaded and constructing it has not itself failed. This is what the runtime returns + /// when that is not the case: `Jvm::new` given a class set missing a class needed before anything + /// can be raised, or `Jvm::exception` failing again while it is still building an exception on the + /// same thread. Java code cannot catch it -- there is no instance to catch -- so it propagates to + /// the host unchanged. + Unraisable(String), } impl Display for JavaError { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { JavaError::JavaException(e) => write!(f, "Java exception: {e:?}"), + JavaError::Unraisable(message) => write!(f, "unraisable error: {message}"), } } } diff --git a/jvm/src/jvm.rs b/jvm/src/jvm.rs index e246dd23..62a7fc54 100644 --- a/jvm/src/jvm.rs +++ b/jvm/src/jvm.rs @@ -49,6 +49,9 @@ struct JvmInner { get_current_thread_id: Box u64 + Sync + Send>, bootstrap_class_loader: Box, bootstrapping: AtomicBool, + /// Per thread, the exceptions `Jvm::exception` is building right now, outermost first -- the floor + /// under its recursion. + raising: RwLock>>, } #[derive(Clone)] @@ -76,6 +79,7 @@ impl Jvm { get_current_thread_id: Box::new(get_current_thread_id), bootstrap_class_loader: Box::new(bootstrap_class_loader), bootstrapping: AtomicBool::new(true), + raising: RwLock::new(BTreeMap::new()), }), }; @@ -89,16 +93,17 @@ impl Jvm { "java/lang/Class", ]; for class_name in bootstrap_classes.iter() { - // Panics like the closure walk below, and for the same reason -- nothing can be *raised* - // yet, this is what loads the classes an exception is made of -- but it has to say which - // name it was, which `unwrap` did not: a host with a gap in its class set read + // Fails like the closure walk below, and for the same reason -- nothing can be *raised* + // yet, this is what loads the classes an exception is made of -- so the error is + // `Unraisable` rather than a Java exception. It has to say which name it was, which the + // `unwrap` this once was did not: a host with a gap in its class set read // `called Option::unwrap() on a None value` and had to bisect this list to find out // which of six. Measured by last round's sweep: 5 of the 12 named refusals were this // line, and two of those names (`java/lang/Object`, `java/io/Serializable`) are also in // the error path's closure, so the same gap got a good message or a useless one // depending only on which loop reached it first. let Some(class_definition) = jvm.inner.bootstrap_class_loader.load_class(&jvm, class_name).await? else { - panic!( + return Err(JavaError::Unraisable(format!( "the class set has no {class_name}, which is one of the {} classes loaded before \ anything else and before any error can be raised. Asked of the bootstrap class \ loader passed to `Jvm::new`, which is the host's own -- not the class path: \ @@ -106,7 +111,7 @@ impl Jvm { this same function and never runs if this fails. Add {class_name} to that \ loader's class set.", bootstrap_classes.len() - ) + ))); }; let class = Class::new(class_definition, None, None); @@ -161,7 +166,7 @@ impl Jvm { continue; } let Some(definition) = jvm.inner.bootstrap_class_loader.load_class(&jvm, &class_name).await? else { - panic!( + return Err(JavaError::Unraisable(format!( "the class set has no {class_name}, which the error path needs before anything can be \ raised. Every raised error is an exception instance carrying a String message, so \ building one resolves java/lang/String and java/lang/NoClassDefFoundError -- and with \ @@ -170,7 +175,7 @@ impl Jvm { and that recursion has no floor (measured: 117 round trips for String, 121 for \ NoClassDefFoundError, then the process aborts on a stack overflow). Add it to the \ class set." - ) + ))); }; pending.extend(definition.interface_names()); pending.extend(definition.super_class_name()); @@ -1036,9 +1041,51 @@ impl Jvm { /// What the caller gets is then the *real* failure rather than the requested one -- a /// NoClassDefFoundError for the missing class, or whatever the constructor threw -- which is how a /// JVM behaves when raising one exception runs into another. + /// + /// Building an exception runs Java code, and that code can fail in a way that is reported by + /// building another exception. One level of that is ordinary -- raising a class that cannot be + /// loaded raises NoClassDefFoundError from inside -- but it can also close into a cycle: hiding + /// `[Ljava/lang/String;` makes `fillInStackTrace` ask for it, which raises NoClassDefFoundError, + /// whose construction calls `fillInStackTrace`, ~57 stack frames a turn until the process aborts on + /// a stack overflow. So a thread that is already building the *same* exception, or is + /// `MAX_RAISING_DEPTH` deep, gets `Unraisable` instead, naming the exception the thread started + /// with -- the first failure, not the last. pub async fn exception(&self, r#type: &str, message: &str) -> JavaError { + // Only a floor for cycles that do not repeat themselves exactly; the ordinary nesting is 2. + const MAX_RAISING_DEPTH: usize = 8; + tracing::info!("throwing java exception: {} {message}", r#type); + let thread_id = (self.inner.get_current_thread_id)(); + let this = format!("{} ({message})", r#type); + { + let mut raising = self.inner.raising.write(); + let stack = raising.entry(thread_id).or_default(); + if stack.contains(&this) || stack.len() >= MAX_RAISING_DEPTH { + return JavaError::Unraisable(format!( + "raising {this} needed raising it again, {} level(s) into raising {}, which is the first failure", + stack.len(), + stack[0] + )); + } + stack.push(this); + } + // Popped on every exit, including this future being dropped mid-way: a stale entry would make + // a later exception on this thread unraisable. + struct Raising<'a>(&'a JvmInner, u64); + impl Drop for Raising<'_> { + fn drop(&mut self) { + let mut raising = self.0.raising.write(); + if let Some(stack) = raising.get_mut(&self.1) { + stack.pop(); + if stack.is_empty() { + raising.remove(&self.1); + } + } + } + } + let _raising = Raising(&self.inner, thread_id); + let message_str = match JavaLangString::from_rust_string(self, message).await { Ok(x) => x, Err(e) => return e, @@ -1170,7 +1217,10 @@ impl Jvm { if let Err(err) = self.execute_method(class, None, &clinit, Box::new([])).await { class.finish_initialization(InitState::Erroneous); - let JavaError::JavaException(exception) = &err; + // An unraisable error has no instance to wrap in ExceptionInInitializerError. + let JavaError::JavaException(exception) = &err else { + return Err(err); + }; if self.is_instance(&**exception, "java/lang/Error") { return Err(err); } diff --git a/jvm/tests/test_error_path_class_sweep.rs b/jvm/tests/test_error_path_class_sweep.rs index b3ce472d..54c16175 100644 --- a/jvm/tests/test_error_path_class_sweep.rs +++ b/jvm/tests/test_error_path_class_sweep.rs @@ -15,7 +15,8 @@ //! the error path later brings its own supertypes with it, and they land here without anyone //! thinking to ask. //! -//! The second axis is *what the refusal says*. `refused by name` used to mean only that construction +//! The second axis is *what the refusal says*. (A refusal was a panic until construction learned to +//! return `JavaError::Unraisable`; it is read from either, and the counts below are unchanged by it.) `refused by name` used to mean only that construction //! had panicked; five of the twelve panicked on `called Option::unwrap() on a None value` from the //! `bootstrap_classes` loop, which is a refusal a host cannot act on, and this sweep reported them //! among the good ones. The panic message is now read and matched against the hidden name, so a @@ -50,6 +51,7 @@ use std::{ sync::atomic::Ordering, }; +use jvm::JavaError; use test_utils::{test_jvm_hiding, test_jvm_recording}; /// Low on purpose. A name on the error path comes back for the cap's worth of questions and then the @@ -63,15 +65,15 @@ enum Outcome { /// that this class is missing. Without a check, the real loader never relents and the process /// aborts on a stack overflow. Recursed, - /// Construction refused *and the panic message contains the hidden name*. What the check - /// produces. The message is read rather than assumed: for five of the names below this outcome + /// Construction refused -- `Unraisable`, or a panic -- *and the message contains the hidden name*. + /// What the check produces. The message is read rather than assumed: for five of the names below this outcome /// used to be `called Option::unwrap() on a None value`, which is a refusal that tells the host /// nothing, and this arm counted it as a good one. A dead process and a read message are two /// facts, so they are measured separately. Panicked, /// Construction refused without saying which class. The defect this file no longer accepts. PanickedAnonymously, - /// Construction returned an error after a single question. Also fine: nothing looped. + /// Construction returned a Java exception after a single question. Also fine: nothing looped. Failed, /// Construction succeeded without the class. It is asked for but not needed. Built, @@ -95,15 +97,13 @@ fn hide(name: &str) -> Outcome { .map(String::as_str) .or_else(|| payload.downcast_ref::<&str>().copied()) .unwrap_or(""); - if message.contains(name) { - Outcome::Panicked - } else { - Outcome::PanickedAnonymously - } + named(message, name) } Ok((result, requests)) => { if requests.load(Ordering::SeqCst) > GIVE_UP_AFTER { Outcome::Recursed + } else if let Err(JavaError::Unraisable(message)) = &result { + named(message, name) } else if result.is_err() { Outcome::Failed } else { @@ -113,6 +113,14 @@ fn hide(name: &str) -> Outcome { } } +fn named(message: &str, name: &str) -> Outcome { + if message.contains(name) { + Outcome::Panicked + } else { + Outcome::PanickedAnonymously + } +} + // A plain `#[test]`, not `#[tokio::test]`: each candidate gets its own runtime inside `block_on`, // and nesting one runtime in another panics before any of this can run. #[test] diff --git a/jvm/tests/test_exception_construction.rs b/jvm/tests/test_exception_construction.rs index c13f9e36..edc06a06 100644 --- a/jvm/tests/test_exception_construction.rs +++ b/jvm/tests/test_exception_construction.rs @@ -18,7 +18,9 @@ async fn test_exception_reports_unloadable_class_instead_of_aborting() -> Result let unloadable = "java/lang/NoSuchClassAnywhere"; let error = jvm.exception(unloadable, "message").await; - let JavaError::JavaException(exception) = error; + let JavaError::JavaException(exception) = error else { + panic!("expected a Java exception, got {error:?}"); + }; assert!(jvm.is_instance(&*exception, "java/lang/NoClassDefFoundError")); let message = jvm diff --git a/jvm/tests/test_exception_fallback_recursion.rs b/jvm/tests/test_exception_fallback_recursion.rs index 743a9a5e..da048f81 100644 --- a/jvm/tests/test_exception_fallback_recursion.rs +++ b/jvm/tests/test_exception_fallback_recursion.rs @@ -1,19 +1,34 @@ +use std::sync::atomic::Ordering; + +use jvm::JavaError; use test_utils::test_jvm_hiding; +/// The message of the `Unraisable` construction returned, or a failure saying what came back instead. +async fn unraisable_hiding(hidden: &str, give_up_after: u32) -> (String, u32) { + let (result, requests) = test_jvm_hiding(hidden, give_up_after).await; + let asked = requests.load(Ordering::SeqCst); + match result { + Err(JavaError::Unraisable(message)) => (message, asked), + Err(JavaError::JavaException(e)) => panic!("hiding {hidden} raised a Java exception {e:?} after {asked} question(s)"), + Ok(_) => panic!("hiding {hidden} built a JVM after {asked} question(s)"), + } +} + // The cycle: `load_class` reports a class it cannot provide by calling // `Jvm::exception("java/lang/NoClassDefFoundError", …)`, building that exception goes back through // the loader, and if the loader cannot provide *that* class either the two call each other with no // floor. Measured on the form before this test existed, with the same harness: 121 round trips // survived and somewhere before 160 the process died of `stack overflow, aborting` (SIGABRT). // -// Reverting the check in `Jvm::new` does not merely fail this test -- it takes the test binary down -// with it, which is the point: an abort is what a host embedding this runtime used to get. +// `Jvm::new` checks the class and returns `Unraisable` naming it; it used to panic, which a host +// could not handle either. #[tokio::test] -#[should_panic(expected = "has no java/lang/NoClassDefFoundError")] async fn a_class_set_missing_the_reporter_fails_at_construction_rather_than_on_the_stack() { // 200 is the measuring device the harness offers, left high on purpose: the check under test // must fire on the *first* question, so any run that reaches the cap has already regressed. - let _ = test_jvm_hiding("java/lang/NoClassDefFoundError", 200).await; + let (message, asked) = unraisable_hiding("java/lang/NoClassDefFoundError", 200).await; + assert!(message.contains("has no java/lang/NoClassDefFoundError"), "{message}"); + assert_eq!(asked, 1); } // The same cycle reached through the other class on the error path. `Jvm::exception` builds its @@ -29,9 +44,33 @@ async fn a_class_set_missing_the_reporter_fails_at_construction_rather_than_on_t // boundary reproduced exactly across repeats. Raising the cap to 100000 aborts the same way, so the // cap is the harness relenting and not a floor. It recurses. #[tokio::test] -#[should_panic(expected = "has no java/lang/String")] async fn a_class_set_missing_string_fails_at_construction_rather_than_on_the_stack() { - // Above the measured 117 on purpose: reaching the cap at all means the check stopped firing on - // the first question, and past that number the run aborts instead of failing. - let _ = test_jvm_hiding("java/lang/String", 200).await; + let (message, asked) = unraisable_hiding("java/lang/String", 200).await; + assert!(message.contains("has no java/lang/String"), "{message}"); + assert_eq!(asked, 1); +} + +// A class loaded before anything else (`bootstrap_classes` in `Jvm::new`). Nothing can be raised yet, +// so this is `Unraisable` too -- and it has to name the class, or the host bisects its class set. +#[tokio::test] +async fn a_class_set_missing_a_bootstrap_class_is_an_error_naming_it() { + let (message, _) = unraisable_hiding("java/lang/Thread", 200).await; + assert!(message.contains("has no java/lang/Thread"), "{message}"); +} + +// The cycle that no class set can reach but nothing in the product ended: `fillInStackTrace` asks for +// `[Ljava/lang/String;`, a loader that cannot provide it raises NoClassDefFoundError, and building +// that calls `fillInStackTrace` again. Before `Jvm::exception` refused to build an exception it was +// already building on the same thread, this cap (the sweep's 20) overflowed the default 2 MiB test +// stack -- `stack overflow, aborting`, rc 134 -- and the run said nothing about the first failure. Now +// the repeat returns `Unraisable` naming the exception the thread started with, and the loader is +// asked twice: once for the first failure, once by the construction that repeated it. +#[tokio::test] +async fn a_failure_while_raising_is_reported_instead_of_recursing() { + let (message, asked) = unraisable_hiding("[Ljava/lang/String;", 20).await; + assert!( + message.contains("into raising java/lang/NoClassDefFoundError ([Ljava/lang/String;), which is the first failure"), + "{message}" + ); + assert_eq!(asked, 2); } diff --git a/rustjava-runtime/src/classes/java/util/logging/stream_handler.rs b/rustjava-runtime/src/classes/java/util/logging/stream_handler.rs index f5813591..d688d013 100644 --- a/rustjava-runtime/src/classes/java/util/logging/stream_handler.rs +++ b/rustjava-runtime/src/classes/java/util/logging/stream_handler.rs @@ -234,6 +234,7 @@ impl StreamHandler { .await?; return Ok(()); } + Err(e @ JavaError::Unraisable(_)) => return Err(e), }; let writer: ClassInstanceRef = jvm.get_field(&this, "writer", "Ljava/io/OutputStreamWriter;").await?; if let Err(JavaError::JavaException(exception)) = jvm diff --git a/rustjava-runtime/tests/classes/java/lang/test_throwable.rs b/rustjava-runtime/tests/classes/java/lang/test_throwable.rs index d7b34138..d7b0c816 100644 --- a/rustjava-runtime/tests/classes/java/lang/test_throwable.rs +++ b/rustjava-runtime/tests/classes/java/lang/test_throwable.rs @@ -46,7 +46,10 @@ async fn test_stacktrace() -> Result<()> { let url_string = JavaLangString::from_rust_string(&jvm, "invalid://invalid").await?; let url: Result> = jvm.new_class("java/net/URL", "(Ljava/lang/String;)V", (url_string,)).await; - let JavaError::JavaException(exception) = url.err().unwrap(); + let error = url.err().unwrap(); + let JavaError::JavaException(exception) = error else { + panic!("expected a Java exception, got {error:?}"); + }; let string_writer = jvm.new_class("java/io/StringWriter", "()V", ()).await?; let print_writer = jvm From 647e92887562e8b2949ea1c0d1ce69b6b177e8f3 Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 24 Sep 2026 02:34:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[rustjava-2026-09-20-name-the-missing-boots?= =?UTF-8?q?trap-class-adopt-p0-fix]=20fix(rustjava-runtime):=20StreamHandl?= =?UTF-8?q?er.publish=20=EA=B0=80=20Unraisable=20=EC=9D=84=20=EC=82=BC?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_head·OutputStreamWriter.write 두 곳의 if let Err(JavaException) 을 match 로 — Err(Unraisable) 이 Ok(()) 로 소실되던 것을 전파. 회귀 시험 1건. Co-Authored-By: Claude Opus 5.5 --- .../java/util/logging/stream_handler.rs | 66 +++++++++------- .../java/util/logging/test_handlers.rs | 76 ++++++++++++++++++- 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/rustjava-runtime/src/classes/java/util/logging/stream_handler.rs b/rustjava-runtime/src/classes/java/util/logging/stream_handler.rs index d688d013..75a3dd0a 100644 --- a/rustjava-runtime/src/classes/java/util/logging/stream_handler.rs +++ b/rustjava-runtime/src/classes/java/util/logging/stream_handler.rs @@ -188,22 +188,26 @@ impl StreamHandler { return Ok(()); } - if let Err(JavaError::JavaException(exception)) = Self::write_head(jvm, &this).await { - if !jvm.is_instance(&*exception, "java/lang/Exception") { - return Err(JavaError::JavaException(exception)); + match Self::write_head(jvm, &this).await { + Ok(()) => {} + Err(JavaError::JavaException(exception)) => { + if !jvm.is_instance(&*exception, "java/lang/Exception") { + return Err(JavaError::JavaException(exception)); + } + let message: ClassInstanceRef = None.into(); + let exception: ClassInstanceRef = exception.into(); + let _: () = jvm + .invoke_virtual( + &this, + "java/util/logging/StreamHandler", + "reportError", + "(Ljava/lang/String;Ljava/lang/Exception;I)V", + (message, exception, 1), + ) + .await?; + return Ok(()); } - let message: ClassInstanceRef = None.into(); - let exception: ClassInstanceRef = exception.into(); - let _: () = jvm - .invoke_virtual( - &this, - "java/util/logging/StreamHandler", - "reportError", - "(Ljava/lang/String;Ljava/lang/Exception;I)V", - (message, exception, 1), - ) - .await?; - return Ok(()); + Err(e @ JavaError::Unraisable(_)) => return Err(e), } let formatter: ClassInstanceRef = jvm.get_field(&this, "formatter", "Ljava/util/logging/Formatter;").await?; let formatted: ClassInstanceRef = match jvm @@ -237,24 +241,28 @@ impl StreamHandler { Err(e @ JavaError::Unraisable(_)) => return Err(e), }; let writer: ClassInstanceRef = jvm.get_field(&this, "writer", "Ljava/io/OutputStreamWriter;").await?; - if let Err(JavaError::JavaException(exception)) = jvm + match jvm .invoke_virtual::<_, ()>(&writer, "java/io/OutputStreamWriter", "write", "(Ljava/lang/String;)V", (formatted,)) .await { - if !jvm.is_instance(&*exception, "java/lang/Exception") { - return Err(JavaError::JavaException(exception)); + Ok(()) => {} + Err(JavaError::JavaException(exception)) => { + if !jvm.is_instance(&*exception, "java/lang/Exception") { + return Err(JavaError::JavaException(exception)); + } + let message: ClassInstanceRef = None.into(); + let exception: ClassInstanceRef = exception.into(); + let _: () = jvm + .invoke_virtual( + &this, + "java/util/logging/StreamHandler", + "reportError", + "(Ljava/lang/String;Ljava/lang/Exception;I)V", + (message, exception, 1), + ) + .await?; } - let message: ClassInstanceRef = None.into(); - let exception: ClassInstanceRef = exception.into(); - let _: () = jvm - .invoke_virtual( - &this, - "java/util/logging/StreamHandler", - "reportError", - "(Ljava/lang/String;Ljava/lang/Exception;I)V", - (message, exception, 1), - ) - .await?; + Err(e @ JavaError::Unraisable(_)) => return Err(e), } Ok(()) } diff --git a/rustjava-runtime/tests/classes/java/util/logging/test_handlers.rs b/rustjava-runtime/tests/classes/java/util/logging/test_handlers.rs index a7a33f9a..3a284764 100644 --- a/rustjava-runtime/tests/classes/java/util/logging/test_handlers.rs +++ b/rustjava-runtime/tests/classes/java/util/logging/test_handlers.rs @@ -1,6 +1,6 @@ use alloc::{boxed::Box, collections::btree_map::BTreeMap, vec}; -use jvm::{Array, ClassInstanceRef, Jvm, Result, runtime::JavaLangString}; +use jvm::{Array, ClassInstanceRef, JavaError, Jvm, Result, runtime::JavaLangString}; use jvm_bytecode::ClassDefinitionImpl; use jvm_class_proto::{JavaFieldProto, JavaMethodProto}; use jvm_types::{ClassAccessFlags, FieldAccessFlags, MethodAccessFlags}; @@ -72,6 +72,32 @@ impl FailingOutputStream { } } +struct UnraisableOutputStream; + +impl UnraisableOutputStream { + fn as_proto() -> RuntimeClassProto { + RuntimeClassProto { + name: "UnraisableLoggingOutputStream", + parent_class: Some("java/io/OutputStream"), + interfaces: vec![], + methods: vec![ + JavaMethodProto::new("", "()V", Self::init, MethodAccessFlags::PUBLIC), + JavaMethodProto::new("write", "(I)V", Self::write, MethodAccessFlags::PUBLIC), + ], + fields: vec![], + access_flags: ClassAccessFlags::PUBLIC, + } + } + + async fn init(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef) -> Result<()> { + jvm.invoke_special(&this, "java/io/OutputStream", "", "()V", ()).await + } + + async fn write(_: &Jvm, _: &mut RuntimeContext, _: ClassInstanceRef, _: i32) -> Result<()> { + Err(JavaError::Unraisable("output unraisable".into())) + } +} + async fn logging_jvm() -> Result { let runtime = TestRuntime::new(BTreeMap::new()); let jvm = create_test_jvm(runtime.clone()).await?; @@ -86,6 +112,14 @@ async fn logging_jvm() -> Result { jvm.register_class( Box::new(ClassDefinitionImpl::from_class_proto( FailingOutputStream::as_proto(), + Box::new(runtime.clone()) as Box<_>, + )), + None, + ) + .await?; + jvm.register_class( + Box::new(ClassDefinitionImpl::from_class_proto( + UnraisableOutputStream::as_proto(), Box::new(runtime) as Box<_>, )), None, @@ -225,6 +259,46 @@ async fn stream_handler_reports_output_failures_without_propagating_them() -> Re Ok(()) } +// `Unraisable` has no Java instance, so `reportError` cannot take it: publish has to hand it back +// rather than fall through to `Ok(())` as the `if let Err(JavaException)` form did. +#[tokio::test] +async fn stream_handler_propagates_unraisable_output_failures() -> Result<()> { + let jvm = logging_jvm().await?; + let output: ClassInstanceRef = jvm.new_class("UnraisableLoggingOutputStream", "()V", ()).await?.into(); + let formatter: ClassInstanceRef = jvm.new_class("java/util/logging/SimpleFormatter", "()V", ()).await?.into(); + let handler: ClassInstanceRef = jvm + .new_class( + "java/util/logging/StreamHandler", + "(Ljava/io/OutputStream;Ljava/util/logging/Formatter;)V", + (output, formatter), + ) + .await? + .into(); + let info: ClassInstanceRef = jvm + .get_static_field("java/util/logging/Level", "INFO", "Ljava/util/logging/Level;") + .await?; + let record: ClassInstanceRef = jvm + .new_class( + "java/util/logging/LogRecord", + "(Ljava/util/logging/Level;Ljava/lang/String;)V", + (info, JavaLangString::from_rust_string(&jvm, "message").await?), + ) + .await? + .into(); + + let result: Result<()> = jvm + .invoke_virtual( + &handler, + "java/util/logging/StreamHandler", + "publish", + "(Ljava/util/logging/LogRecord;)V", + (record,), + ) + .await; + assert!(matches!(&result, Err(JavaError::Unraisable(m)) if m == "output unraisable"), "{result:?}"); + Ok(()) +} + #[tokio::test] async fn stream_handler_applies_level_and_custom_filter_before_writing() -> Result<()> { let jvm = logging_jvm().await?;