Skip to content

Commit 5cc461f

Browse files
authored
[rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0] feat(jvm)!: JavaError::Unraisable — Jvm::new 는 패닉 대신 Err, Jvm::exception 재귀에 바닥 (#94) [rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0-fix]
[rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0] feat(jvm)!: JavaError::Unraisable — Jvm::new 는 패닉 대신 Err, Jvm::exception 재귀에 바닥
2 parents f43f7b1 + c759bfd commit 5cc461f

13 files changed

Lines changed: 320 additions & 58 deletions

File tree

‎REPORT.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
# REPORT
2+
## [2026-09-24] `JavaError` 에 «Java 예외로 만들 수 없는 실패»를 뒀다 — `Jvm::new` 는 패닉 대신 `Err`, 예외 생성의 재귀에는 바닥 (rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0)
3+
- 무엇을: `JavaError::Unraisable(String)` 변종 추가. `Jvm::new` 의 패닉 2곳(부트스트랩 클래스·오류 경로 closure)이 빠진 클래스 이름을 담은 `Err` 를 돌려준다. `Jvm::exception` 은 같은 스레드에서 이미 만들고 있는 예외를 다시 만들려 하면(또는 깊이 8) `Unraisable` 로 첫 실패를 명명한다.
4+
- 왜: 채택 제안 2건(`…name-the-missing-bootstrap-class#p0` · `…string-array-hiding-overflows-stack#p0`)이 같은 장애물 — 단일 변종 `JavaError` — 에 닿았고, 하류 임베더 wie 가 같은 변종을 요청했다(타이틀 2건이 호스트를 죽였다). AGENTS.md 「라이브러리 코드는 패닉하지 않는다」.
5+
- 사용자 영향: 불완전한 클래스 집합을 받은 호스트가 프로세스 abort 대신 처리 가능한 오류를 받는다. ★공개 enum 확장이라 외부 소비자의 irrefutable 구조분해는 깨진다. 후속 추천 1건(GC 순회 `Result` 전파) — `docs/worklog/2026-09-24-unraisable-error-variant.{md,json}`.
6+
- 보정(-fix · 검수 반려): `StreamHandler::publish` 의 `if let Err(JavaException)` 2곳(헤드·본문 쓰기)이 `Unraisable` 을 `Ok(())` 로 삼키던 것을 `match` 로 전파 · 회귀 시험 1건.
27
## [2026-09-23] 클래스 파일 거부가 «어디서» 걸렸는지 말한다 — 검증 규칙 11개, 오류 변종은 1개 (rustjava-2026-09-18-bootstrap-argument-index-and-tag-adopt-p0)
38
- 무엇을: `validate_class` 규칙을 전수 세어(14개 · 고정문장 13개) 표를 걸으며 멈춘 위치를 이미 쥔 **11개**가 그 위치를 싣게 했다 — `ClassFileError::InvalidFormatAt { cause, location }` 하나와 표 5종(`Location`)으로.
49
- 왜: #73 이 부트스트랩 인자 규칙 하나를 구조화한 뒤 나머지가 몇이나 되는지 아무도 세지 않았다. 규칙마다 변종을 늘리면 `&'static str` 설계가 피하던 enum 비대가 오므로, 변종은 «규칙 수»가 아니라 «표 종류 수»로만 늘게 멈춤 기준을 먼저 세웠다.

‎STATE.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
(둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다)
88

99
## 완료
10+
- [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`.
11+
보정(-fix): `stream_handler.rs` `publish` 의 `if let Err(JavaException)` 2곳 → `match` + `Unraisable` 전파(삼킴 제거) · 회귀 `stream_handler_propagates_unraisable_output_failures`.
1012
- [rustjava-2026-09-18-bootstrap-argument-index-and-tag-adopt-p0] ★**검증 규칙이 멈춘 위치를 말한다 — 11규칙, 변종 1개.** 채택 제안 `2026-09-18-bootstrap-argument-index-and-tag#p0`.
1113
★**전제 반증(작게)**: `validate_class` 규칙은 15/14 가 아니라 **14 · 고정문장 13**(@origin/main `c654ae2e`).
1214
★**전수**: 13 중 **11**이 표를 걸으며 멈춘 위치를 쥐고 있었다(pool 3 · field 3 · method 3 · interface 1 · class attribute 1) · `this_class`/`super_class` 2개는 가리킬 곳 없음 → `InvalidFormat` 유지.
@@ -1525,7 +1527,7 @@
15251527
★**카드 «열림»은 tower 술어(`… − injected`)로 세지 마라** — 이 레인은 그 술어로 **0**이다(주입 = 발권 요청일 뿐 착지가 아니다). 여기 술어는 `전체 − adopted − declined` 다.
15261528

15271529
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`.
1528-
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`.
1530+
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`.
15291531
3. **카드 밖**: PR **#81** — `CONFLICTING`(2026-09-23 워밍 후 재조회) · 충돌 해소 선행.
15301532

15311533
---- 이하 ⓪(2026-09-23 전수 재측 판)·①~⑤ 는 사료다. ★**「다음」으로 읽지 마라** ----
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"schema": "rustjava-worklog-v1",
3+
"date": "2026-09-24",
4+
"taskId": "rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0",
5+
"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.",
6+
"adoptedProposals": [
7+
"2026-09-20-name-the-missing-bootstrap-class#p0",
8+
"2026-09-20-string-array-hiding-overflows-stack#p0"
9+
],
10+
"proposals": [
11+
{
12+
"title": "Let the GC reachability walk return errors instead of unwrapping them",
13+
"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.",
14+
"userBenefit": "An embedder (wie) whose guest hands over a broken object gets an error for that program instead of losing the whole emulator.",
15+
"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<usize>, so the propagation point exists.",
16+
"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.",
17+
"effort": "M",
18+
"target": "jvm/src/garbage_collector.rs"
19+
}
20+
]
21+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# 2026-09-24 — `JavaError::Unraisable` : `Jvm::new` 는 `Err`, `Jvm::exception` 에는 바닥
2+
3+
티켓 `rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0` · 채택
4+
`2026-09-20-name-the-missing-bootstrap-class#p0` · `2026-09-20-string-array-hiding-overflows-stack#p0`.
5+
6+
## 반증 먼저 (@origin/main `d5a1d2f0`)
7+
- ⒜ `AGENTS.md:15` 「never panic in library code」 — 문면 그대로.
8+
- ⒝ `JavaError` 단일 변종 · irrefutable `let JavaError::JavaException(..) = ..` **3곳**
9+
(`jvm/src/jvm.rs` `<clinit>` 래핑 · `jvm/tests/test_exception_construction.rs` · `rustjava-runtime/tests/.../test_throwable.rs`) — 수 일치.
10+
추가로 exhaustive `match` 2곳(`jvm-bytecode/src/interpreter.rs` · `.../logging/stream_handler.rs`)이 깨졌다.
11+
- ⒞ 재귀: `Jvm::exception` 에 가드 0 — 순환은 닫혀 있다.
12+
13+
## 결정 = ⒜ 비-Java 변종 추가
14+
- 근거: ⑴AGENTS.md 조항 ⑵`Jvm::new` 는 이미 `Result` 를 돌려준다 — 패닉은 서명과 어긋난다 ⑶하류 임베더 실재 —
15+
wie `2026-09-22-lgt-object-reference-gate#p0` 이 같은 변종을 요청(타이틀 2건이 호스트를 죽였다) ⑷breaking 범위:
16+
이 repo 3곳 + match 2곳 · wie 는 `jvm` 을 crates.io 0.1.1 로 소비하므로 **판올림 때까지 깨지지 않는다**(wie irrefutable 다수 — 그때 치를 비용).
17+
- 이름 `Unraisable(String)`: 「Java 예외로 만들 수 없다」는 **사실**을 이름으로 — 원인이 호스트(클래스 집합)든 런타임(재귀)이든 같다.
18+
- `#[non_exhaustive]` 는 **안 붙였다** — 소비자에게 `_` 팔을 강제해 새 변종이 조용해진다. 다음 변종이 생기면 그때 판단.
19+
20+
## 티켓과 다르게 한 것 — 「깊이 1」 가드는 틀렸다 (실측)
21+
깊이 1로 넣자 `test_exception_reports_unloadable_class_instead_of_aborting` 이 red:
22+
`jvm.exception("java/lang/NoSuchClassAnywhere", …)` 는 **정상적으로** 안쪽에서 NoClassDefFoundError 를 만든다(1단 중첩 = JVM 동작).
23+
⇒ 규칙: **같은 스레드에서 «같은 예외(타입+메시지)»를 이미 만들고 있으면** 거부(= 순환) + 반복하지 않는 순환의 바닥으로 **깊이 8**.
24+
정상 중첩은 2단이다.
25+
26+
## 측정
27+
- `[Ljava/lang/String;` 숨김 cap 20: 종전 2 MiB 스택 overflow(rc 134) → `Unraisable`, loader 질문 **2회**, 메시지가 첫 실패
28+
`java/lang/NoClassDefFoundError ([Ljava/lang/String;)` 명명.
29+
- 스윕: `37 candidate(s): 0 recursed · 12 refused by name · 0 refused anonymously · 25 failed cleanly · 0 not needed` — 종전과 동일
30+
(거부를 패닉이 아니라 `Unraisable` 메시지에서 읽게만 바꿨다).
31+
- 변이(전건 원복 `cmp`): 가드 무력화 → `has overflowed its stack` · 부트스트랩 루프에 익명 패닉 복원 → `…bootstrap_class_is_an_error_naming_it` FAILED ·
32+
변종 삭제 → `jvm` 컴파일 에러 8.
33+
- `cargo test --all` **594 passed · 0 failed**(592 + 신규 2) · clippy stable/beta/wasm32 rc=0 · fmt rc=0 · python 5종 rc=0.
34+
35+
## 후속
36+
- p0: GC 도달성 순회(`garbage_collector::find_reachable_objects`)의 `.unwrap()` 들을 `Result` 로 — wie 제안의 둘째 반. 변종이 생겼으니 이제 전파할 곳이 있다.

‎jvm-bytecode/src/interpreter.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ impl Interpreter {
6060
return Err(JavaError::JavaException(e));
6161
}
6262
}
63+
// No instance, so no handler can match it: it goes to the host as it is.
64+
Err(e @ JavaError::Unraisable(_)) => return Err(e),
6365
}
6466
}
6567

