Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions REPORT.md
Original file line number Diff line number Diff line change
@@ -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 비대가 오므로, 변종은 «규칙 수»가 아니라 «표 종류 수»로만 늘게 멈춤 기준을 먼저 세웠다.
Expand Down
4 changes: 3 additions & 1 deletion STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 유지.
Expand Down Expand Up @@ -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 전수 재측 판)·①~⑤ 는 사료다. ★**「다음」으로 읽지 마라** ----
Expand Down
21 changes: 21 additions & 0 deletions docs/worklog/2026-09-24-unraisable-error-variant.json
Original file line number Diff line number Diff line change
@@ -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<usize>, 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"
}
]
}
36 changes: 36 additions & 0 deletions docs/worklog/2026-09-24-unraisable-error-variant.md
Original file line number Diff line number Diff line change
@@ -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` `<clinit>` 래핑 · `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 제안의 둘째 반. 변종이 생겼으니 이제 전파할 곳이 있다.
2 changes: 2 additions & 0 deletions jvm-bytecode/src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
11 changes: 11 additions & 0 deletions jvm/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
use alloc::{
boxed::Box,
fmt::{self, Display, Formatter},
string::String,
};

use crate::ClassInstance;

#[derive(Debug)]
pub enum JavaError {
JavaException(Box<dyn ClassInstance>),
/// 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}"),
}
}
}
Expand Down
66 changes: 58 additions & 8 deletions jvm/src/jvm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ struct JvmInner {
get_current_thread_id: Box<dyn Fn() -> u64 + Sync + Send>,
bootstrap_class_loader: Box<dyn BootstrapClassLoader>,
bootstrapping: AtomicBool,
/// Per thread, the exceptions `Jvm::exception` is building right now, outermost first -- the floor
/// under its recursion.
raising: RwLock<BTreeMap<u64, Vec<String>>>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -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()),
}),
};

Expand All @@ -89,24 +93,25 @@ 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: \
`java.class.path` belongs to the system class loader, which is built later in \
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);

Expand Down Expand Up @@ -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 \
Expand All @@ -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());
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading