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
4 changes: 4 additions & 0 deletions REPORT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# REPORT
## [2026-09-24] GC 가 호스트 오류에 패닉하지 않고 `Err` 를 돌려준다 (wie-2026-09-22-lgt-object-reference-gate-adopt-p0)
- 무엇을: `Jvm::collect_garbage` 의 도달성 순회가 `get_field`·`get_static_field`·배열 `load` 오류를 `.unwrap()` 대신 호출자에게 돌려준다. 내부 불변식 두 곳은 `JavaError::Unraisable` 로 대상을 이름으로 말한다. 오류가 나면 아무것도 해제하지 않는다.
- 왜: wie 제안 `2026-09-22-lgt-object-reference-gate#p0` 의 나머지 절반이다(변종은 #94 에서 끝났다). 변종만 있으면 호스트가 올린 오류가 GC 경로에서 다시 패닉이 된다.
- 사용자 영향: 게스트가 잘못된 참조를 넘겨도 GC 가 호스트를 죽이지 않는다. 공개 API 변경 없음. wie 는 crates.io 릴리스 뒤에야 게이트의 `None` 을 호스트 오류로 올릴 수 있다. 후속 추천 0건 — 한계 3개는 `docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md` 에 있다.
## [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 「라이브러리 코드는 패닉하지 않는다」.
Expand Down
1 change: 1 addition & 0 deletions STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
(둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다)

## 완료
- [wie-2026-09-22-lgt-object-reference-gate-adopt-p0] GC 도달성 순회 `Result` 전파 — `get_field`·`get_static_field`·`load` unwrap 3곳 → `?`(호스트 오류 그대로) · 불변식 unwrap 2곳 → `Unraisable` · 오류 시 중단(해제 0). 새 변종 0 · 공개 서명 변경 0. 양방향: 새 시험 ok ↔ unwrap 복원 시 panic FAILED. 채택 `2026-09-22-lgt-object-reference-gate#p0` · `2026-09-24-unraisable-error-variant#p0`. wie 효력은 crates.io 릴리스 뒤. 상세 `docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md`.
- [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`.
Expand Down
14 changes: 14 additions & 0 deletions docs/worklog/2026-09-24-gc-walk-propagates-host-errors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"schema": "rustjava-worklog-v1",
"date": "2026-09-24",
"taskId": "wie-2026-09-22-lgt-object-reference-gate-adopt-p0",
"summary": "GC reachability walk returns Result: host get_field/get_static_field/load errors reach collect_garbage's caller instead of panicking; two internal-invariant unwraps become Unraisable. No new JavaError variant, no public signature change.",
"adoptedProposals": [
"2026-09-22-lgt-object-reference-gate#p0",
"2026-09-24-unraisable-error-variant#p0"
],
"proposals": [],
"verification": [
"cargo test -p jvm --test test_gc_host_error ok; reverting get_field ? to unwrap -> panicked at garbage_collector.rs:103:55 FAILED"
]
}
32 changes: 32 additions & 0 deletions docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 2026-09-24 — GC 도달성 순회가 호스트 오류를 `Result` 로 돌려준다

티켓 `wie-2026-09-22-lgt-object-reference-gate-adopt-p0` · 채택 `2026-09-22-lgt-object-reference-gate#p0`
(wie 원 제안 — 원 worklog 무접촉) · `2026-09-24-unraisable-error-variant#p0`(같은 일을 가리키는 이 repo 의 후속 카드).

## 반증 먼저
- ⒜ wie `origin/main`(`7e40b2f4`, 2026-09-24 01:12 +0900) `Cargo.toml:73` `jvm = { version = "^0.1.1" }` — 아직 crates.io 에서 소비한다.
wie 쪽 게이트(`object_from_raw -> Option`)가 이미 패닉을 막고 있다 ⇒ 이 변경이 wie 에 주는 가치는 «진단 승격»이고,
효력은 이 PR 착지 + crates.io 릴리스 뒤다.
- ⒝ 변종 절반은 #94(`5cc461f9`, `JavaError::Unraisable`)가 이미 냈다 ⇒ **새 변종 0**. 이 회차는 순회 쪽 절반만 한다.

## 변경
- `jvm/src/garbage_collector.rs`: `determine_garbage`·`find_reachable_objects`·`find_static_reachable_objects`·`find_all_fields`
→ `Result`. `get_field`·`get_static_field`·`load` 의 `.unwrap()` 3곳 → `?`(호스트 오류를 그대로 전달).
내부 불변식 `.unwrap()` 2곳(`as_array_instance` · 슈퍼클래스 `get_class`) → `Unraisable`(대상을 이름으로 적는다).
- `jvm/src/jvm.rs` `collect_garbage`: `determine_garbage(..)?`. 서명(`Result<usize>`)은 그대로다.
- ★**중단하고, 건너뛰지 않는다**: 읽지 못한 객체가 다른 객체의 유일한 참조를 쥐고 있을 수 있으므로 «나머지만 garbage 로 보고»하면 산 객체를 해제한다.
순회 중에는 destroy 가 없으므로 오류가 나면 힙은 그대로다.

## 파열 범위 (실측)
- 공개 API 변경 **0** — `garbage_collector` 는 `mod garbage_collector;`(비공개)이고 `collect_garbage` 서명 불변.
- `JavaError` match 지점 신규 파열 **0**(변종 추가 없음). 호출부 `collect_garbage()?` 는 워크스페이스에 원래 `?` 로 쓰여 있어 무수정.

## 양방향
- 새 시험 `jvm/tests/test_gc_host_error.rs`: `get_field` 가 `Unraisable` 을 내는 호스트 `ClassInstance` 를 global ref 로 두면 `collect_garbage` 가 그 오류를 돌려주고,
ref 를 내린 뒤 GC 는 `Ok(0)` 이며 감싼 `Vector` 는 여전히 쓸 수 있다 → **ok**.
- 개악(`get_field(..)?` → `.unwrap()` 되돌림) → `panicked at jvm/src/garbage_collector.rs:103:55` **FAILED**.

## 한계
- `ClassInstance::class_definition` 은 여전히 `Result` 가 아니다(트레이트 서명 변경 = 모든 impl 파열) — 이번 범위 밖.
- `collect_garbage` 의 `self.destroy(object).unwrap()` 은 그대로다(순회가 아니라 해제 단계 · 중간 실패 시 일부만 해제되는 의미를 따로 정해야 한다).
- `get_static_field`·배열 `load` 경로는 같은 `?` 이지만 전용 시험이 없다(get_field 경로만 잠갔다).
91 changes: 53 additions & 38 deletions jvm/src/garbage_collector.rs
Original file line number Diff line number Diff line change
@@ -1,49 +1,56 @@
use alloc::{boxed::Box, collections::BTreeMap, string::String, vec::Vec};
use alloc::{boxed::Box, collections::BTreeMap, format, string::String, vec::Vec};
use jvm_types::FieldAccessFlags;

use hashbrown::{HashSet, hash_set::Entry};

use crate::{ClassDefinition, ClassInstance, Field, JavaValue, Jvm, class_loader::Class, thread::JvmThread};
use crate::{ClassDefinition, ClassInstance, Field, JavaError, JavaValue, Jvm, Result, class_loader::Class, thread::JvmThread};

/// The objects nothing reachable refers to.
///
/// The walk reads fields through the `ClassInstance`/`ClassDefinition` implementations, which may belong
/// to the host and may fail. A failure ends the walk with that error rather than a partial answer: an
/// object whose fields could not be read might hold the only reference to another, so reporting the rest
/// as garbage would free live objects. Nothing is destroyed before the walk returns, so an error leaves the
/// heap as it was.
pub fn determine_garbage(
jvm: &Jvm,
threads: &BTreeMap<u64, JvmThread>,
global_references: &BTreeMap<u64, Box<dyn ClassInstance>>,
all_class_instances: &HashSet<Box<dyn ClassInstance>>,
classes: &BTreeMap<String, Class>,
interned_strings: &[Box<dyn ClassInstance>],
) -> Vec<Box<dyn ClassInstance>> {
) -> Result<Vec<Box<dyn ClassInstance>>> {
let mut reachable_objects = HashSet::new();

classes.values().for_each(|x| {
find_reachable_objects(jvm, &x.java_class(), &mut reachable_objects);
find_static_reachable_objects(jvm, x, &mut reachable_objects);
});
for class in classes.values() {
find_reachable_objects(jvm, &class.java_class(), &mut reachable_objects)?;
find_static_reachable_objects(jvm, class, &mut reachable_objects)?;
}

threads
for object in threads
.values()
.flat_map(|thread| thread.iter_frame().flat_map(|stack| stack.local_variables()))
.for_each(|x| {
find_reachable_objects(jvm, x, &mut reachable_objects);
});
{
find_reachable_objects(jvm, object, &mut reachable_objects)?;
}

threads.values().filter_map(|thread| thread.java_thread()).for_each(|x| {
find_reachable_objects(jvm, x, &mut reachable_objects);
});
for object in threads.values().filter_map(|thread| thread.java_thread()) {
find_reachable_objects(jvm, object, &mut reachable_objects)?;
}

global_references.values().for_each(|object| {
find_reachable_objects(jvm, object, &mut reachable_objects);
});
for object in global_references.values() {
find_reachable_objects(jvm, object, &mut reachable_objects)?;
}

interned_strings.iter().for_each(|x| {
find_reachable_objects(jvm, x, &mut reachable_objects);
});
for object in interned_strings {
find_reachable_objects(jvm, object, &mut reachable_objects)?;
}

all_class_instances.difference(&reachable_objects).cloned().collect()
Ok(all_class_instances.difference(&reachable_objects).cloned().collect())
}

fn find_static_reachable_objects(jvm: &Jvm, class: &Class, reachable_objects: &mut HashSet<Box<dyn ClassInstance>>) {
let fields = find_all_fields(jvm, &*class.definition);
fn find_static_reachable_objects(jvm: &Jvm, class: &Class, reachable_objects: &mut HashSet<Box<dyn ClassInstance>>) -> Result<()> {
let fields = find_all_fields(jvm, &*class.definition)?;
for field in fields {
if !field.access_flags().contains(FieldAccessFlags::STATIC) {
continue;
Expand All @@ -52,38 +59,42 @@ fn find_static_reachable_objects(jvm: &Jvm, class: &Class, reachable_objects: &m
let descriptor = field.descriptor();

if (descriptor.starts_with('L') && descriptor.ends_with(';')) || descriptor.starts_with('[') {
let value = class.definition.get_static_field(&*field).unwrap();
let value = class.definition.get_static_field(&*field)?;
if let JavaValue::Object(Some(value)) = value {
find_reachable_objects(jvm, &value, reachable_objects);
find_reachable_objects(jvm, &value, reachable_objects)?;
}
}
}

Ok(())
}

#[allow(clippy::borrowed_box)]
fn find_reachable_objects(jvm: &Jvm, object: &Box<dyn ClassInstance>, reachable_objects: &mut HashSet<Box<dyn ClassInstance>>) {
fn find_reachable_objects(jvm: &Jvm, object: &Box<dyn ClassInstance>, reachable_objects: &mut HashSet<Box<dyn ClassInstance>>) -> Result<()> {
let entry = reachable_objects.entry(object.clone());
if let Entry::Occupied(_) = entry {
return;
return Ok(());
}
entry.insert();

let name = object.class_definition().name();
if name.starts_with('[') {
if name.starts_with("[L") || name.starts_with("[[") {
// is object array
let array = object.as_array_instance().unwrap();
let values = array.load(0, array.length()).unwrap();
let array = object
.as_array_instance()
.ok_or_else(|| JavaError::Unraisable(format!("{object:?} is named {name} but is not an array instance")))?;
let values = array.load(0, array.length())?;

for value in values {
if let JavaValue::Object(Some(value)) = value {
find_reachable_objects(jvm, &value, reachable_objects);
find_reachable_objects(jvm, &value, reachable_objects)?;
}
}
}
// do nothing for primitive arrays
} else {
let fields = find_all_fields(jvm, &*object.class_definition());
let fields = find_all_fields(jvm, &*object.class_definition())?;
for field in fields {
if field.access_flags().contains(FieldAccessFlags::STATIC) {
continue;
Expand All @@ -92,24 +103,28 @@ fn find_reachable_objects(jvm: &Jvm, object: &Box<dyn ClassInstance>, reachable_
let descriptor = field.descriptor();

if (descriptor.starts_with('L') && descriptor.ends_with(';')) || descriptor.starts_with('[') {
let value = object.get_field(&*field).unwrap();
let value = object.get_field(&*field)?;
if let JavaValue::Object(Some(value)) = value {
find_reachable_objects(jvm, &value, reachable_objects);
find_reachable_objects(jvm, &value, reachable_objects)?;
}
}
}
}

Ok(())
}

fn find_all_fields(jvm: &Jvm, class_definition: &dyn ClassDefinition) -> Vec<Box<dyn Field>> {
fn find_all_fields(jvm: &Jvm, class_definition: &dyn ClassDefinition) -> Result<Vec<Box<dyn Field>>> {
let result = class_definition.fields();
let super_class_name = class_definition.super_class_name();

if let Some(x) = super_class_name {
let super_class = jvm.get_class(&x).unwrap();
let super_fields = find_all_fields(jvm, &*super_class.definition);
result.into_iter().chain(super_fields).collect()
let super_class = jvm
.get_class(&x)
.ok_or_else(|| JavaError::Unraisable(format!("{} has superclass {x}, which is not loaded", class_definition.name())))?;
let super_fields = find_all_fields(jvm, &*super_class.definition)?;
Ok(result.into_iter().chain(super_fields).collect())
} else {
result
Ok(result)
}
}
2 changes: 1 addition & 1 deletion jvm/src/jvm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,7 @@ impl Jvm {
let classes = self.inner.classes.read();
let interned_strings = self.interned_strings();

determine_garbage(self, &threads, &global_references, &all_objects, &classes, &interned_strings)
determine_garbage(self, &threads, &global_references, &all_objects, &classes, &interned_strings)?
};

let garbage_count = garbage.len();
Expand Down
75 changes: 75 additions & 0 deletions jvm/tests/test_gc_host_error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use core::hash::{Hash, Hasher};

use jvm::{ClassDefinition, ClassInstance, ClassInstanceRef, Field, JavaError, JavaValue, Result};

use test_utils::test_jvm;

/// A host object whose fields cannot be read -- what wie's `ClassInstance` impl reports when the guest
/// hands it a word that does not decode to a live object.
#[derive(Clone, Debug)]
struct Unreadable(Box<dyn ClassInstance>);

impl ClassInstance for Unreadable {
fn destroy(self: Box<Self>) {}

fn identity(&self) -> usize {
self.0.identity()
}

fn shallow_clone(&self) -> Result<Box<dyn ClassInstance>> {
Ok(Box::new(self.clone()))
}

fn class_definition(&self) -> Box<dyn ClassDefinition> {
self.0.class_definition()
}

fn equals(&self, other: &dyn ClassInstance) -> Result<bool> {
Ok(other
.as_any()
.downcast_ref::<Self>()
.is_some_and(|other| other.identity() == self.identity()))
}

fn get_field(&self, field: &dyn Field) -> Result<JavaValue> {
Err(JavaError::Unraisable(format!("cannot read {} of a dead guest object", field.name())))
}

fn put_field(&mut self, _: &dyn Field, _: JavaValue) -> Result<()> {
unreachable!()
}
}

impl Hash for Unreadable {
fn hash<H: Hasher>(&self, state: &mut H) {
// distinct from the wrapped instance, which is reachable on its own
(self.identity(), "Unreadable").hash(state);
}
}

// `find_reachable_objects` used to `.unwrap()` every field read, so a host `ClassInstance` that could
// not read a field took the process down from inside `Jvm::collect_garbage` -- which already returns
// `Result`. The error now reaches the caller unchanged, and nothing is freed on the way out.
#[tokio::test]
async fn a_field_the_host_cannot_read_fails_the_collection_instead_of_panicking() -> Result<()> {
let jvm = test_jvm().await?;

jvm.push_native_frame();
let vector = jvm.new_class("java/util/Vector", "()V", ()).await?;
jvm.collect_garbage()?;

let unreadable = jvm.new_global_ref(&ClassInstanceRef::<()>::new(Some(Box::new(Unreadable(vector.clone())))));

match jvm.collect_garbage() {
Err(JavaError::Unraisable(message)) => assert!(message.contains("of a dead guest object"), "{message}"),
other => panic!("expected the host error back, got {other:?}"),
}

// The failed walk freed nothing: the heap is collectable again once the object is gone.
drop(unreadable);
assert_eq!(jvm.collect_garbage()?, 0);
let size: i32 = jvm.invoke_virtual(&vector, "java/util/Vector", "size", "()I", ()).await?;
assert_eq!(size, 0);

Ok(())
}
Loading