‎jvm/src/error.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
use alloc::{
22
boxed::Box,
33
fmt::{self, Display, Formatter},
4+
string::String,
45
};
56

67
use crate::ClassInstance;
78

89
#[derive(Debug)]
910
pub enum JavaError {
1011
JavaException(Box<dyn ClassInstance>),
12+
/// A failure that could not be raised as a Java exception, with a message saying what failed.
13+
///
14+
/// A Java exception is an instance of a Java class, so it can only exist once the classes it is
15+
/// made of are loaded and constructing it has not itself failed. This is what the runtime returns
16+
/// when that is not the case: `Jvm::new` given a class set missing a class needed before anything
17+
/// can be raised, or `Jvm::exception` failing again while it is still building an exception on the
18+
/// same thread. Java code cannot catch it -- there is no instance to catch -- so it propagates to
19+
/// the host unchanged.
20+
Unraisable(String),
1121
}
1222

1323
impl Display for JavaError {
1424
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1525
match self {
1626
JavaError::JavaException(e) => write!(f, "Java exception: {e:?}"),
27+
JavaError::Unraisable(message) => write!(f, "unraisable error: {message}"),
1728
}
1829
}
1930
}

‎jvm/src/jvm.rs‎

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ struct JvmInner {
4949
get_current_thread_id: Box<dyn Fn() -> u64 + Sync + Send>,
5050
bootstrap_class_loader: Box<dyn BootstrapClassLoader>,
5151
bootstrapping: AtomicBool,
52+
/// Per thread, the exceptions `Jvm::exception` is building right now, outermost first -- the floor
53+
/// under its recursion.
54+
raising: RwLock<BTreeMap<u64, Vec<String>>>,
5255
}
5356

