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
15 changes: 3 additions & 12 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,9 @@ jobs:
- uses: actions/checkout@v7
- run: python3 scripts/check-dod-ci-parity.py

# a checker that iterates a set prints its findings in a different order on different runs, so two
# rounds cannot diff their output — and every other axis stays green while it does. This reads the
# checkers' AST rather than re-running them. One runner, not the matrix.
script_output_order:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: python3 scripts/check-script-output-order.py

# Jvm::exception unwraps new_class(), so naming a class the loader cannot resolve panics instead
# of throwing. This compares the names against the registered protos (see the script's docstring
# for what it cannot see). One runner, not the matrix.
# Jvm::exception returns whatever new_class() failed with, so naming a class the loader cannot
# resolve raises NoClassDefFoundError instead of the intended exception. This compares the literal
# names against the registered protos. One runner, not the matrix.
named_exception_classes:
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
python3 scripts/check-dod-ci-parity.py
python3 scripts/check-named-exception-classes-are-loadable.py
python3 scripts/check-merge-dropped-symbols.py
python3 scripts/check-script-output-order.py
```
★★**이 블록은 이제 «기계가 지킨다» — `scripts/check-dod-ci-parity.py`(CI job `dod_parity`)가
이 코드블록과 `rust.yml` 을 «각각 파싱해» 대칭차를 낸다.** 어긋나면 그 자리에서 red 다.
Expand Down
19 changes: 19 additions & 0 deletions docs/worklog/2026-09-25-adoption-audit-prune.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## [2026-09-25] 09-19 예외 클래스 사슬 군살 제거 — 검사기 1개 삭제 · 보고 1개 축소 · 낡은 주석 정정 (rustjava-2026-09-19-adoption-audit-prune)

- **무엇을**: `scripts/check-script-output-order.py` 와 그 CI 잡 `script_output_order`, DoD 줄을 지웠다.
`check-named-exception-classes-are-loadable.py` 에서 블라인드 스팟 보고(비리터럴 호출처 계수)를 빼고
스캔을 `NAMED.finditer` 한 번으로 줄였다. `rust.yml`·스크립트 머리의 «unwrap → panic» 서술(#76 이후 거짓)과
`jvm.rs` 의 회차 이력 주석을 불변식만 남기고 줄였다. `test_exception_fallback_recursion.rs` 의 `asked == 2` 를
`asked < 20` 으로 풀었다.
- **왜**: 사슬 8 PR 에서 검사기가 잡은 것 0(rust.yml 최근 100회 전부 success) · 출력순서 검사기는 현 위반 0에
후속 제안도 기각됐고, 블라인드 스팟 보고가 가리키는 1곳은 테스트다. 주석의 회차 이력은 git/worklog 가 이미 가진다.
- **사용자 영향**: 없음 — 제품 동작 변경 0. 검사기 출력은 블라인드 스팟 3~4줄이 빠지고 판정 줄(43 / 852 / 268)은 같다.

### 판정 메모
- `asked == 1`(두 곳)은 **남겼다**: 카운터는 숨긴 이름에 대한 질의만 센다(`test-utils` `HidesOneClass`) —
«첫 질의에서 멈추고 다시 묻지 않는다»가 불변식 자체다. `asked == 2` 는 반복 가드가 «몇 번째에» 걸리는지를
고정하므로 «상한(20) 전에 멈췄다»로 완화했다.
- 후속 제안 접기(계약 6): 이 사슬(`2026-09-19-*`·`2026-09-20-*`)의 제안은 **전건 이미 처분**돼 있다
(12건 = 채택 10 · 09-21 기각 2) ⇒ 접을 카드 0, `.json` 을 쓰지 않았다.
- 회차 기록은 이 파일에만 적었다 — `REPORT.md`·`STATE.md` 를 동결하는 PR #97 이 열려 있어, 거기 적으면
어느 쪽이 먼저 착지하든 충돌 또는 해시 잠금 red 가 된다.
48 changes: 10 additions & 38 deletions jvm/src/jvm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,8 @@ impl Jvm {
"java/lang/Class",
];
for class_name in bootstrap_classes.iter() {
// 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.
// Nothing can be *raised* yet -- these are the classes an exception is made of -- so a gap
// is `Unraisable`, and it names the class so the host does not have to bisect this list.
let Some(class_definition) = jvm.inner.bootstrap_class_loader.load_class(&jvm, class_name).await? else {
return Err(JavaError::Unraisable(format!(
"the class set has no {class_name}, which is one of the {} classes loaded before \
Expand Down Expand Up @@ -130,35 +123,14 @@ impl Jvm {

// Everything the error path needs before anything at all can be raised.
//
// `Jvm::exception` builds a message with `java/lang/String` and then an instance of
// `java/lang/NoClassDefFoundError`, so a class set missing either cannot report even its own
// gap -- reporting the absent String needs a String, and reporting the absent reporter needs
// the reporter. Two earlier rounds each closed one of those names and each found the next by
// reading the code and guessing. Then the whole question was measured at once, by hiding every
// name construction asks the loader for, one at a time (`jvm/tests/test_error_path_class_sweep.rs`,
// 37 non-array names): five *more* recurse with no floor -- `java/lang/Throwable`,
// `java/lang/Error`, `java/lang/LinkageError`, `java/lang/CharSequence` and
// `java/lang/Comparable` -- and they are exactly the supertype and interface closure of those
// two. Of course they are: resolving a class resolves its supertypes, and a gap found *there*
// is reported with the very classes still being resolved. The closure is 9 names and the
// account of it is complete: 2 were already checked, 5 recursed, and the remaining 2
// (`java/lang/Object`, `java/io/Serializable`) are in `bootstrap_classes` above, so they fail
// before this runs -- naming themselves there, as of the round that closed that gap.
//
// So the closure is what is checked, not a list someone has to remember to extend. Asked of
// the loader directly and never through `resolve_class`, because the reporting path cannot
// report *this* failure: building the report is the thing that is missing. A bare
// `resolve_class` here would hand the question to `Jvm::exception`, which is the cycle itself
// -- measured, that is still `stack overflow, aborting`, only during construction instead of
// later.
//
// Start-up cost, counted rather than timed (the previous round measured wall clock here and
// discarded it as below this host's noise): the walk asks the loader 9 times where the two
// asserts it replaces asked twice, so construction goes from 44 loader questions to 51.
//
// Here rather than in `bootstrap_classes` above, and before the properties loop below: that
// loop is the first thing in construction that needs a String, and resolution runs class
// initialisation, which needs the thread attached above.
// `Jvm::exception` builds a `java/lang/String` message and a `java/lang/NoClassDefFoundError`
// instance, and resolving those resolves their supertype and interface closure. A gap anywhere
// in that closure cannot be reported -- the report needs the missing class -- so it would
// recurse until the stack overflows. The closure is walked here instead of a fixed list, and
// asked of the loader directly: `resolve_class` would hand the failure to `Jvm::exception`,
// which is the cycle itself. It runs after the thread is attached (initialisation needs it) and
// before the properties loop below, the first thing that needs a String.
// `jvm/tests/test_error_path_class_sweep.rs` hides each name construction asks for.
let mut pending = Vec::from(["java/lang/String".to_owned(), "java/lang/NoClassDefFoundError".to_owned()]);
let mut asked = HashSet::new();
while let Some(class_name) = pending.pop() {
Expand Down
7 changes: 4 additions & 3 deletions jvm/tests/test_exception_fallback_recursion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,15 @@ async fn a_class_set_missing_a_bootstrap_class_is_an_error_naming_it() {
// that calls `fillInStackTrace` again. Before `Jvm::exception` refused to build an exception it was
// already building on the same thread, this cap (the sweep's 20) overflowed the default 2 MiB test
// stack -- `stack overflow, aborting`, rc 134 -- and the run said nothing about the first failure. Now
// the repeat returns `Unraisable` naming the exception the thread started with, and the loader is
// asked twice: once for the first failure, once by the construction that repeated it.
// the repeat returns `Unraisable` naming the exception the thread started with. The invariant is
// that this happens before the cap: how many questions the guard needs first is its business
// (today 2 -- the first failure and the repeat), and pinning that number would pin the guard's depth.
#[tokio::test]
async fn a_failure_while_raising_is_reported_instead_of_recursing() {
let (message, asked) = unraisable_hiding("[Ljava/lang/String;", 20).await;
assert!(
message.contains("into raising java/lang/NoClassDefFoundError ([Ljava/lang/String;), which is the first failure"),
"{message}"
);
assert_eq!(asked, 2);
assert!(asked < 20, "asked {asked} times: the guard did not stop it before the cap");
}
Loading
Loading