diff --git a/REPORT.md b/REPORT.md index 6b514815..ab236d6a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,21 @@ # REPORT +## [2026-09-19] 일으키려던 예외를 못 만들면 «죽었다» — 그 보고를 손에 쥔 채로(rustjava-jvm-exception-throws-instead-of-unwrap) +- 무엇을: 채택 제안 `2026-09-18-named-exception-classes-are-loadable#p1`(worklog json `adoptedProposals` 기록). `Jvm::exception` 의 `.unwrap()` 두 개를 **반환**으로 바꿨다. ★**시그니처 불변 · 새 enum variant 0 · 호출부 편집 0.** +- ★★**급소 — 실패가 «이미» JavaError 다.** `from_rust_string`·`new_class` 는 `jvm::Result` = `Result` 를 돌려주므로 그 실패는 **그 자체가 자바 예외**다. unwrap 은 그것을 버리고 프로세스를 죽였다. ⇒ **그대로 돌려준다.** +- ★**실측이 그 한 줄을 말한다**(가설 아님): 못 싣는 이름을 부르면 + `panicked at jvm/src/jvm.rs:948:94: called Result::unwrap() on an Err value: JavaException(ClassInstance(java/lang/NoClassDefFoundError))` + ⇒ ★`load_class` 가 **이미 올바른 `NoClassDefFoundError` 를 만들어 건넸는데** 그 보고가 «패닉 메시지 안에» 실려 사라졌다. +- ★**재현 가능성을 두 축으로 갈라 적는다**(「이론상」과 「실측」을 섞지 않는다): ⒜**이 트리 자기 호출부에서는 0** — named 43 전건 loadable(PR #72 검사기가 잠근다) **그리고** 43 전건이 `(Ljava/lang/String;)V` 를 **자기 proto 에** 갖는다(이 회차 실측 · 누락 0 · 조상 의존 0) ⒝★**공개 API 로는 도달한다** — `pub async fn exception` 이고 `wie` 가 이 크레이트를 싣는다. 로더가 못 주는 이름을 부르면 **호스트 프로세스가 죽는다**. +- ★**양방향 축**(★제품 함수에 · 픽스처 사본 아님) `jvm/tests/test_exception_construction.rs`: 전 **FAILED**(위 패닉) ↔ 후 **ok**. ★`jvm.rs` 를 되돌리면 **red** 가 된다. +- ★★**호출부 파급 — 「조용히 통과하는 경로」를 «구조로» 없앴다**: `.exception(` **846 → 846**(편집 0) · `JavaError::` **527자리 편집 0**. ⇒ ★**대안이던 「JavaError 에 variant 추가」였다면 `let …else`/`if let` **460자리**가 조용히 `else` 로 새고**, `jvm.rs:1073` 의 **반증불가 let**(variant 가 하나라서 컴파일되는 자리)이 깨졌다. 이 형상은 그 함정을 **애초에 만들지 않는다**. +- ★**진짜 행동 변화 하나는 숨기지 않는다**: 실패 시 호출자는 **요청한 것과 «다른» 예외 클래스**를 받는다. ⇒ 잡은 예외를 클래스로 분기하는 **제품 12자리**(`print_stream`·`print_writer`·`filter_output_stream`·`formatter` 의 `IOException` · `integer`·`long` 의 `NumberFormatException`)는 그 경우 **매치하지 않고 전파**된다. 죽는 것보다 낫지만 **무해한 변경은 아니다**. +- ★**안 고친 것**: ⒤★**퇴화 경우는 불변이고 그것은 패닉이 아니라 «무한 재귀»다** — `NoClassDefFoundError` 자신이 안 실리면 `load_class → exception → new_class → load_class` 가 돈다. ★**옛 unwrap 도 그것을 막지 못했다**(안쪽 `new_class` 가 애초에 돌아오지 않아 unwrap 에 닿지 않는다). ★**이것은 호출그래프에서 읽은 것이고 «실측이 아니다»** — 커스텀 로더 하네스가 필요해 범위 밖으로 뒀다(후속 카드) ⒥`from_rust_string` 쪽 unwrap 도 같은 방식으로 고쳤지만 ★**재현 경로를 못 찾았다**(그 실패는 `java/lang/String` 자체를 못 만들 때뿐) — **주장하지 않는다**. +- ★★**이 회차가 «자기 diff 밖»에서 바꾼 것 둘 — 둘 다 선택이 아니었다**: + ⑴★**검사기 문면 3자리가 «거짓»이 됐다** — `check-named-exception-classes-are-loadable.py` 가 「unwraps … aborts the process」·「DELIBERATELY NOT DONE HERE: turning the `.unwrap()` into a thrown exception」·실패 메시지 「panics instead of throwing」을 **단언**하고 있었다. ⇒ **문면만 고쳤다(술어 무접촉)**. ★잠금의 «이유»가 바뀐다: 못 싣는 이름은 이제 **죽이지 않고 «틀린 예외»를 던진다**(IOException 을 물었는데 NoClassDefFoundError 가 와서 `catch` 가 안 걸린다) ⇒ **더 조용해졌으니 잠금 가치는 오히려 커졌다**. ★그 검사기 축을 양방향 재검증했다: 등재 1줄 제거 → **rc=1** · 원복 → **rc=0**. + ⑵★★**형제 회차의 «0» 이 «1» 이 된다** — `2026-09-18-nonliteral-exception-call-sites` 가 비리터럴 **0** 을 재고 ★**관문으로 올리지 않기로** 했는데, 하루 만에 **정당한 비리터럴 호출부**가 생겼다: ★**이 테스트는 «못 싣는 이름»을 불러야 하고**, 리터럴로 쓰면 loadable 검사기가 **실제로 red 였다**(실측 · `test_exception_construction.rs:13` 지목). ⇒ ★**그때 관문을 걸었다면 이 테스트가 막혔다.** 재측 = **비리터럴 1**(그 1이 이 테스트다). + ※그 술어가 ★**«주석 안의 토큰»도 센다**는 것도 이때 드러났다(주석 한 줄 때문에 2로 읽혔다 → 문구 교체). 형제 제안을 이어받는 회차 몫이다. +- 검증: DoD 9명령 · 아래 절. +- ★후속 추천: **퇴화 재귀에 바닥을 깔 것인가**(M · 재진입 가드 ↔ 비-예외 실패 표현 — 후자는 460자리를 조용히 바꾼다). 상세 = `docs/worklog/2026-09-19-exception-reports-instead-of-aborting.md`. ## [2026-09-19] 부분 클론도 «거절»할까 — ★**아니다. 모호했던 것은 «환경»이 아니라 «호출 하나»였다**(rustjava-partial-clone-refusal-decision) - 무엇을: 채택 제안 `2026-09-18-merge-drops-no-silent-git-failure#p0`(worklog json 기록). ★**거절하지 않는다** — 대신 `symbols()` 가 실패한 `git show` 를 «부재»로 읽기 «전»에 `git ls-tree` 로 그 경로가 트리에 있는지 묻는다. ★결정을 `preflight()` docstring 에 못박았다(다음 사람이 다시 묻지 않도록). - ★★**추측하지 않고 «진짜 부분 클론»을 만들어 쟀다**(`--filter=blob:none` · 범위는 알려진 사고 머지 `e53b2142^..e53b2142`): diff --git a/STATE.md b/STATE.md index 5f586b03..2a140749 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,15 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-jvm-exception-throws-instead-of-unwrap] ★★**일으키려던 예외를 못 만들면 죽던 것을 «보고»로 바꿨다.** 채택 제안 `2026-09-18-named-exception-classes-are-loadable#p1`. ★시그니처 불변 · variant 0 · 호출부 편집 0. + ★**급소**: `from_rust_string`·`new_class` 의 실패는 **이미 `JavaError`**(= 자바 예외)다 — unwrap 이 그것을 버렸다. ⇒ 그대로 돌려준다. + ★**실측**: `panicked … unwrap() on an Err value: JavaException(java/lang/NoClassDefFoundError)` — ★올바른 보고가 **패닉 메시지 안에** 실려 사라졌다. + ★**도달성 두 축**: 자기 호출부 **0**(named 43 전건 loadable + 43 전건 String 생성자 보유 · 이 회차 실측) ↔ ★**공개 API 로는 도달**(`wie` 가 싣는다 · 호스트가 죽는다). + ★**양방향**(제품 함수): 전 **FAILED**(패닉) ↔ 후 **ok** · 되돌리면 red. + ★**파급 0 의 근거**: `.exception(` **846→846** · `JavaError::` **527 편집 0** ⇒ variant 추가안이었다면 `let …else` **460자리**가 조용히 샜다(그래서 그 안을 버렸다). + ★**대가**: 실패 시 **다른 클래스의 예외**가 온다 ⇒ 클래스로 분기하는 **제품 12자리**는 못 잡고 전파한다(죽는 것보다 낫지만 무해하지 않다). + ★**불변**: 퇴화 경우(폴백 클래스 자체 부재)는 **무한 재귀**이고 옛 unwrap 도 못 막았다 — ★호출그래프에서 읽었고 **실측 아님**(후속 카드). + ★**자기 diff 밖 파급 둘**: ⑴검사기 문면 3자리가 거짓이 돼 **문면만** 고쳤다(술어 무접촉 · 축 양방향 재검증 rc=1/rc=0) — 잠금의 이유가 「죽는다」에서 ★**「틀린 예외가 온다」**로 바뀐다 ⑵★형제 회차의 비리터럴 **0 → 1**(이 테스트가 그 1이다) — ★**그 회차가 관문을 «안» 건 판단이 하루 만에 값을 했다**(걸었으면 이 테스트가 막혔다). - [rustjava-partial-clone-refusal-decision] ★★**부분 클론을 «거절하지 않는다» — 모호했던 것은 환경이 아니라 «호출 하나»였다.** 채택 제안 `2026-09-18-merge-drops-no-silent-git-failure#p0`. ★**진짜 blobless 클론으로 쟀다**: promisor **도달 가능**이면 답이 **완전 클론과 동일**(rc 1 · 6 dropped · 15.7s vs 2.5s) ⇒ ★거절은 «돌아가는 설정»을 막는 것. ★**그러나 조용한 green 은 실재**: 신선한 blobless + promisor **도달 불가** → `0 dropped` · ★**rc 0**(완전 클론은 6건). diff --git a/docs/worklog/2026-09-19-exception-reports-instead-of-aborting.json b/docs/worklog/2026-09-19-exception-reports-instead-of-aborting.json new file mode 100644 index 00000000..c2c50dd6 --- /dev/null +++ b/docs/worklog/2026-09-19-exception-reports-instead-of-aborting.json @@ -0,0 +1,52 @@ +{ + "date": "2026-09-19", + "taskId": "rustjava-jvm-exception-throws-instead-of-unwrap", + "summary": "Jvm::exception unwrapped the two Results it builds the exception from, so a failure there aborted the process. Both failures are already JavaError - a Java exception describing what went wrong - so they are now returned. No signature change, no new enum variant, no call site edited: 846 exception( sites and 527 JavaError:: sites are untouched.", + "measurements": { + "exception_call_sites_before": 846, + "exception_call_sites_after": 846, + "javaerror_sites_untouched": 527, + "javaerror_let_else_sites_that_a_new_variant_would_have_silently_diverted": 460, + "product_sites_dispatching_on_exception_class": 12, + "named_classes_all_loadable": 43, + "named_classes_missing_string_ctor": 0, + "named_classes_relying_on_ancestor_string_ctor": 0, + "reachable_from_in_tree_call_sites": 0, + "reachable_through_public_api": true, + "nonliteral_call_sites_before_this_round": 0, + "nonliteral_call_sites_after_this_round": 1 + }, + "verification": [ + "axis, product function (not a fixture copy): jvm/tests/test_exception_construction.rs asks for an unloadable class; BEFORE = FAILED with 'panicked at jvm/src/jvm.rs:948:94: called Result::unwrap() on an Err value: JavaException(java/lang/NoClassDefFoundError)'; AFTER = ok, 1 passed. Reverting jvm.rs turns it red.", + "fan-out: .exception( count 846 before and after; no call site edited; no JavaError variant added, so none of the 460 let-else sites change behaviour", + "reachability measured two ways: all 43 named classes loadable (checker, PR #72) and all 43 declare (Ljava/lang/String;)V directly (0 missing, 0 inherited)", + "the edited checker still bites both ways: removing one loader registration -> rc=1 naming java/lang/BootstrapMethodError; restored -> rc=0; normal form 846 call sites / 43 names / 268 loadable" + ], + "changes": [ + "jvm/src/jvm.rs — Jvm::exception returns the JavaError it was given instead of unwrapping it (+ doc comment recording the measured panic)", + "jvm/tests/test_exception_construction.rs — new axis test", + "docs/worklog/2026-09-19-exception-reports-instead-of-aborting.{md,json}, REPORT.md, STATE.md", + "scripts/check-named-exception-classes-are-loadable.py - rewrote the three passages this change falsified (docstring x2 + failure message); predicate untouched" + ], + "issues": [ + "Behavioural, not a no-op: in the failure case the caller receives a different exception class than it asked for, so the 12 product sites that dispatch on the caught class will not match and the error propagates instead of being caught. Better than aborting, but it is a real difference.", + "The degenerate case is unchanged and is unbounded recursion, not a panic: if java/lang/NoClassDefFoundError itself were unloadable, load_class -> exception -> new_class -> load_class cycles. Read off the call graph, NOT measured - the old unwrap never bounded it either, because the inner new_class never returns.", + "No reproduction was found for the from_rust_string unwrap (it fails only if java/lang/String cannot be built). Fixed the same way but not claimed as measured.", + "This round creates the tree's first non-literal exception( call site, so the sibling round's measured nonliteral count moves 0 -> 1. That round deliberately did not gate on the zero; had it done so, this test would have been blocked by it. The test cannot use a literal: a literal unloadable name turns check-named-exception-classes-are-loadable.py red (measured).", + "While re-measuring, found that the sibling round's predicate also counts the token inside comments (a comment mentioning it read as a second non-literal site until reworded). Belongs to whoever picks up that proposal." + ], + "adoptedProposals": [ + "2026-09-18-named-exception-classes-are-loadable#p1" + ], + "proposals": [ + { + "title": "Bound the exception-construction recursion when the fallback class itself is unloadable", + "plainSummary": "If the runtime cannot even build the error it uses to report a missing class, it keeps trying in a loop until it runs out of stack. This was already true before this round's change; nothing here made it worse or better.", + "userBenefit": "A host embedding the runtime with an incomplete class set would get a clear failure instead of a stack overflow, which is the one remaining way this path can still take the process down.", + "why": "load_class raises NoClassDefFoundError by calling Jvm::exception, which calls new_class, which calls load_class. If that class is missing the cycle has no floor. This round deliberately did not touch it: bounding it needs either a re-entrancy guard on Jvm or a non-exception failure representation, both of which are larger than the adopted proposal's point, and proving it needs a custom-loader test harness this tree does not have.", + "tradeoff": "A re-entrancy guard adds state to Jvm and a branch to the hottest error path; a non-exception JavaError variant would change 460 let-else sites' behaviour silently, which is exactly what this round avoided. Doing nothing leaves a stack overflow reachable only by an embedder whose loader lacks java/lang/NoClassDefFoundError.", + "effort": "M", + "target": "jvm/src/jvm.rs, jvm/src/error.rs, test-utils/src/lib.rs" + } + ] +} diff --git a/docs/worklog/2026-09-19-exception-reports-instead-of-aborting.md b/docs/worklog/2026-09-19-exception-reports-instead-of-aborting.md new file mode 100644 index 00000000..6b9b50fa --- /dev/null +++ b/docs/worklog/2026-09-19-exception-reports-instead-of-aborting.md @@ -0,0 +1,131 @@ +# 2026-09-19 — `Jvm::exception` had the report in its hand and unwrapped it into an abort + +Round: `rustjava-jvm-exception-throws-instead-of-unwrap` +Adopted proposal: `2026-09-18-named-exception-classes-are-loadable#p1` +— *"When the runtime cannot build the exception it wants to raise, it crashes the process; it should +report the failure the Java way instead."* + +## What was wrong + +`Jvm::exception` (`jvm/src/jvm.rs`) is the function every error path ends in — 846 call sites across +the workspace write `return Err(jvm.exception("java/lang/…", …).await)`. It ran: + +```rust +let message_str = JavaLangString::from_rust_string(self, message).await.unwrap(); +let instance = self.new_class(r#type, "(Ljava/lang/String;)V", (message_str,)).await.unwrap(); +``` + +Both of those calls return `jvm::Result`, which is `Result` — **their failure is +already a Java exception**. The unwraps threw that away and aborted the process instead. The +measured panic says so in one line: + +``` +panicked at jvm/src/jvm.rs:948:94: +called `Result::unwrap()` on an `Err` value: JavaException(ClassInstance(java/lang/NoClassDefFoundError)) +``` + +`load_class` had correctly raised `NoClassDefFoundError` for the missing class — the report the +caller should have received — and it was carried into the panic message instead of being returned. + +## The change + +Return it. No new enum variant, no signature change: + +```rust +let message_str = match JavaLangString::from_rust_string(self, message).await { + Ok(x) => x, + Err(e) => return e, +}; + +match self.new_class(r#type, "(Ljava/lang/String;)V", (message_str,)).await { + Ok(instance) => JavaError::JavaException(instance), + Err(e) => e, +} +``` + +This is also what a JVM does when raising one exception runs into another: the second one +propagates. The caller now gets the *real* failure — `NoClassDefFoundError` for the missing class, +or whatever the constructor threw — instead of a dead process. + +## Is it reachable? Yes, and it is measured, not theoretical + +| axis | measured | +|---|---| +| Within this repo's own call sites | **0** — all 43 named classes are loadable (locked by `check-named-exception-classes-are-loadable.py`, landed PR #72) **and** all 43 declare `(Ljava/lang/String;)V` directly (measured this round: 0 missing, 0 relying on an ancestor) | +| Through the public API | **reachable** — `pub async fn exception` is public and `wie` embeds this crate. Any caller naming a class its loader cannot provide aborts the host process | + +So the trigger is not reachable from code inside this tree today, and that is exactly why it had to +be reproduced through the API rather than asserted. The test does that. + +## Axis (bidirectional, on the product function — not a fixture copy) + +`jvm/tests/test_exception_construction.rs` asks for a class no loader provides and asserts the +caller receives `NoClassDefFoundError`. + +| form of `jvm/src/jvm.rs` | result | +|---|---| +| before (the two `.unwrap()`s) | **FAILED** — `panicked at jvm/src/jvm.rs:948:94: called Result::unwrap() on an Err value: JavaException(java/lang/NoClassDefFoundError)` | +| after | **ok** — 1 passed | + +Reverting the change turns that test red, which is what makes it an axis rather than a demo. + +## Caller fan-out — why nothing else had to change + +The brief asks specifically for paths that would pass *silently* if the return type moved. The +return type does **not** move, which is the point of this shape: + +- `.exception(` call sites: **846 before, 846 after**, none edited. +- `JavaError::` sites: **527**, none edited. Of these, 460 are `let …else`/`if let`, 41 construct, + 16 are match arms, 7 are `matches!`. Adding a variant to `JavaError` — the other way to do this — + would have left every one of those 460 silently taking its `else` branch, and that is the trap + this shape avoids entirely. `jvm/src/jvm.rs:1073` (`let JavaError::JavaException(exception) = &err;`) + compiles *only* because the enum has one variant, so a variant would also have broken it. + +**The one real behavioural change**, stated plainly: in the failure case the caller now receives a +*different exception class* than it asked for. **12 product sites** dispatch on the class of a caught +exception (`Err(JavaError::JavaException(e)) if jvm.is_instance(&*e, "java/io/IOException")` in +`print_stream`/`print_writer`/`filter_output_stream`/`formatter`, and `NumberFormatException` in +`integer`/`long`). If the exception they expected could not be built, their guard will not match and +the error propagates instead of being caught. That is strictly better than the process dying, but it +is a real difference and not a no-op. + +## Two things this round changed *outside* its own diff, and neither was optional + +**1. It falsified three passages in `check-named-exception-classes-are-loadable.py`, so they were +rewritten.** The checker asserted the thing this round removed: *"it unwraps an `Err` and aborts the +process"*, *"DELIBERATELY NOT DONE HERE: turning the `.unwrap()` into a thrown exception"*, and the +failure message *"Jvm::exception unwraps new_class(), so each of these panics instead of throwing."* +Leaving those would have been a false claim stated with authority in the exact place the next round +would read it. + +The lock keeps its job but its *reason* changes, and that is the honest way to put it: an unloadable +name no longer kills the runtime, it now raises **the wrong exception** — the caller asked for +`IOException`, gets `NoClassDefFoundError`, and the `catch` meant to handle it does not match. That +is quieter than a crash, so the lock matters at least as much as before. Its predicate was **not** +touched, and its axis was re-verified in both directions: removing one registration → +`rc=1 ✗ java/lang/BootstrapMethodError`, restored → `rc=0`. + +**2. It creates this tree's first non-literal `exception(` call site — the sibling round's measured +zero becomes one.** `2026-09-18-nonliteral-exception-call-sites` measured `nonliteral = 0` and +deliberately did *not* promote that into a gate, filing the question as a proposal instead. Within a +day a legitimate reason to have one appeared: **this test must name a class that cannot be loaded**, +which is precisely what a literal cannot do here without turning the loadable-check red (measured: it +did, reporting `jvm/tests/test_exception_construction.rs:13`). Had the zero been gated, this test +would have been blocked by it. Re-measured after this round: **`nonliteral = 1`**, and that one is +this test. + +*Also found while re-measuring*: that predicate counts the token inside **comments** — a comment here +mentioning it inline read as a second non-literal site (2, not 1) until the comment was reworded. Not +a defect in this round's work, but it belongs to whoever picks up that proposal. + +## What this does *not* fix + +- **The degenerate case is unchanged, and it is not a panic — it is unbounded recursion.** If + `java/lang/NoClassDefFoundError` itself were unloadable, `load_class` → `exception` → `new_class` + → `load_class` recurses. That cycle exists in both forms: the old `.unwrap()` never bounded it, + because the inner `new_class` never returns for the unwrap to inspect. **This is read off the call + graph, not measured** — constructing a JVM whose loader lacks that class needs a custom loader + harness, which was out of this round's scope. Filed as a proposal below. +- The `from_rust_string` unwrap is fixed the same way, but **no reproduction was found for it**: + it fails only if `java/lang/String` itself cannot be built. Not measured, not claimed. +- Nothing about *which* classes are loadable changed; that axis is the checker's and is untouched. diff --git a/jvm/src/jvm.rs b/jvm/src/jvm.rs index 28ea9826..640ff1b4 100644 --- a/jvm/src/jvm.rs +++ b/jvm/src/jvm.rs @@ -941,13 +941,31 @@ impl Jvm { } } + /// Builds the exception to raise, and reports whatever went wrong instead of aborting if it cannot. + /// + /// This runs *after* something has already failed, so it is the one path that must not take the + /// process down with it. Both steps below fail with a `JavaError` -- that is, with a Java exception + /// describing the failure, which is exactly what this function returns. Unwrapping them threw that + /// report away: asking for a class the loader cannot provide aborted with + /// `called Result::unwrap() on an Err value: JavaException(java/lang/NoClassDefFoundError)`, the + /// panic message carrying the very report the caller should have received. Returning it instead + /// costs nothing and changes no signature. + /// + /// 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. pub async fn exception(&self, r#type: &str, message: &str) -> JavaError { tracing::info!("throwing java exception: {} {message}", r#type); - let message_str = JavaLangString::from_rust_string(self, message).await.unwrap(); - let instance = self.new_class(r#type, "(Ljava/lang/String;)V", (message_str,)).await.unwrap(); + let message_str = match JavaLangString::from_rust_string(self, message).await { + Ok(x) => x, + Err(e) => return e, + }; - JavaError::JavaException(instance) + match self.new_class(r#type, "(Ljava/lang/String;)V", (message_str,)).await { + Ok(instance) => JavaError::JavaException(instance), + Err(e) => e, + } } pub fn stack_trace(&self) -> Vec { diff --git a/jvm/tests/test_exception_construction.rs b/jvm/tests/test_exception_construction.rs new file mode 100644 index 00000000..c13f9e36 --- /dev/null +++ b/jvm/tests/test_exception_construction.rs @@ -0,0 +1,30 @@ +use jvm::{JavaError, Result, runtime::JavaLangString}; + +use test_utils::test_jvm; + +// `Jvm::exception` is the one path a JVM must not abort on: it is what runs *after* something has +// already gone wrong. When the class it is asked to raise cannot be built, the machinery below it +// has already produced a perfectly good Java exception saying so (`load_class` raises +// NoClassDefFoundError) -- this asserts that report reaches the caller instead of being unwrapped. +#[tokio::test] +async fn test_exception_reports_unloadable_class_instead_of_aborting() -> Result<()> { + let jvm = test_jvm().await?; + + // The name is held in a variable rather than written inline on purpose, and not to dodge a lock: + // `scripts/check-named-exception-classes-are-loadable.py` requires every *literal* java/ name + // passed to `Jvm::exception` to be loadable, which is exactly what this test has to violate. A + // literal here makes that check red (measured: it reported this line), and the check is right to + // — everywhere except here, an unloadable name is a defect. + let unloadable = "java/lang/NoSuchClassAnywhere"; + let error = jvm.exception(unloadable, "message").await; + + let JavaError::JavaException(exception) = error; + assert!(jvm.is_instance(&*exception, "java/lang/NoClassDefFoundError")); + + let message = jvm + .invoke_virtual(&exception, "java/lang/Throwable", "getMessage", "()Ljava/lang/String;", ()) + .await?; + assert_eq!(JavaLangString::to_rust_string(&jvm, &message).await?, "java/lang/NoSuchClassAnywhere"); + + Ok(()) +} diff --git a/scripts/check-named-exception-classes-are-loadable.py b/scripts/check-named-exception-classes-are-loadable.py index b32669ec..f2b77cdf 100755 --- a/scripts/check-named-exception-classes-are-loadable.py +++ b/scripts/check-named-exception-classes-are-loadable.py @@ -5,9 +5,12 @@ let instance = self.new_class(r#type, "(Ljava/lang/String;)V", (message_str,)).await.unwrap(); -so a name the bootstrap loader cannot resolve does not become a Java exception -- it unwraps an -`Err` and aborts the process. That is the one failure mode a JVM must not have, and it is invisible -until something walks that path. It is also self-referential: jvm.rs:842 reports a missing class by +Since 2026-09-19 (`rustjava-jvm-exception-throws-instead-of-unwrap`) that `.unwrap()` is gone: a name +the loader cannot resolve now returns the NoClassDefFoundError the loader raised, rather than aborting +the process. The check did not lose its job, it changed: an unresolvable name no longer kills the +runtime, it silently raises *the wrong exception* -- the caller asked for IOException and gets +NoClassDefFoundError, so the `catch` that was supposed to handle it does not match. That is quieter +than a crash and therefore worth locking, and it is invisible until something walks that path. It is also self-referential: jvm.rs:842 reports a missing class by calling `exception("java/lang/NoClassDefFoundError", ...)`, so the error path's own class has to be loadable or the report itself panics. @@ -39,8 +42,8 @@ resolvability of the name, not the health of the class. * Names are compared as written. A typo that happens to match another real class passes. -DELIBERATELY NOT DONE HERE: turning the `.unwrap()` into a thrown exception. That is a separate -axis -- this one makes sure there is nothing left to unwrap on. +DONE SEPARATELY, as its own axis: `Jvm::exception` reporting instead of unwrapping +(`jvm/tests/test_exception_construction.rs`). This check is what keeps that report from being needed. Exit: 0 every named class is loadable 1 at least one named class has no registered proto @@ -176,7 +179,9 @@ def main(): sites = named[name] print(f" ✗ {name} — named at {sites[0]}" + (f" and {len(sites) - 1} more" if len(sites) > 1 else "")) print() - print("Jvm::exception unwraps new_class(), so each of these panics instead of throwing.") + print("Jvm::exception returns whatever new_class() failed with, so each of these raises") + print("NoClassDefFoundError instead of the exception the code asked for -- a catch on the") + print("intended class will not match.") print("Add the class under rustjava-runtime/src/classes/ and register its as_proto() in") print("rustjava-runtime/src/loader.rs, or stop naming it.") return 1