Skip to content

Commit e0e7868

Browse files
authored
[wie-2026-09-22-lgt-object-reference-gate-adopt-p0] fix(jvm): GC 도달성 순회가 호스트 오류를 Result 로 전파한다 (#95)
[wie-2026-09-22-lgt-object-reference-gate-adopt-p0] fix(jvm): GC 도달성 순회가 호스트 오류를 Result 로 전파한다
2 parents 5cc461f + c9c4d2b commit e0e7868

7 files changed

Lines changed: 180 additions & 39 deletions

File tree

‎REPORT.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# REPORT
2+
## [2026-09-24] GC 가 호스트 오류에 패닉하지 않고 `Err` 를 돌려준다 (wie-2026-09-22-lgt-object-reference-gate-adopt-p0)
3+
- 무엇을: `Jvm::collect_garbage` 의 도달성 순회가 `get_field`·`get_static_field`·배열 `load` 오류를 `.unwrap()` 대신 호출자에게 돌려준다. 내부 불변식 두 곳은 `JavaError::Unraisable` 로 대상을 이름으로 말한다. 오류가 나면 아무것도 해제하지 않는다.
4+
- 왜: wie 제안 `2026-09-22-lgt-object-reference-gate#p0` 의 나머지 절반이다(변종은 #94 에서 끝났다). 변종만 있으면 호스트가 올린 오류가 GC 경로에서 다시 패닉이 된다.
5+
- 사용자 영향: 게스트가 잘못된 참조를 넘겨도 GC 가 호스트를 죽이지 않는다. 공개 API 변경 없음. wie 는 crates.io 릴리스 뒤에야 게이트의 `None` 을 호스트 오류로 올릴 수 있다. 후속 추천 0건 — 한계 3개는 `docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md` 에 있다.
26
## [2026-09-24] `JavaError` 에 «Java 예외로 만들 수 없는 실패»를 뒀다 — `Jvm::new` 는 패닉 대신 `Err`, 예외 생성의 재귀에는 바닥 (rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0)
37
- 무엇을: `JavaError::Unraisable(String)` 변종 추가. `Jvm::new` 의 패닉 2곳(부트스트랩 클래스·오류 경로 closure)이 빠진 클래스 이름을 담은 `Err` 를 돌려준다. `Jvm::exception` 은 같은 스레드에서 이미 만들고 있는 예외를 다시 만들려 하면(또는 깊이 8) `Unraisable` 로 첫 실패를 명명한다.
48
- 왜: 채택 제안 2건(`…name-the-missing-bootstrap-class#p0` · `…string-array-hiding-overflows-stack#p0`)이 같은 장애물 — 단일 변종 `JavaError` — 에 닿았고, 하류 임베더 wie 가 같은 변종을 요청했다(타이틀 2건이 호스트를 죽였다). AGENTS.md 「라이브러리 코드는 패닉하지 않는다」.

‎STATE.md‎

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

99
## 완료
10+
- [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`.
1011
- [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`.
1112
보정(-fix): `stream_handler.rs` `publish` 의 `if let Err(JavaException)` 2곳 → `match` + `Unraisable` 전파(삼킴 제거) · 회귀 `stream_handler_propagates_unraisable_output_failures`.
1213
- [rustjava-2026-09-18-bootstrap-argument-index-and-tag-adopt-p0] ★**검증 규칙이 멈춘 위치를 말한다 — 11규칙, 변종 1개.** 채택 제안 `2026-09-18-bootstrap-argument-index-and-tag#p0`.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"schema": "rustjava-worklog-v1",
3+
"date": "2026-09-24",
4+
"taskId": "wie-2026-09-22-lgt-object-reference-gate-adopt-p0",
5+
"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.",
6+
"adoptedProposals": [
7+
"2026-09-22-lgt-object-reference-gate#p0",
8+
"2026-09-24-unraisable-error-variant#p0"
9+
],
10+
"proposals": [],
11+
"verification": [
12+
"cargo test -p jvm --test test_gc_host_error ok; reverting get_field ? to unwrap -> panicked at garbage_collector.rs:103:55 FAILED"
13+
]
14+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# 2026-09-24 — GC 도달성 순회가 호스트 오류를 `Result` 로 돌려준다
2+
3+
티켓 `wie-2026-09-22-lgt-object-reference-gate-adopt-p0` · 채택 `2026-09-22-lgt-object-reference-gate#p0`
4+
(wie 원 제안 — 원 worklog 무접촉) · `2026-09-24-unraisable-error-variant#p0`(같은 일을 가리키는 이 repo 의 후속 카드).
5+
6+
## 반증 먼저
7+
- ⒜ wie `origin/main`(`7e40b2f4`, 2026-09-24 01:12 +0900) `Cargo.toml:73` `jvm = { version = "^0.1.1" }` — 아직 crates.io 에서 소비한다.
8+
wie 쪽 게이트(`object_from_raw -> Option`)가 이미 패닉을 막고 있다 ⇒ 이 변경이 wie 에 주는 가치는 «진단 승격»이고,
9+
효력은 이 PR 착지 + crates.io 릴리스 뒤다.
10+
- ⒝ 변종 절반은 #94(`5cc461f9`, `JavaError::Unraisable`)가 이미 냈다 ⇒ **새 변종 0**. 이 회차는 순회 쪽 절반만 한다.
11+
12+
## 변경
13+
- `jvm/src/garbage_collector.rs`: `determine_garbage`·`find_reachable_objects`·`find_static_reachable_objects`·`find_all_fields`
14+
→ `Result`. `get_field`·`get_static_field`·`load` 의 `.unwrap()` 3곳 → `?`(호스트 오류를 그대로 전달).
15+
내부 불변식 `.unwrap()` 2곳(`as_array_instance` · 슈퍼클래스 `get_class`) → `Unraisable`(대상을 이름으로 적는다).
16+
- `jvm/src/jvm.rs` `collect_garbage`: `determine_garbage(..)?`. 서명(`Result<usize>`)은 그대로다.
17+
- ★**중단하고, 건너뛰지 않는다**: 읽지 못한 객체가 다른 객체의 유일한 참조를 쥐고 있을 수 있으므로 «나머지만 garbage 로 보고»하면 산 객체를 해제한다.
18+
순회 중에는 destroy 가 없으므로 오류가 나면 힙은 그대로다.
19+
20+
## 파열 범위 (실측)
21+
- 공개 API 변경 **0** — `garbage_collector` 는 `mod garbage_collector;`(비공개)이고 `collect_garbage` 서명 불변.
22+
- `JavaError` match 지점 신규 파열 **0**(변종 추가 없음). 호출부 `collect_garbage()?` 는 워크스페이스에 원래 `?` 로 쓰여 있어 무수정.
23+
24+
## 양방향
25+
- 새 시험 `jvm/tests/test_gc_host_error.rs`: `get_field` 가 `Unraisable` 을 내는 호스트 `ClassInstance` 를 global ref 로 두면 `collect_garbage` 가 그 오류를 돌려주고,
26+
ref 를 내린 뒤 GC 는 `Ok(0)` 이며 감싼 `Vector` 는 여전히 쓸 수 있다 → **ok**.
27+
- 개악(`get_field(..)?` → `.unwrap()` 되돌림) → `panicked at jvm/src/garbage_collector.rs:103:55` **FAILED**.
28+
29+
## 한계
30+
- `ClassInstance::class_definition` 은 여전히 `Result` 가 아니다(트레이트 서명 변경 = 모든 impl 파열) — 이번 범위 밖.
31+
- `collect_garbage` 의 `self.destroy(object).unwrap()` 은 그대로다(순회가 아니라 해제 단계 · 중간 실패 시 일부만 해제되는 의미를 따로 정해야 한다).
32+
- `get_static_field`·배열 `load` 경로는 같은 `?` 이지만 전용 시험이 없다(get_field 경로만 잠갔다).

‎jvm/src/garbage_collector.rs‎

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,56 @@
1-
use alloc::{boxed::Box, collections::BTreeMap, string::String, vec::Vec};
1+
use alloc::{boxed::Box, collections::BTreeMap, format, string::String, vec::Vec};
22
use jvm_types::FieldAccessFlags;
33

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

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

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

18-
classes.values().for_each(|x| {
19-
find_reachable_objects(jvm, &x.java_class(), &mut reachable_objects);
20-
find_static_reachable_objects(jvm, x, &mut reachable_objects);
21-
});
25+
for class in classes.values() {
26+
find_reachable_objects(jvm, &class.java_class(), &mut reachable_objects)?;
27+
find_static_reachable_objects(jvm, class, &mut reachable_objects)?;
28+
}
2229

23-
threads
30+
for object in threads
2431
.values()
2532
.flat_map(|thread| thread.iter_frame().flat_map(|stack| stack.local_variables()))
26-
.for_each(|x| {
27-
find_reachable_objects(jvm, x, &mut reachable_objects);
28-
});
33+
{
34+
find_reachable_objects(jvm, object, &mut reachable_objects)?;
35+
}
2936

30-
threads.values().filter_map(|thread| thread.java_thread()).for_each(|x| {
31-
find_reachable_objects(jvm, x, &mut reachable_objects);
32-
});
37+
for object in threads.values().filter_map(|thread| thread.java_thread()) {
38+
find_reachable_objects(jvm, object, &mut reachable_objects)?;
39+
}
3340

34-
global_references.values().for_each(|object| {
35-
find_reachable_objects(jvm, object, &mut reachable_objects);
36-
});
41+
for object in global_references.values() {
42+
find_reachable_objects(jvm, object, &mut reachable_objects)?;
43+
}
3744

38-
interned_strings.iter().for_each(|x| {
39-
find_reachable_objects(jvm, x, &mut reachable_objects);
40-
});
45+
for object in interned_strings {
46+
find_reachable_objects(jvm, object, &mut reachable_objects)?;
47+
}
4148

42-
all_class_instances.difference(&reachable_objects).cloned().collect()
49+
Ok(all_class_instances.difference(&reachable_objects).cloned().collect())
4350
}
4451

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

5461
if (descriptor.starts_with('L') && descriptor.ends_with(';')) || descriptor.starts_with('[') {
55-
let value = class.definition.get_static_field(&*field).unwrap();
62+
let value = class.definition.get_static_field(&*field)?;
5663
if let JavaValue::Object(Some(value)) = value {
57-
find_reachable_objects(jvm, &value, reachable_objects);
64+
find_reachable_objects(jvm, &value, reachable_objects)?;
5865
}
5966
}
6067
}
68+
69+
Ok(())
6170
}
6271