5457
#[derive(Clone)]
@@ -76,6 +79,7 @@ impl Jvm {
7679
get_current_thread_id: Box::new(get_current_thread_id),
7780
bootstrap_class_loader: Box::new(bootstrap_class_loader),
7881
bootstrapping: AtomicBool::new(true),
82+
raising: RwLock::new(BTreeMap::new()),
7983
}),
8084
};
8185

@@ -89,24 +93,25 @@ impl Jvm {
8993
"java/lang/Class",
9094
];
9195
for class_name in bootstrap_classes.iter() {
92-
// Panics like the closure walk below, and for the same reason -- nothing can be *raised*
93-
// yet, this is what loads the classes an exception is made of -- but it has to say which
94-
// name it was, which `unwrap` did not: a host with a gap in its class set read
96+
// Fails like the closure walk below, and for the same reason -- nothing can be *raised*
97+
// yet, this is what loads the classes an exception is made of -- so the error is
98+
// `Unraisable` rather than a Java exception. It has to say which name it was, which the
99+
// `unwrap` this once was did not: a host with a gap in its class set read
95100
// `called Option::unwrap() on a None value` and had to bisect this list to find out
96101
// which of six. Measured by last round's sweep: 5 of the 12 named refusals were this
97102
// line, and two of those names (`java/lang/Object`, `java/io/Serializable`) are also in
98103
// the error path's closure, so the same gap got a good message or a useless one
99104
// depending only on which loop reached it first.
100105
let Some(class_definition) = jvm.inner.bootstrap_class_loader.load_class(&jvm, class_name).await? else {
101-
panic!(
106+
return Err(JavaError::Unraisable(format!(
102107
"the class set has no {class_name}, which is one of the {} classes loaded before \
103108
anything else and before any error can be raised. Asked of the bootstrap class \
104109
loader passed to `Jvm::new`, which is the host's own -- not the class path: \
105110
`java.class.path` belongs to the system class loader, which is built later in \
106111
this same function and never runs if this fails. Add {class_name} to that \
107112
loader's class set.",
108113
bootstrap_classes.len()
109-
)
114+
)));
110115
};
111116
let class = Class::new(class_definition, None, None);
112117

@@ -161,7 +166,7 @@ impl Jvm {
161166
continue;
162167
}
163168
let Some(definition) = jvm.inner.bootstrap_class_loader.load_class(&jvm, &class_name).await? else {
164-
panic!(
169+
return Err(JavaError::Unraisable(format!(
165170
"the class set has no {class_name}, which the error path needs before anything can be \
166171
raised. Every raised error is an exception instance carrying a String message, so \
167172
building one resolves java/lang/String and java/lang/NoClassDefFoundError -- and with \
@@ -170,7 +175,7 @@ impl Jvm {
170175
and that recursion has no floor (measured: 117 round trips for String, 121 for \
171176
NoClassDefFoundError, then the process aborts on a stack overflow). Add it to the \
172177
class set."
173-
)
178+
)));
174179
};
175180
pending.extend(definition.interface_names());
176181
pending.extend(definition.super_class_name());
@@ -1036,9 +1041,51 @@ impl Jvm {
10361041
/// What the caller gets is then the *real* failure rather than the requested one -- a
10371042
/// NoClassDefFoundError for the missing class, or whatever the constructor threw -- which is how a
10381043
/// JVM behaves when raising one exception runs into another.
1044+
///
1045+
/// Building an exception runs Java code, and that code can fail in a way that is reported by
1046+
/// building another exception. One level of that is ordinary -- raising a class that cannot be
1047+
/// loaded raises NoClassDefFoundError from inside -- but it can also close into a cycle: hiding
1048+
/// `[Ljava/lang/String;` makes `fillInStackTrace` ask for it, which raises NoClassDefFoundError,
1049+
/// whose construction calls `fillInStackTrace`, ~57 stack frames a turn until the process aborts on
1050+
/// a stack overflow. So a thread that is already building the *same* exception, or is
1051+
/// `MAX_RAISING_DEPTH` deep, gets `Unraisable` instead, naming the exception the thread started
1052+
/// with -- the first failure, not the last.
10391053
pub async fn exception(&self, r#type: &str, message: &str) -> JavaError {
1054+
// Only a floor for cycles that do not repeat themselves exactly; the ordinary nesting is 2.
1055+
const MAX_RAISING_DEPTH: usize = 8;
1056+
10401057
tracing::info!("throwing java exception: {} {message}", r#type);
10411058

1059+
let thread_id = (self.inner.get_current_thread_id)();
1060+
let this = format!("{} ({message})", r#type);
1061+
{
1062+
let mut raising = self.inner.raising.write();
1063+
let stack = raising.entry(thread_id).or_default();
1064+
if stack.contains(&this) || stack.len() >= MAX_RAISING_DEPTH {
1065+
return JavaError::Unraisable(format!(
1066+
"raising {this} needed raising it again, {} level(s) into raising {}, which is the first failure",
1067+
stack.len(),
1068+
stack[0]
1069+
));
1070+
}
1071+
stack.push(this);
1072+
}
1073+
// Popped on every exit, including this future being dropped mid-way: a stale entry would make
1074+
// a later exception on this thread unraisable.
1075+
struct Raising<'a>(&'a JvmInner, u64);
1076+
impl Drop for Raising<'_> {
1077+
fn drop(&mut self) {
1078+
let mut raising = self.0.raising.write();
1079+
if let Some(stack) = raising.get_mut(&self.1) {
1080+
stack.pop();
1081+
if stack.is_empty() {
1082+
raising.remove(&self.1);
1083+
}
1084+
}
1085+
}
1086+
}
1087+
let _raising = Raising(&self.inner, thread_id);
1088+
10421089
let message_str = match JavaLangString::from_rust_string(self, message).await {
10431090
Ok(x) => x,
10441091
Err(e) => return e,
@@ -1170,7 +1217,10 @@ impl Jvm {
11701217
if let Err(err) = self.execute_method(class, None, &clinit, Box::new([])).await {
11711218
class.finish_initialization(InitState::Erroneous);
11721219

1173-
let JavaError::JavaException(exception) = &err;
1220+
// An unraisable error has no instance to wrap in ExceptionInInitializerError.
1221+
let JavaError::JavaException(exception) = &err else {
1222+
return Err(err);
1223+
};
11741224
if self.is_instance(&**exception, "java/lang/Error") {
11751225
return Err(err);
11761226
}

0 commit comments

Comments
 (0)