From c9c4d2bc62314103a713b3261eaf0fe94c7ba67d Mon Sep 17 00:00:00 2001 From: jun0 Date: Thu, 24 Sep 2026 05:04:10 +0900 Subject: [PATCH] =?UTF-8?q?[wie-2026-09-22-lgt-object-reference-gate-adopt?= =?UTF-8?q?-p0]=20fix(jvm):=20GC=20=EB=8F=84=EB=8B=AC=EC=84=B1=20=EC=88=9C?= =?UTF-8?q?=ED=9A=8C=EA=B0=80=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EB=A5=BC=20Result=20=EB=A1=9C=20=EC=A0=84=ED=8C=8C?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_reachable_objects 등 순회 4함수를 Result 로 바꿔 get_field·get_static_field·load 의 .unwrap() 3곳을 ? 로, 내부 불변식 unwrap 2곳을 JavaError::Unraisable 로 바꿨다. 오류가 나면 순회를 중단하고 아무것도 해제하지 않는다. collect_garbage 서명 불변 · 새 변종 0. Co-Authored-By: Claude Opus 5.5 --- REPORT.md | 4 + STATE.md | 1 + ...-09-24-gc-walk-propagates-host-errors.json | 14 +++ ...26-09-24-gc-walk-propagates-host-errors.md | 32 +++++++ jvm/src/garbage_collector.rs | 91 +++++++++++-------- jvm/src/jvm.rs | 2 +- jvm/tests/test_gc_host_error.rs | 75 +++++++++++++++ 7 files changed, 180 insertions(+), 39 deletions(-) create mode 100644 docs/worklog/2026-09-24-gc-walk-propagates-host-errors.json create mode 100644 docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md create mode 100644 jvm/tests/test_gc_host_error.rs diff --git a/REPORT.md b/REPORT.md index b03855a3..e62c5f40 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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 「라이브러리 코드는 패닉하지 않는다」. diff --git a/STATE.md b/STATE.md index 820e3bc0..60218d16 100644 --- a/STATE.md +++ b/STATE.md @@ -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`. diff --git a/docs/worklog/2026-09-24-gc-walk-propagates-host-errors.json b/docs/worklog/2026-09-24-gc-walk-propagates-host-errors.json new file mode 100644 index 00000000..23dcf30f --- /dev/null +++ b/docs/worklog/2026-09-24-gc-walk-propagates-host-errors.json @@ -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" + ] +} diff --git a/docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md b/docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md new file mode 100644 index 00000000..1469da29 --- /dev/null +++ b/docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md @@ -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`)은 그대로다. +- ★**중단하고, 건너뛰지 않는다**: 읽지 못한 객체가 다른 객체의 유일한 참조를 쥐고 있을 수 있으므로 «나머지만 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 경로만 잠갔다). diff --git a/jvm/src/garbage_collector.rs b/jvm/src/garbage_collector.rs index be9786f2..b914b8a6 100644 --- a/jvm/src/garbage_collector.rs +++ b/jvm/src/garbage_collector.rs @@ -1,10 +1,17 @@ -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, @@ -12,38 +19,38 @@ pub fn determine_garbage( all_class_instances: &HashSet>, classes: &BTreeMap, interned_strings: &[Box], -) -> Vec> { +) -> Result>> { 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>) { - let fields = find_all_fields(jvm, &*class.definition); +fn find_static_reachable_objects(jvm: &Jvm, class: &Class, reachable_objects: &mut HashSet>) -> Result<()> { + let fields = find_all_fields(jvm, &*class.definition)?; for field in fields { if !field.access_flags().contains(FieldAccessFlags::STATIC) { continue; @@ -52,19 +59,21 @@ 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, reachable_objects: &mut HashSet>) { +fn find_reachable_objects(jvm: &Jvm, object: &Box, reachable_objects: &mut HashSet>) -> Result<()> { let entry = reachable_objects.entry(object.clone()); if let Entry::Occupied(_) = entry { - return; + return Ok(()); } entry.insert(); @@ -72,18 +81,20 @@ fn find_reachable_objects(jvm: &Jvm, object: &Box, reachable_ 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; @@ -92,24 +103,28 @@ fn find_reachable_objects(jvm: &Jvm, object: &Box, 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> { +fn find_all_fields(jvm: &Jvm, class_definition: &dyn ClassDefinition) -> Result>> { 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) } } diff --git a/jvm/src/jvm.rs b/jvm/src/jvm.rs index 62a7fc54..83800461 100644 --- a/jvm/src/jvm.rs +++ b/jvm/src/jvm.rs @@ -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(); diff --git a/jvm/tests/test_gc_host_error.rs b/jvm/tests/test_gc_host_error.rs new file mode 100644 index 00000000..b20af209 --- /dev/null +++ b/jvm/tests/test_gc_host_error.rs @@ -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); + +impl ClassInstance for Unreadable { + fn destroy(self: Box) {} + + fn identity(&self) -> usize { + self.0.identity() + } + + fn shallow_clone(&self) -> Result> { + Ok(Box::new(self.clone())) + } + + fn class_definition(&self) -> Box { + self.0.class_definition() + } + + fn equals(&self, other: &dyn ClassInstance) -> Result { + Ok(other + .as_any() + .downcast_ref::() + .is_some_and(|other| other.identity() == self.identity())) + } + + fn get_field(&self, field: &dyn Field) -> Result { + 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(&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(()) +}