6372
#[allow(clippy::borrowed_box)]
64-
fn find_reachable_objects(jvm: &Jvm, object: &Box<dyn ClassInstance>, reachable_objects: &mut HashSet<Box<dyn ClassInstance>>) {
73+
fn find_reachable_objects(jvm: &Jvm, object: &Box<dyn ClassInstance>, reachable_objects: &mut HashSet<Box<dyn ClassInstance>>) -> Result<()> {
6574
let entry = reachable_objects.entry(object.clone());
6675
if let Entry::Occupied(_) = entry {
67-
return;
76+
return Ok(());
6877
}
6978
entry.insert();
7079

7180
let name = object.class_definition().name();
7281
if name.starts_with('[') {
7382
if name.starts_with("[L") || name.starts_with("[[") {
7483
// is object array
75-
let array = object.as_array_instance().unwrap();
76-
let values = array.load(0, array.length()).unwrap();
84+
let array = object
85+
.as_array_instance()
86+
.ok_or_else(|| JavaError::Unraisable(format!("{object:?} is named {name} but is not an array instance")))?;
87+
let values = array.load(0, array.length())?;
7788

7889
for value in values {
7990
if let JavaValue::Object(Some(value)) = value {
80-
find_reachable_objects(jvm, &value, reachable_objects);
91+
find_reachable_objects(jvm, &value, reachable_objects)?;
8192
}
8293
}
8394
}
8495
// do nothing for primitive arrays
8596
} else {
86-
let fields = find_all_fields(jvm, &*object.class_definition());
97+
let fields = find_all_fields(jvm, &*object.class_definition())?;
8798
for field in fields {
8899
if field.access_flags().contains(FieldAccessFlags::STATIC) {
89100
continue;
@@ -92,24 +103,28 @@ fn find_reachable_objects(jvm: &Jvm, object: &Box<dyn ClassInstance>, reachable_
92103
let descriptor = field.descriptor();
93104

94105
if (descriptor.starts_with('L') && descriptor.ends_with(';')) || descriptor.starts_with('[') {
95-
let value = object.get_field(&*field).unwrap();
106+
let value = object.get_field(&*field)?;
96107
if let JavaValue::Object(Some(value)) = value {
97-
find_reachable_objects(jvm, &value, reachable_objects);
108+
find_reachable_objects(jvm, &value, reachable_objects)?;
98109
}
99110
}
100111
}
101112
}
113+
114+
Ok(())
102115
}
103116

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

108121
if let Some(x) = super_class_name {
109-
let super_class = jvm.get_class(&x).unwrap();
110-
let super_fields = find_all_fields(jvm, &*super_class.definition);
111-
result.into_iter().chain(super_fields).collect()
122+
let super_class = jvm
123+
.get_class(&x)
124+
.ok_or_else(|| JavaError::Unraisable(format!("{} has superclass {x}, which is not loaded", class_definition.name())))?;
125+
let super_fields = find_all_fields(jvm, &*super_class.definition)?;
126+
Ok(result.into_iter().chain(super_fields).collect())
112127
} else {
113-
result
128+
Ok(result)
114129
}
115130
}

‎jvm/src/jvm.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1128,7 +1128,7 @@ impl Jvm {
11281128
let classes = self.inner.classes.read();
11291129
let interned_strings = self.interned_strings();
11301130

1131-
determine_garbage(self, &threads, &global_references, &all_objects, &classes, &interned_strings)
1131+
determine_garbage(self, &threads, &global_references, &all_objects, &classes, &interned_strings)?
11321132
};
11331133

11341134
let garbage_count = garbage.len();

‎jvm/tests/test_gc_host_error.rs‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use core::hash::{Hash, Hasher};
2+
3+
use jvm::{ClassDefinition, ClassInstance, ClassInstanceRef, Field, JavaError, JavaValue, Result};
4+
5+
use test_utils::test_jvm;
6+
7+
/// A host object whose fields cannot be read -- what wie's `ClassInstance` impl reports when the guest
8+
/// hands it a word that does not decode to a live object.
9+
#[derive(Clone, Debug)]
10+
struct Unreadable(Box<dyn ClassInstance>);
11+
12+
impl ClassInstance for Unreadable {
13+
fn destroy(self: Box<Self>) {}
14+
15+
fn identity(&self) -> usize {
16+
self.0.identity()
17+
}
18+
19+
fn shallow_clone(&self) -> Result<Box<dyn ClassInstance>> {
20+
Ok(Box::new(self.clone()))
21+
}
22+
23+
fn class_definition(&self) -> Box<dyn ClassDefinition> {
24+
self.0.class_definition()
25+
}
26+
27+
fn equals(&self, other: &dyn ClassInstance) -> Result<bool> {
28+
Ok(other
29+
.as_any()
30+
.downcast_ref::<Self>()
31+
.is_some_and(|other| other.identity() == self.identity()))
32+
}
33+
34+
fn get_field(&self, field: &dyn Field) -> Result<JavaValue> {
35+
Err(JavaError::Unraisable(format!("cannot read {} of a dead guest object", field.name())))
36+
}
37+
38+
fn put_field(&mut self, _: &dyn Field, _: JavaValue) -> Result<()> {
39+
unreachable!()
40+
}
41+
}
42+
43+
impl Hash for Unreadable {
44+
fn hash<H: Hasher>(&self, state: &mut H) {
45+
// distinct from the wrapped instance, which is reachable on its own
46+
(self.identity(), "Unreadable").hash(state);
47+
}
48+
}
49+
50+
// `find_reachable_objects` used to `.unwrap()` every field read, so a host `ClassInstance` that could
51+
// not read a field took the process down from inside `Jvm::collect_garbage` -- which already returns
52+
// `Result`. The error now reaches the caller unchanged, and nothing is freed on the way out.
53+
#[tokio::test]
54+
async fn a_field_the_host_cannot_read_fails_the_collection_instead_of_panicking() -> Result<()> {
55+
let jvm = test_jvm().await?;
56+
57+
jvm.push_native_frame();
58+
let vector = jvm.new_class("java/util/Vector", "()V", ()).await?;
59+
jvm.collect_garbage()?;
60+
61+
let unreadable = jvm.new_global_ref(&ClassInstanceRef::<()>::new(Some(Box::new(Unreadable(vector.clone())))));
62+
63+
match jvm.collect_garbage() {
64+
Err(JavaError::Unraisable(message)) => assert!(message.contains("of a dead guest object"), "{message}"),
65+
other => panic!("expected the host error back, got {other:?}"),
66+
}
67+
68+
// The failed walk freed nothing: the heap is collectable again once the object is gone.
69+
drop(unreadable);
70+
assert_eq!(jvm.collect_garbage()?, 0);
71+
let size: i32 = jvm.invoke_virtual(&vector, "java/util/Vector", "size", "()I", ()).await?;
72+
assert_eq!(size, 0);
73+
74+
Ok(())
75+
}

0 commit comments

Comments
 (0)