diff --git a/REPORT.md b/REPORT.md index 8374e4b9..b03855a3 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,9 @@ # 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}`. +- 보정(-fix · 검수 반려): `StreamHandler::publish` 의 `if let Err(JavaException)` 2곳(헤드·본문 쓰기)이 `Unraisable` 을 `Ok(())` 로 삼키던 것을 `match` 로 전파 · 회귀 시험 1건. ## [2026-09-23] 클래스 파일 거부가 «어디서» 걸렸는지 말한다 — 검증 규칙 11개, 오류 변종은 1개 (rustjava-2026-09-18-bootstrap-argument-index-and-tag-adopt-p0) - 무엇을: `validate_class` 규칙을 전수 세어(14개 · 고정문장 13개) 표를 걸으며 멈춘 위치를 이미 쥔 **11개**가 그 위치를 싣게 했다 — `ClassFileError::InvalidFormatAt { cause, location }` 하나와 표 5종(`Location`)으로. - 왜: #73 이 부트스트랩 인자 규칙 하나를 구조화한 뒤 나머지가 몇이나 되는지 아무도 세지 않았다. 규칙마다 변종을 늘리면 `&'static str` 설계가 피하던 enum 비대가 오므로, 변종은 «규칙 수»가 아니라 «표 종류 수»로만 늘게 멈춤 기준을 먼저 세웠다. diff --git a/STATE.md b/STATE.md index 2a602022..820e3bc0 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,8 @@ (둘 다 이것보다 오래됐고 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`. + 보정(-fix): `stream_handler.rs` `publish` 의 `if let Err(JavaException)` 2곳 → `match` + `Unraisable` 전파(삼킴 제거) · 회귀 `stream_handler_propagates_unraisable_output_failures`. - [rustjava-2026-09-18-bootstrap-argument-index-and-tag-adopt-p0] ★**검증 규칙이 멈춘 위치를 말한다 — 11규칙, 변종 1개.** 채택 제안 `2026-09-18-bootstrap-argument-index-and-tag#p0`. ★**전제 반증(작게)**: `validate_class` 규칙은 15/14 가 아니라 **14 · 고정문장 13**(@origin/main `c654ae2e`). ★**전수**: 13 중 **11**이 표를 걸으며 멈춘 위치를 쥐고 있었다(pool 3 · field 3 · method 3 · interface 1 · class attribute 1) · `this_class`/`super_class` 2개는 가리킬 곳 없음 → `InvalidFormat` 유지. @@ -1525,7 +1527,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..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 @@ -234,26 +238,31 @@ 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 + 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/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 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?;