diff --git a/REPORT.md b/REPORT.md index 9bf2727d..bf1247c8 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,13 @@ # REPORT +## [2026-09-20] 오류 경로의 클래스 집합을 «한 번에» 쟀다 — ★**답은 «둘»이 아니었다**(rustjava-string-on-the-error-path-p0) +- 무엇을: 채택 제안 `2026-09-19-string-on-the-error-path#p0`. 후보를 **유도**해(기록 로더로 정상 구성 1회 — 요청 **51** · 서로 다른 이름 **42** · 배열 **5** 제외 ⇒ **37**) 하나씩 숨겨 재구성하는 **스윕**을 만들고 돌렸다. +- ★★**결과 — 제안이 「still just these two」면 «기록된 음성»이라 했던 그 가정이 «반증»됐다**: ★**5개가 더 재귀한다**(바닥 없음) — `java/lang/Throwable` · `Error` · `LinkageError` · `CharSequence` · `Comparable`. ★**우연이 아니다** — 기존 두 이름의 **상위형·인터페이스 폐포**다(클래스를 resolve 하면 상위형도 resolve 되고, 거기 결손은 «아직 resolve 중인 그 두 클래스»로 보고된다). +- ★**처방은 목록이 아니라 «폐포»다**: 손으로 적은 assert 2개 → **작업목록 루프 1개**(씨앗 2 · `interface_names()`·`super_class_name()` 을 밀어 넣는다 · ★로더에 **직접** 묻는다 — `resolve_class` 는 질문을 `Jvm::exception` 에 넘기고 그것이 곧 순환이다). ★**폐포 9개로 계산이 닫힌다**: 이미 막힌 **2** + 재귀 **5** + `bootstrap_classes` 가 먼저 잡는 **2**(`Object`·`Serializable`) ⇒ 설명 안 되는 이름 **0**. +- ★**양방향**(제품 호출부 · 사본 아님): 정상 `37 candidate(s): 0 recursed · 12 refused by name · 25 failed cleanly` **ok** ↔ `pending.extend(...)` 두 줄 제거(= 고침 전 동작) → ★**`5 recursed` FAILED** ↔ 복원 **ok**. ★기존 잠금 2건은 **손대지 않고 통과**한다(새 문안이 `has no java/lang/String`·`has no java/lang/NoClassDefFoundError` 를 그대로 담는다). +- ★**대가**: ⒜시작 비용 **로더 질문 44 → 51**(폐포 9 ↔ 종전 2). ★**벽시계는 재지 않았다** — 전 회차가 같은 자리에서 재고 「노이즈 아래」로 버렸다. ⒝`cargo test --all` 에 스윕 **~15초**. ⒞★**배열 5개는 제외**했고 근거는 측정이다 — 로더가 배열을 **합성**하므로(`define_array_class`) 클래스 집합이 배열을 빠뜨릴 수 없다. 그래도 쟀다: `[C` **재귀**(상한 20에 21질문) · ★`[Ljava/lang/String;` 는 **그 상한에서도 스택 오버플로**(순환이 로더로 안 돌아와 상한이 못 끝낸다) ⇒ in-process 로 못 도는 유일한 후보. ⒟부트스트랩 6개를 숨기면 **`Option::unwrap()` 패닉**이라 **이름을 말하지 않는다**(12건 중 5건). +- 검증: DoD 9명령 rc 0. +- ★후속 추천 **2건**: ⒜**부트스트랩 클래스의 이름 없는 unwrap 패닉**(S) ⒝**`[Ljava/lang/String;` 의 «상한이 못 끝내는» 두 번째 순환**(M). 상세 = `docs/worklog/2026-09-20-error-path-class-closure.{md,json}`. + ## [2026-09-19] 오류 경로의 «또 하나»는 `java/lang/String` 이고 — ★**재귀한다**(rustjava-error-path-needs-java-lang-string-measure-first) - 무엇을: 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`(worklog json 기록). ★**제안의 조건이 「측정이 먼저」였고 그대로 했다** — 선행 회차가 String 에 대해 **아무것도 주장하지 않았고**(자기 worklog 에 「실측 아님」이라 적었다) 셋 중 무엇인지가 열려 있었다. - ★★**답 = ⒜ 재귀한다**(⒝ 깨끗한 실패도, ⒞ 이미 상주도 아니다). 하니스로 String 을 숨겨 이분법으로 경계를 찾았다: 상한 **116 생존 ↔ 117 `stack overflow, aborting`(SIGABRT · rc 134)** · ★**양쪽 2회씩 재현** · ★**상한 100000 도 abort** ⇒ 상한은 «하니스가 양보하는 지점»이지 **바닥이 아니다**. diff --git a/STATE.md b/STATE.md index 5d64d570..34ce6e1c 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,13 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-string-on-the-error-path-p0] ★★**오류 경로의 클래스 집합을 한 번에 쟀다 — 답은 «둘»이 아니었다.** 채택 제안 `2026-09-19-string-on-the-error-path#p0`. + ★**후보를 «유도»했다**(손 목록 아님): 기록 로더로 정상 구성 1회 → 요청 **51** · 서로 다른 이름 **42** · 배열 **5** 제외 ⇒ 후보 **37**. + ★★**5개가 더 재귀한다**: `Throwable`·`Error`·`LinkageError`·`CharSequence`·`Comparable` = 기존 두 이름의 **상위형·인터페이스 폐포**. + ★**처방 = 폐포 walk**(assert 2 → 작업목록 1 · 로더에 **직접** 질의). ★**폐포 9로 계산이 닫힌다**(막힌 2 + 재귀 5 + bootstrap 2) ⇒ 미설명 **0**. + ★**양방향**: `0 recursed` ok ↔ `extend` 2줄 제거 → **5 recursed FAILED** ↔ 복원 ok. 기존 잠금 2건 **무수정 통과**. + ★**대가**: 로더 질문 **44→51** · 스윕 **~15초** · ★배열 5개 제외(로더가 **합성**하므로 클래스 집합이 못 빠뜨린다 — 단 `[Ljava/lang/String;` 는 상한 20에서도 오버플로 = in-process 불가). + ★**후속 2건**: 부트스트랩 unwrap 이 **이름을 말하지 않는다**(S) · `[Ljava/lang/String;` 의 두 번째 순환(M). - [rustjava-error-path-needs-java-lang-string-measure-first] ★★**오류 경로의 또 하나 `java/lang/String` — ⒜ «재귀한다»로 확정하고 선재 확인을 넣었다.** 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`. ★제안의 조건이 「측정이 먼저」였고 그대로 했다. ★**실측**: 상한 **116 생존 ↔ 117 SIGABRT «stack overflow, aborting»**(양쪽 2회 재현) · ★**상한 100000 도 abort** ⇒ 바닥이 없다. ★**⒞ 아님을 구조로**: `bootstrap_classes` **6개에 String 없음** · `from_rust_class` 는 이름을 **`[B`(nameBytes)** 로 넣는다 ⇒ 처음 필요한 곳은 프로퍼티 루프. diff --git a/docs/worklog/2026-09-20-error-path-class-closure.json b/docs/worklog/2026-09-20-error-path-class-closure.json new file mode 100644 index 00000000..f13767e5 --- /dev/null +++ b/docs/worklog/2026-09-20-error-path-class-closure.json @@ -0,0 +1,61 @@ +{ + "date": "2026-09-20", + "taskId": "rustjava-string-on-the-error-path-p0", + "summary": "Adopted 2026-09-19-string-on-the-error-path#p0. Swept every class name construction asks the bootstrap loader for, one hidden at a time. The answer to 'is it still just those two?' is no: five more recurse with no floor, and they are exactly the supertype/interface closure of the two known names. Jvm::new now walks that closure instead of naming classes one at a time, and the sweep stays as the lock.", + "decision": "Check the closure, not a list. The two hand-written asserts are replaced by a worklist that asks the bootstrap loader directly for java/lang/String, java/lang/NoClassDefFoundError and every supertype and interface they carry, so a class added to the error path later brings its own prerequisites with it.", + "measurements": { + "cited_tree": "origin/main 5d732b13", + "names_recorded_during_a_normal_construction": 51, + "distinct_names": 42, + "array_names_skipped": 5, + "candidates_swept": 37, + "before_fix": { "recursed": 5, "refused_by_name": 7, "failed_cleanly": 25, "built": 0 }, + "after_fix": { "recursed": 0, "refused_by_name": 12, "failed_cleanly": 25, "built": 0 }, + "recursing_names": ["java/lang/Throwable", "java/lang/Error", "java/lang/LinkageError", "java/lang/CharSequence", "java/lang/Comparable"], + "closure_size": 9, + "closure": ["java/lang/NoClassDefFoundError", "java/lang/LinkageError", "java/lang/Error", "java/lang/Throwable", "java/lang/Object", "java/io/Serializable", "java/lang/String", "java/lang/CharSequence", "java/lang/Comparable"], + "closure_account": "2 already checked + 5 recursing + 2 (Object, Serializable) already in bootstrap_classes, so the 9 are fully accounted for", + "loader_questions_per_startup": "44 -> 51: the walk asks 9 where the two asserts it replaces asked 2", + "sweep_runtime_s": "13.98 / 15.92 / 30.79 across runs on a loaded host", + "give_up_after": 20 + }, + "verification": { + "bidirectional": "product call site jvm/src/jvm.rs, not a copy: with the two `pending.extend(...)` lines removed (i.e. the pre-fix behaviour of checking only the two names) the sweep reports `5 recursed` and FAILS; restored, it reports `0 recursed` and passes", + "existing_locks_unchanged": "jvm/tests/test_exception_fallback_recursion.rs passes untouched -- the new panic message still contains `has no java/lang/String` and `has no java/lang/NoClassDefFoundError`, which is what those two should_panic tests match", + "arrays_excluded_with_a_measurement_not_an_opinion": "the bootstrap loader synthesises array classes (define_array_class), so no class set can lack one. Measured anyway: hiding `[C` recurses (21 questions at a cap of 20) and hiding `[Ljava/lang/String;` overflows the stack at that same cap -- its recursion does not come back through the loader, so the cap cannot end it. That last one is the only candidate this sweep cannot run in-process.", + "dod": "all 9 DoD commands rc 0" + }, + "changes": [ + "jvm/src/jvm.rs: the two hand-written asserts become one worklist over the supertype/interface closure, asked of the loader directly; the NoClassDefFoundError resolve_class stays where it was", + "test-utils/src/lib.rs: RecordsRequests + test_jvm_recording (the candidate list has to come from what the loader is actually asked); test_jvm_hiding returns (Result, count) so the count survives a failing run -- the failing runs are the interesting ones", + "jvm/tests/test_error_path_class_sweep.rs: the sweep, kept as the lock" + ], + "issues": [ + "Hiding one of the six bootstrap_classes fails on `called Option::unwrap() on a None value`, which does not say which class. Bounded, so not this round's defect, but it is 5 of the 12 named refusals and the message is useless. Recorded as proposal p0.", + "Why `[Ljava/lang/String;` overflows at a cap of 20 was not chased: it is out of reach of the class-set axis. Recorded as proposal p1.", + "The sweep costs ~15s of `cargo test --all`. That is the price of the generalisation and it was not hidden." + ], + "adoptedProposals": [ + "2026-09-19-string-on-the-error-path#p0" + ], + "proposals": [ + { + "title": "Say which bootstrap class is missing instead of unwrapping on None", + "plainSummary": "If a host's class set is missing one of the six classes loaded first, the runtime dies with a message that does not name it.", + "userBenefit": "A host with a gap in its class set reads the name of the missing class instead of a bare unwrap panic, which is the same thing the error path's own check already gives for the other names.", + "why": "Measured by the sweep this round: of 37 candidates, 12 refuse by name and 5 of those 12 are `called Option::unwrap() on a None value` -- java/lang/Object, java/lang/Runnable, java/lang/Thread, java/io/Serializable and java/lang/Class, from the `bootstrap_classes` loop in Jvm::new. They fail at construction, which is correct, but the panic says nothing. Two of them (Object, Serializable) are also in the error path's closure, so the same gap gets a good message or a useless one depending only on which loop reaches it first.", + "tradeoff": "It is a one-line change to an `unwrap` and cannot go wrong, which is also the argument against doing it at all -- nothing is broken, only unhelpful. Against that: the round that has to debug it pays the whole cost, and this round has just demonstrated that reading a class-set failure without a name is exactly the expensive kind.", + "effort": "S", + "target": "jvm/src/jvm.rs" + }, + { + "title": "Find out why hiding [Ljava/lang/String; overflows the stack at a cap of 20", + "plainSummary": "One array class, when the loader refuses it, loops in a way that the test harness's escape hatch cannot stop.", + "userBenefit": "Nothing directly -- it is a bound on how much the sweep can see, and knowing it is what stops the next round from trusting a green sweep more than it should.", + "why": "Measured: hiding `[C` recurses and stops at the cap (21 questions at a cap of 20), but hiding `[Ljava/lang/String;` aborts with `stack overflow, aborting` at that same cap. The cap only ends a loop that comes back through the loader, so this one does not -- there is a second cycle shape here that the sweep's instrument cannot observe. It is out of reach of the class-set axis (array classes are synthesised, not supplied), so the sweep skips arrays and says so, but 'cannot be reached today' is a weaker claim than 'cannot exist'.", + "tradeoff": "This is curiosity about a path no host can take, and the honest expected result is a recorded explanation rather than a fix. The cost of not doing it is that the sweep's array exclusion rests on one sentence that nobody has checked against the second cycle.", + "effort": "M", + "target": "jvm/src/jvm.rs, jvm/tests/test_error_path_class_sweep.rs" + } + ] +} diff --git a/docs/worklog/2026-09-20-error-path-class-closure.md b/docs/worklog/2026-09-20-error-path-class-closure.md new file mode 100644 index 00000000..d1559567 --- /dev/null +++ b/docs/worklog/2026-09-20-error-path-class-closure.md @@ -0,0 +1,70 @@ +# 2026-09-20 — 오류 경로의 클래스 집합을 «한 번에» 쟀다 · 답은 «둘이 아니었다» + +채택 제안 `2026-09-19-string-on-the-error-path#p0`. +티켓 `rustjava-string-on-the-error-path-p0` · 인용 트리 `origin/main 5d732b13`. + +## 제안이 물은 것 + +> 「This round and the one before it each closed exactly one class, and each found the next by +> **reading the code and guessing** … A sweep that **hides each bootstrap-reachable name in turn** +> and records which ones recurse would answer the whole question once.」 +> tradeoff — 「If it turns out the answer is **still just these two**, the result is a **recorded +> negative** rather than a new check.」 + +## 답 — ★**「still just these two」가 아니다** + +기록 로더로 정상 구성을 한 번 돌려 후보를 «유도»했다(손으로 적지 않았다): 요청 **51건** · 서로 다른 이름 **42** · +배열 이름 **5** 제외 ⇒ 후보 **37**. 그 37개를 하나씩 숨겨 재구성했다. + +| 분류 | 고침 전 | 고침 후 | +|---|---|---| +| ★**재귀(바닥 없음)** | **5** | ★**0** | +| 이름을 들어 거절 | 7 | **12** | +| 한 번 묻고 깨끗이 실패 | 25 | 25 | +| 없어도 구성됨 | 0 | 0 | + +★**재귀한 5개** = `java/lang/Throwable` · `java/lang/Error` · `java/lang/LinkageError` · +`java/lang/CharSequence` · `java/lang/Comparable`. +★**그 5개는 우연이 아니다** — 기존에 막아 둔 두 이름의 **상위형·인터페이스 폐포(closure)** 다. +클래스를 resolve 하면 상위형도 resolve 되고, ★**거기서 난 결손은 «아직 resolve 중인 바로 그 두 클래스»로 보고된다.** + +## 처방 — 목록이 아니라 «폐포»를 본다 + +`Jvm::new` 의 손으로 적은 assert 두 개를 **작업목록 루프 하나**로 바꿨다. 씨앗은 그 두 이름이고, +얻은 정의의 `interface_names()`·`super_class_name()` 을 계속 밀어 넣는다. 로더에 **직접** 묻는다 — +`resolve_class` 로 물으면 그 질문이 `Jvm::exception` 으로 가고, 그것이 곧 순환이다. + +★**폐포는 9개이고 계산이 «닫힌다»**: `NoClassDefFoundError · LinkageError · Error · Throwable · +Object · Serializable · String · CharSequence · Comparable` += **이미 막혀 있던 2** + **재귀한 5** + **`bootstrap_classes` 가 먼저 잡는 2**(`Object`·`Serializable`). +⇒ 설명되지 않는 이름이 **0** 이다. + +## 양방향 — 제품 호출부에서(사본 아님) + +| | 개악 | 결과 | +|---|---|---| +| **정상** | 없음 | `37 candidate(s): 0 recursed · 12 refused by name · 25 failed cleanly · 0 not needed` · **ok** | +| **개악** | `jvm/src/jvm.rs` 의 `pending.extend(...)` 두 줄 제거(= 두 이름만 보던 고침 전 동작) | ★**`5 recursed` · FAILED** | +| **복원** | 되돌림 | **0 recursed · ok** | + +★기존 잠금 `jvm/tests/test_exception_fallback_recursion.rs` **2건은 손대지 않고 그대로 통과**한다 — +새 패닉 문안이 `has no java/lang/String` · `has no java/lang/NoClassDefFoundError` 를 그대로 담기 때문이다. + +## 대가 — 숨기지 않는다 + +- ★**시작 비용**: 로더 질문 **44 → 51**(폐포 9회 ↔ 종전 2회). 벽시계는 재지 «않았다» — + 전 회차가 같은 자리에서 재고 「이 호스트의 노이즈 아래」로 버렸다. **결정론적 수로만 말한다.** +- ★**`cargo test --all` 에 스윕 ~15초**가 붙는다. 일반화의 값이고, 깎지 않았다. +- ★**배열 이름 5개는 스윕에서 제외**했고 그 이유는 «의견이 아니라 측정»이다: 부트스트랩 로더가 + 배열을 **합성**한다(`define_array_class`) ⇒ 어떤 클래스 집합도 배열을 «빠뜨릴» 수 없다. + 그래도 재 봤다 — `[C` 는 **재귀**(상한 20에 21질문) · ★**`[Ljava/lang/String;` 는 «그 상한에서도» + 스택 오버플로**다(= 그 순환은 로더로 돌아오지 않아 상한이 끝내지 못한다). ⇒ ★**이 스윕이 in-process 로 + 돌릴 수 없는 유일한 후보**이고, 제안 `#p1` 로 남겼다. +- ★**부트스트랩 6개를 숨기면 `called Option::unwrap() on a None value`** 로 죽는다 — 구성 시점에 + 멈추니 옳지만 **이름을 말하지 않는다**. 12건 중 **5건**이 그것이다. 이 회차의 결함이 아니라 별 계급이라 + 제안 `#p0` 으로 남겼다. + +## 왜 지금인가 + +전 두 회차가 **각각 한 클래스씩** 닫았고 다음 이름을 **읽고 추측해** 찾았다. 그 속도로는 폐포 9개를 닫는 데 +회차 7번이 더 든다 — 그리고 ★**그 방식은 「이게 마지막인가」에 영원히 답하지 못한다.** 이번에 기계가 답했다. diff --git a/jvm/src/jvm.rs b/jvm/src/jvm.rs index 2e78cca1..30f04f81 100644 --- a/jvm/src/jvm.rs +++ b/jvm/src/jvm.rs @@ -105,29 +105,59 @@ impl Jvm { class.set_java_class(java_class); } - // Resolve the *other* class the error path needs, for the same reason and with the same - // shape as the `java/lang/NoClassDefFoundError` check below. `Jvm::exception` builds its - // message with `JavaLangString::from_rust_string` *before* it builds the exception instance, - // so a class set without `java/lang/String` cannot report anything either: reporting the - // missing String needs a String. Measured against a loader that hides it -- 116 round trips - // survived, 117 died of `stack overflow, aborting` (SIGABRT), and the boundary reproduced - // exactly across repeats. The cap is the harness giving the real class up, not a floor: - // raising it to 100000 aborts just the same, so there is no floor. + // Everything the error path needs before anything at all can be raised. // - // Here rather than beside that check, because this one has to come *first*: the properties - // loop directly below is the first thing in construction that needs a String, and the check - // below it runs too late to be reached. Same reason it cannot go in `bootstrap_classes` - // above -- resolution runs class initialisation, which needs the thread attached above. - // Asked of the loader directly first, because the reporting path cannot report *this* - // failure: building the report is the thing that is missing. - assert!( - jvm.inner.bootstrap_class_loader.load_class(&jvm, "java/lang/String").await?.is_some(), - "the class set has no java/lang/String, which every raised error needs before it can \ - exist: an exception carries a message, and building that message is the first thing \ - the reporting path does. Nothing can be raised without it -- reporting the absent \ - String would itself need a String, and that recursion has no floor (measured: 117 \ - round trips, then the process aborts on a stack overflow). Add it to the class set." - ); + // `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 -- on an `unwrap` that does not say which class, which is a smaller defect + // than this one and is left recorded rather than fixed here. + // + // 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. + 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() { + if !asked.insert(class_name.clone()) { + continue; + } + let Some(definition) = jvm.inner.bootstrap_class_loader.load_class(&jvm, &class_name).await? else { + panic!( + "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 \ + them their supertypes and interfaces. A name missing from that closure cannot be \ + reported, because reporting it needs the same classes that are still being resolved, \ + 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()); + } jvm.resolve_class("java/lang/String").await?; // init properties @@ -148,34 +178,14 @@ impl Jvm { // load system class loader JavaLangClassLoader::get_system_class_loader(&jvm).await?; - // Resolve the class the error path reports *with*, so that path cannot eat its own tail. - // A class the loader cannot provide is reported by - // `Jvm::exception("java/lang/NoClassDefFoundError", …)`, and building that exception goes - // back through the loader. If the loader cannot provide *that* class either, the two call - // each other with no floor -- measured against a loader that hides it: 121 round trips - // survived and somewhere before 160 the process died of `stack overflow, aborting` - // (SIGABRT). Resolving it once here registers it, so afterwards the registry answers and the - // loader is never asked again: the cycle stops being reachable rather than being bounded. - // - // Here rather than in `bootstrap_classes` above because resolution runs class - // initialisation, which needs the thread attached below that list (measured: adding it there - // panicked on the missing thread frame). - // Asked of the loader directly first, because the reporting path cannot report *this* - // failure: building the report is what 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. One direct question - // turns the same condition into an immediate, named failure. - assert!( - jvm.inner - .bootstrap_class_loader - .load_class(&jvm, "java/lang/NoClassDefFoundError") - .await? - .is_some(), - "the class set has no java/lang/NoClassDefFoundError, which is the class this runtime \ - reports every other missing class with. Nothing can be raised without it: the reporter \ - would be asked to report its own absence, and that recursion has no floor (measured: \ - 121 round trips, then the process aborts on a stack overflow). Add it to the class set." - ); + // Resolve the class the error path reports *with*, so that path cannot eat its own tail: a + // class the loader cannot provide is reported by + // `Jvm::exception("java/lang/NoClassDefFoundError", …)`, and building that exception goes back + // through the loader. Resolving it once here registers it, so afterwards the registry answers + // and the loader is never asked again -- the cycle stops being reachable rather than being + // bounded. Its *presence* was already established by the closure walk above; this is only the + // resolution, and it stays here because it runs class initialisation, which needs the system + // class loader above it. jvm.resolve_class("java/lang/NoClassDefFoundError").await?; jvm.inner.bootstrapping.store(false, Ordering::Relaxed); diff --git a/jvm/tests/test_error_path_class_sweep.rs b/jvm/tests/test_error_path_class_sweep.rs new file mode 100644 index 00000000..58c65402 --- /dev/null +++ b/jvm/tests/test_error_path_class_sweep.rs @@ -0,0 +1,120 @@ +//! Every class construction asks the loader for, hidden one at a time, and what that does. +//! +//! Two earlier rounds each closed exactly one class on the error path -- `java/lang/String` and +//! `java/lang/NoClassDefFoundError` -- and each found the next by reading the code and guessing. +//! Nothing enumerated the candidates, so "is that all of them?" had no answer. This asks the whole +//! question at once: build a JVM with a loader that records every name it is asked for, then rebuild +//! it once per name with that name hidden, and classify what happens. +//! +//! The first run of this sweep answered it: **no**. Of 37 non-array names, five more recursed with +//! no floor -- `java/lang/Throwable`, `java/lang/Error`, `java/lang/LinkageError`, +//! `java/lang/CharSequence` and `java/lang/Comparable` -- which is the supertype and interface +//! closure of the two known ones, less the two of that closure that `bootstrap_classes` already +//! loads. Before: `5 recursed · 7 refused by name · 25 failed cleanly`. After: `0 · 12 · 25`. +//! `Jvm::new` now walks that closure, and this file is what keeps the answer true: a class added to +//! the error path later brings its own supertypes with it, and they land here without anyone +//! thinking to ask. +//! +//! Array names are skipped, and the reason is not squeamishness: the bootstrap loader *synthesises* +//! them (`define_array_class`), so no class set can be missing one and hiding them measures a loader +//! that refuses to build an array rather than a gap a host can have. It is not a free skip -- hiding +//! `[C` recurses (21 questions at a cap of 20) and hiding `[Ljava/lang/String;` overflows the stack +//! *at that same cap*, i.e. its recursion does not come back through the loader at all, so the cap +//! cannot end it. That is the one candidate class this sweep cannot run in-process, and it is out of +//! reach of the class-set axis, so it is recorded here rather than guarded. + +use std::{ + future::Future, + panic::{self, AssertUnwindSafe}, + sync::atomic::Ordering, +}; + +use test_utils::{test_jvm_hiding, test_jvm_recording}; + +/// Low on purpose. A name on the error path comes back for the cap's worth of questions and then the +/// harness relents, so the recursion is *observed* rather than ridden into a stack overflow -- the +/// measured floors are 117 and 121 round trips, far above this. +const GIVE_UP_AFTER: u32 = 20; + +#[derive(Debug, PartialEq)] +enum Outcome { + /// Came back for every question the cap allowed: the reporting path needs this class to report + /// that this class is missing. Without a check, the real loader never relents and the process + /// aborts on a stack overflow. + Recursed, + /// Construction refused, naming the class. What the check produces. + Panicked, + /// Construction returned an error after a single question. Also fine: nothing looped. + Failed, + /// Construction succeeded without the class. It is asked for but not needed. + Built, +} + +fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(future) +} + +fn hide(name: &str) -> Outcome { + match panic::catch_unwind(AssertUnwindSafe(|| block_on(test_jvm_hiding(name, GIVE_UP_AFTER)))) { + Err(_) => Outcome::Panicked, + Ok((result, requests)) => { + if requests.load(Ordering::SeqCst) > GIVE_UP_AFTER { + Outcome::Recursed + } else if result.is_err() { + Outcome::Failed + } else { + Outcome::Built + } + } + } +} + +// A plain `#[test]`, not `#[tokio::test]`: each candidate gets its own runtime inside `block_on`, +// and nesting one runtime in another panics before any of this can run. +#[test] +fn no_class_construction_asks_for_can_only_be_reported_with_itself() { + let (_jvm, names) = block_on(test_jvm_recording()).unwrap(); + let mut candidates = names.lock().unwrap().clone(); + candidates.sort(); + candidates.dedup(); + candidates.retain(|name| !name.starts_with('[')); + assert!( + candidates.len() > 30, + "the recording loader saw {} names -- too few to be the whole of construction", + candidates.len() + ); + + // The hidden-class runs panic by design; their default output would bury the report below. + let hook = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let outcomes = candidates.iter().map(|name| (name.clone(), hide(name))).collect::>(); + panic::set_hook(hook); + + // Never silent: the counts are the measurement, and a green run that reports nothing cannot be + // told apart from a green run that looked at nothing. + let count = |want| outcomes.iter().filter(|(_, outcome)| *outcome == want).count(); + println!( + "{} candidate(s): {} recursed · {} refused by name · {} failed cleanly · {} not needed", + outcomes.len(), + count(Outcome::Recursed), + count(Outcome::Panicked), + count(Outcome::Failed), + count(Outcome::Built) + ); + + let recursed = outcomes + .iter() + .filter(|(_, outcome)| *outcome == Outcome::Recursed) + .map(|(name, _)| name.as_str()) + .collect::>(); + assert!( + recursed.is_empty(), + "hiding {recursed:?} makes construction ask for the same class more than {GIVE_UP_AFTER} times: \ + reporting the gap needs the class that is missing. Add it to the closure `Jvm::new` walks, or \ + explain here why it cannot be reached through a class set." + ); +} diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index b560d081..e68a1d0f 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -453,7 +453,12 @@ where } /// A JVM whose bootstrap loader cannot produce `hidden`, plus the counter of how often it was asked. -pub async fn test_jvm_hiding(hidden: &str, give_up_after: u32) -> Result<(Jvm, Arc)> { +/// +/// The count is returned *beside* the result rather than inside it, because the interesting runs are +/// the failing ones: a class the error path needs makes construction come back for it over and over, +/// and then end in an error. Returning `Result<(Jvm, count)>` threw the count away on exactly those +/// runs, which are the ones the sweep in `jvm/tests/test_error_path_class_sweep.rs` reads. +pub async fn test_jvm_hiding(hidden: &str, give_up_after: u32) -> (Result, Arc) { let runtime = TestRuntime::new(BTreeMap::new()); let requests = Arc::new(AtomicU32::new(0)); let loader = HidesOneClass { @@ -463,8 +468,44 @@ pub async fn test_jvm_hiding(hidden: &str, give_up_after: u32) -> Result<(Jvm, A give_up_after, }; let properties = [("java.class.path", ".")].into_iter().collect(); + let jvm = Jvm::new(loader, move || runtime.current_task_id(), properties).await; + (jvm, requests) +} + +/// A bootstrap loader that answers normally and records every name it is asked for, in order. +/// +/// `HidesOneClass` above can measure any single name cheaply, but nothing enumerated the names worth +/// measuring -- the two classes the error path is known to need were each found by a round reading +/// the code and guessing the next one. This is the other half: the candidate list has to come from +/// what the loader is *actually* asked for while a JVM is built, because a hand-written list goes +/// stale the first time construction reaches for one more class, and silently. +pub struct RecordsRequests { + inner: C, + names: Arc>>, +} + +#[async_trait::async_trait] +impl jvm::BootstrapClassLoader for RecordsRequests +where + C: jvm::BootstrapClassLoader, +{ + async fn load_class(&self, jvm: &Jvm, name: &str) -> Result>> { + self.names.lock().unwrap().push(name.to_owned()); + self.inner.load_class(jvm, name).await + } +} + +/// A JVM built normally, plus every class name its bootstrap loader was asked for, in order. +pub async fn test_jvm_recording() -> Result<(Jvm, Arc>>)> { + let runtime = TestRuntime::new(BTreeMap::new()); + let names = Arc::new(Mutex::new(Vec::new())); + let loader = RecordsRequests { + inner: get_bootstrap_class_loader(Box::new(runtime.clone())), + names: names.clone(), + }; + let properties = [("java.class.path", ".")].into_iter().collect(); let jvm = Jvm::new(loader, move || runtime.current_task_id(), properties).await?; - Ok((jvm, requests)) + Ok((jvm, names)) } pub async fn test_jvm() -> Result {