From 38cbab7e84e7e16d4c53476f9c13a9530c428525 Mon Sep 17 00:00:00 2001 From: jun0 Date: Fri, 18 Sep 2026 16:36:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[rustjava-lock-every-named-exception-class-?= =?UTF-8?q?is-loadable]=20test(ci):=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=80=EB=A5=B4=EB=8A=94=20=EC=98=88=EC=99=B8=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EA=B0=80=20=EC=8B=A4=EC=9D=84=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EA=B2=83=EC=9D=B8=EC=A7=80=20=ED=95=9C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EC=97=90=EC=84=9C=20=EB=8C=80=EC=A1=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/rust.yml | 9 + CLAUDE.md | 1 + REPORT.md | 12 ++ STATE.md | 8 + ...-named-exception-classes-are-loadable.json | 48 ++++++ ...18-named-exception-classes-are-loadable.md | 95 +++++++++++ ...ck-named-exception-classes-are-loadable.py | 154 ++++++++++++++++++ 7 files changed, 327 insertions(+) create mode 100644 docs/worklog/2026-09-18-named-exception-classes-are-loadable.json create mode 100644 docs/worklog/2026-09-18-named-exception-classes-are-loadable.md create mode 100755 scripts/check-named-exception-classes-are-loadable.py diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ba4932c0..9246f299 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -69,3 +69,12 @@ jobs: steps: - uses: actions/checkout@v7 - run: python3 scripts/check-dod-ci-parity.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. + named_exception_classes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: python3 scripts/check-named-exception-classes-are-loadable.py diff --git a/CLAUDE.md b/CLAUDE.md index 6ceb79d2..2777e357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ cargo test --all python3 scripts/check-worklog-json.py python3 scripts/check-dod-ci-parity.py + python3 scripts/check-named-exception-classes-are-loadable.py ``` ★★**이 블록은 이제 «기계가 지킨다» — `scripts/check-dod-ci-parity.py`(CI job `dod_parity`)가 이 코드블록과 `rust.yml` 을 «각각 파싱해» 대칭차를 낸다.** 어긋나면 그 자리에서 red 다. diff --git a/REPORT.md b/REPORT.md index 30b7c752..0cc43d0e 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,16 @@ # REPORT +## [2026-09-18] 이름으로 부르는 예외 클래스가 «실을 수 있는» 것인가 — 한 자리에서 대조한다 (rustjava-lock-every-named-exception-class-is-loadable) +- 무엇을: 채택 제안 `2026-09-17-string-concat-recipe-arity#p0`(worklog json `adoptedProposals` 기록). 산출물 = `scripts/check-named-exception-classes-are-loadable.py` **한 자리** + CI job + DoD 한 줄. ★**런타임 클래스 추가 0 · `.unwrap()` 무접촉.** +- ★**전제를 코드로 확인했다**(총괄 선실측 없음): `jvm/src/jvm.rs:943-950` 의 `new_class(...).await.`★**`unwrap()`** ⇒ 부트스트랩 로더가 이름을 못 풀면 **Java 예외가 아니라 프로세스가 죽는다**. ★**자기 참조다** — `:842` 가 클래스 부재를 `exception("java/lang/NoClassDefFoundError", …)` 로 보고하므로 **오류 경로 자신의 클래스**가 실려야 한다. +- ★**베이스라인 실측**: `exception(` 리터럴 클래스명 **41 고유 / 호출부 812** ↔ `loader.rs` 등재 **265항목(고유 263) · 이름 해석 265/265** ⇒ ★**못 싣는 이름 «0»**(제안의 「baseline is now 0」 재현). + ★**제안의 「72」는 재현되지 않았다** — 내 술어는 「`exception(` 첫 인자 리터럴·고유」로 **41**이다. ★**재현 못 한 수는 인용하지 않았다.** +- ★**실을 수 있는 집합 = «등재분»이지 «`name:` 리터럴 전수»가 아니다** — 정의는 됐는데 미등재인 이름이 **5건** 있다(전부 `exception(` 밖이라 베이스라인은 어느 쪽이든 0). +- ★**양방향 4축**: ⒜현 상태 **rc=0** ⒝★**⑶ 실제 사례 재현** — `BootstrapMethodError` 등재 1줄 제거 → **rc=1**(`interpreter.rs:1109` 지목) ⒞프로토 이름 오타 → **rc=1**(321 호출부) ⒟★**fail-closed** — 해석 불가 등재 → **rc=2 «못 쟀다»**(집합을 조용히 줄여 false red 를 내지 않는다). 배선도 양방향 — CI step 제거 시 `dod_parity` **rc=1**. +- ★**못 보는 것**(바닥이지 증명이 아니다): ★**런타임 조립 이름**(`format!`·상수·변수)은 **안 보인다** · `exception(` 만 훑는다(`new_class(`·`find_class(` 는 `Result` 를 돌려주므로 축이 다르다) · 초기화 실패는 통과 · 다른 실재 클래스와 겹치는 오타는 통과. +- ★**잃는 것**: DoD 명령 **6 → 7**(실측 **~1초**/회차 · 초판 32.7초를 `target/` 가지치기로 없앴다) · ★**「green 이니 패닉 없다」는 거짓**(위 구멍) · ★**`.unwrap()` 은 그대로**라 새는 이름이 생기면 여전히 패닉한다(전환은 범위 밖). +- 검증: `cargo test --all` **rc=0** · `check-dod-ci-parity` **「명령 7개 · toolchain 2개」 rc=0** · `check-worklog-json` rc=0. +- ★후속 추천: ⑴**리터럴이 «아닌» `exception(` 호출부를 세라**(S — 구멍의 크기를 아직 모른다) ⑵**`.unwrap()` → throw 전환**(M · 이 회차가 명시적으로 범위 밖으로 둔 것). 상세 = `docs/worklog/2026-09-18-named-exception-classes-are-loadable.md`. + ## [2026-09-18] 「어느 bootstrap argument 가 왜 나빴나」 — ★**대전제 ⓒ 에서 끝난다: 그 일을 하는 축이 이미 떠 있다**(rustjava-adopt-loadable-bootstrap-arguments-diagnostic) - 무엇을: 채택 제안 `2026-09-17-loadable-bootstrap-arguments#p0` 의 처분(worklog json `adoptedProposals` 기록). ★**코드 0행** — `classfile/src/{error,validation}.rs` **무접촉**. - ★**제안의 전제는 참이다 — CLI 로 돌려서 봤다**(`main` @ `8c7b473f`): 서로 다른 세 규칙(`LdcDynamicBSMArgPastEnd` 나쁜 argument · `LdcDynamicDuplicateBSM` 중복 속성 · `LdcDynamicOldMajor` 버전 게이트)이 ★**글자 하나 다르지 않은 `java.lang.ClassFormatError: Invalid class file`** 를 낸다. diff --git a/STATE.md b/STATE.md index 271a6043..43d118ef 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,14 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-lock-every-named-exception-class-is-loadable] ★★**이름으로 부르는 예외 클래스가 «실을 수 있는» 것인가 — 대조 한 자리.** 채택 제안 `2026-09-17-string-concat-recipe-arity#p0`(worklog json 기록). + ★**전제 확인(코드)**: `jvm/src/jvm.rs:943-950` `new_class(...).await.`**`unwrap()`** ⇒ 못 싣는 이름은 **throw 가 아니라 패닉**. ★자기 참조 — `:842` 가 부재를 `exception("java/lang/NoClassDefFoundError")` 로 보고한다. + ★**베이스라인 0**: `exception(` 리터럴 **41 고유 / 812 호출부** ↔ 등재 프로토 **265(고유 263) · 해석 265/265**. ★제안의 「72」는 **재현 안 됨** ⇒ 내 수(41)를 적었다. + ★**실을 수 있는 집합 = 등재분**(미등재 `name:` 5건 존재 — 전부 `exception(` 밖). + ★**양방향**: 현 상태 rc=0 · ★`BootstrapMethodError` 등재 제거 → **rc=1**(실제 패닉 사례 재현) · 프로토 이름 오타 → rc=1 · ★해석 불가 등재 → **rc=2(못 쟀다 · fail-closed)** · CI step 제거 → `dod_parity` rc=1. + ★**못 보는 것**: **런타임 조립 이름 안 보임**(바닥이지 증명 아님) · `exception(` 만 · 초기화 실패 통과. + ★**잃는 것**: DoD **6→7**(~1초 · 초판 32.7초는 `target/` 가지치기로 해소) · ★`.unwrap()` 무접촉(전환은 범위 밖). + ★`--all` rc=0 · `dod-ci-parity` **명령 7개** rc=0. - [rustjava-adopt-loadable-bootstrap-arguments-diagnostic] ★★**대전제 ⓒ 에서 끝났다 — 그 일을 하는 축(PR #67)이 이미 떠 있다.** 채택 제안 `2026-09-17-loadable-bootstrap-arguments#p0`(worklog json 기록). ★**코드 0행.** ★**전제는 참**(CLI 실행: 세 규칙이 전부 `ClassFormatError: Invalid class file` 동일 문면) — 그러나 ★**편집 영역은 전부 겹친다**(전달된 값은 **3 중 1** — 아래): #67 이 제안 `target` 두 파일을 고치고, 이 술어에 **이미 사유를 주며**, 밋밋한 문면 **두 자리**를 둘 다 고쳤다. ★제안 `tradeoff` 자신이 「두 번 하지 말고 함께 하라」고 적었다. ★**남는 잔여는 좁다** — `&'static str` 이라 **인덱스를 못 담는다** ⇒ 「기대」 달성 · 「인덱스·실제」 미달 ⇒ ★**새 카드를 냈다**(★**M** — 초판 `S` 에서 **실측 후 올렸다**: 잔여도 **같은 3층**을 건너 `target` **5파일/4크레이트**(`classfile`·`jvm-bytecode`·`RustJava`·`test-utils` — ★층 3 ↔ 크레이트 4: 경계 층이 두 크레이트에 걸친다) · `InvalidFormat` 을 넓히면 생성 17 + 매치 11 이라 **새 variant** 를 고르고 **두 갈래의 대가**를 적었다). diff --git a/docs/worklog/2026-09-18-named-exception-classes-are-loadable.json b/docs/worklog/2026-09-18-named-exception-classes-are-loadable.json new file mode 100644 index 00000000..2410b219 --- /dev/null +++ b/docs/worklog/2026-09-18-named-exception-classes-are-loadable.json @@ -0,0 +1,48 @@ +{ + "date": "2026-09-18", + "taskId": "rustjava-lock-every-named-exception-class-is-loadable", + "summary": "Jvm::exception ends in new_class(...).unwrap(), so naming a class the bootstrap loader cannot resolve panics instead of throwing. This round added one cross-check, scripts/check-named-exception-classes-are-loadable.py, wired into the DoD block and a CI job. It compares the class names the Rust code writes as literals in exception(...) against the protos actually registered in rustjava-runtime/src/loader.rs. No runtime class was added and the .unwrap() was not touched.", + "changes": [ + "scripts/check-named-exception-classes-are-loadable.py — new. Two sets: literal java//javax/ names in the first argument of exception(, and the name: field of every type registered via ::as_proto() in loader.rs. Exit 0 all loadable, 1 something is not, 2 cannot measure.", + ".github/workflows/rust.yml — new job named_exception_classes, one runner like worklog_json and dod_parity rather than the 6-cell matrix.", + "CLAUDE.md — the DoD block gains the command, which is what check-dod-ci-parity.py then locks against the workflow." + ], + "verification": [ + "PREMISE CONFIRMED BY READING THE CODE, not by quoting the proposal: jvm/src/jvm.rs:943-950 is `let instance = self.new_class(r#type, \"(Ljava/lang/String;)V\", (message_str,)).await.unwrap();`. new_class returns Result, so an unresolvable name aborts. The axis is self-referential: jvm.rs:842 reports a missing class by calling exception(\"java/lang/NoClassDefFoundError\", ...), so the error path's own class must be loadable or the report panics.", + "BASELINE, measured this round: 41 distinct literal names across 812 exception( call sites; loader.rs registers 265 ::as_proto() entries (263 distinct types), all 265 resolved back to a name: literal; names named but not loadable = 0. The proposal's 'baseline is now 0' reproduces.", + "THE PROPOSAL'S 72 DID NOT REPRODUCE. This round counts 41, using 'distinct literal first argument of exception('. 72 is presumably a different predicate (call sites, or new_class included); it was not reproduced, so this round writes its own number rather than quoting one it cannot stand behind.", + "LOADABLE SET IS THE REGISTERED ONE, NOT EVERY name: LITERAL. Five names exist under classes/ but are absent from loader.rs — java/util/AbstractList$ListItr, java/util/ArrayList$ListItr, java/util/Vector$ListItr, java/util/logging/Formatter, org/rustjava/net/JarURLConnection — so using every literal would overstate what the loader can return. None of the five is named in exception(, so the baseline is 0 either way.", + "BIDIRECTIONAL: current tree rc=0 (41 named, 263 loadable). Removing the BootstrapMethodError registration from loader.rs — the case that actually panicked — gives rc=1 naming java/lang/BootstrapMethodError at jvm-bytecode/src/interpreter.rs:1109. Renaming the NullPointerException proto while leaving it registered gives rc=1 with 321 call sites. Adding a registered entry whose type cannot be resolved gives rc=2 'cannot measure', not a false red. Restoring gives rc=0.", + "WIRING MEASURED BOTH WAYS: deleting the new CI step makes check-dod-ci-parity.py rc=1 (FAIL 대칭차 있음: 축 A); restoring gives rc=0 with 'commands 7, toolchains 2'.", + "COST: 0.80s / 0.98s / 0.91s over three runs on a loaded machine. The first draft took 32.7s because rglob walked target/; pruning target and .git at the top level removed that, and the reason is in a comment so it does not get re-introduced." + ], + "issues": [ + "IT IS A FLOOR, NOT A PROOF. A name assembled at run time (format!, a const, a variable, a match arm) is not a literal at the call site and is invisible. Reading the check as 'green means no panic' is wrong.", + "Only exception( is scanned. new_class( and find_class( are out of scope because they hand the Result back rather than unwrapping it, which is why the panic lives on this path.", + "A registered proto that fails to initialise at run time still passes; only name resolvability is checked. A typo that collides with another real class also passes.", + "The .unwrap() itself was not changed. This round removes what there is to unwrap on; it does not remove the unwrap. If a name escapes through the hole above it still panics.", + "DoD grows from 6 commands to 7, and check-dod-ci-parity.py now carries that symmetry too. Measured cost is ~1s per local round.", + "OPEN PR INTERACTION: PR #71 also adds a DoD command and a CI job, touching the same CLAUDE.md block and the same region of rust.yml. Whichever lands second will conflict there, and the resolution must be a union of both commands or dod_parity goes red." + ], + "adoptedProposals": ["2026-09-17-string-concat-recipe-arity#p0"], + "proposals": [ + { + "title": "Count the exception() call sites whose class name is not a literal", + "plainSummary": "The new check only sees class names written out in full; nobody knows yet how many are built at run time instead, so we cannot say how big the blind spot is.", + "userBenefit": "A name assembled at run time can still name a class the runtime cannot load, and that still ends as a crash rather than a Java exception — exactly the failure the new check was meant to close.", + "why": "The check landed this round reads the first argument of exception( only when it is a string literal, and 41 distinct names across 812 call sites pass that filter. What is not known is the denominator: how many call sites pass something else. Until that number exists, 'the check is a floor' is a statement without a size, and nobody can judge whether closing the gap is worth anything.", + "tradeoff": "Counting is cheap, but acting on the count is not: forcing literals would mean a constant table and touching every non-literal call site, and some of those names genuinely depend on runtime state. The count may also show the gap is tiny, in which case the right answer is to write the number down and stop — which is still worth the measurement.", + "effort": "S", + "target": "scripts/check-named-exception-classes-are-loadable.py, jvm/src/jvm.rs" + }, + { + "title": "Make Jvm::exception throw instead of unwrapping", + "plainSummary": "When the runtime cannot build the exception it wants to raise, it crashes the process; it should report the failure the Java way instead.", + "userBenefit": "A JVM must not abort on an error path. Today an unresolvable class name takes the whole process down, and no amount of checking source text can guarantee that never happens.", + "why": "jvm/src/jvm.rs:943-950 unwraps new_class(). The lock added this round makes the input set safe as far as literals go, but it is explicitly a floor — a name built at run time bypasses it entirely. The last line of defence is still missing, and it is one Result away.", + "tradeoff": "exception() currently returns JavaError, not Result, so every caller changes — 812 call sites reference it. There is also a genuine question of what to throw when the class you wanted to throw is itself unavailable, which is the self-referential case at jvm.rs:842; picking a wrong answer there turns one crash into an infinite recursion.", + "effort": "M", + "target": "jvm/src/jvm.rs, jvm-bytecode/src/interpreter.rs, rustjava-runtime/src/classes/" + } + ] +} diff --git a/docs/worklog/2026-09-18-named-exception-classes-are-loadable.md b/docs/worklog/2026-09-18-named-exception-classes-are-loadable.md new file mode 100644 index 00000000..c24a8eb5 --- /dev/null +++ b/docs/worklog/2026-09-18-named-exception-classes-are-loadable.md @@ -0,0 +1,95 @@ +# 이름으로 부르는 예외 클래스가 «실을 수 있는» 것인가 — 한 자리에서 대조한다 + +채택 제안 `2026-09-17-string-concat-recipe-arity#p0`(운영자가 tower 「추천 후속 작업」에서 채택). + +## ⓐ 주장을 «내가» 먼저 확인했다 — 재현된다 + +제안의 주장: 「런타임이 자기가 갖고 있지 않은 예외 클래스를 올리려다 **throw 가 아니라 panic** 했다」. + +**기전은 코드에 그대로 있다**(`jvm/src/jvm.rs:943-950`): +```rust +pub async fn exception(&self, r#type: &str, message: &str) -> JavaError { + let instance = self.new_class(r#type, "(Ljava/lang/String;)V", (message_str,)).await.unwrap(); + // ^^^^^^^^ +``` +`new_class` 는 `Result` 를 돌려주고, 부트스트랩 로더가 이름을 못 풀면 `Err` 다 ⇒ ★**`.unwrap()` 이 패닉한다.** +Java 예외가 되는 것이 아니라 **프로세스가 죽는다.** + +★**그리고 이 축은 «자기 참조»다** — `jvm/src/jvm.rs:842` 가 클래스 부재를 보고하는 방식이 +`return Err(self.exception("java/lang/NoClassDefFoundError", class_name).await);` 다. +⇒ **오류 경로 자신의 클래스가 실리지 않으면 «보고»가 패닉한다.** + +**실을 수 있는 집합의 정본**은 `rustjava-runtime/src/loader.rs` 의 `protos` 배열이다 — +`get_runtime_class_proto` 가 `protos.into_iter().find(|proto| proto.name == name)` 로 고르고, +부트스트랩 로더(`load_class` → `find_rustjar_class(RT_RUSTJAR, …)`)가 결국 그 함수에 닿는다 +(`src/runtime.rs:173-174` · `test-utils/src/lib.rs:318-319`). + +## 착수 시점 실측 + +| 축 | 값 | +|---|---| +| `exception(` 호출부의 리터럴 클래스명 | **41 고유** · 호출부 **812** | +| `loader.rs` 등재 `::as_proto()` | **265 항목**(고유 타입 **263**) | +| 등재 타입 → `name:` 해석 | **265/265 성공**(해석 실패 0) | +| ★**못 싣는 이름(A − B)** | ★**0 — 베이스라인이 이미 0이다** | + +★**제안의 「72 java/javax class names」와 내 41 은 다르다.** 내 술어는 ★**`exception(` 첫 인자의 리터럴**만 +세고 **고유**로 센다 — 72 는 다른 술어(호출부 총계나 `new_class` 포함)로 보이나 **그 값을 재현하지 못했다.** +★**그래서 인용하지 않고 내 수를 적는다.** 제안의 「baseline is now 0」은 **재현된다.** + +★**정의는 됐는데 «등재되지 않은» 이름이 5건 있다**(이 축과 별개 · 고치지 않았다): +`java/util/AbstractList$ListItr` · `java/util/ArrayList$ListItr` · `java/util/Vector$ListItr` · +`java/util/logging/Formatter` · `org/rustjava/net/JarURLConnection`. +⇒ ★**그래서 검사기는 «`name:` 리터럴 전수»가 아니라 «등재분»을 실을 수 있는 집합으로 쓴다** — 그쪽이 진짜다. + +## 무엇을 만들었나 — 대조 검사 «한 자리» + +`scripts/check-named-exception-classes-are-loadable.py` (DoD 7번째 명령 · CI job `named_exception_classes`). +★**「그 한 클래스를 추가한다」로 끝내지 않았다** — 어느 쪽 집합이 늘어도 이 한 자리가 문다. + +## ★못 보는 것 — 「증명」이 아니라 «바닥»이다 + +- ★**런타임에 만들어지는 이름**(`format!` · `const` · 변수 · `match` 팔이 돌려주는 `&str`)은 호출부에 + 리터럴이 아니므로 **보이지 않는다.** 이것이 가장 큰 구멍이고, 제안 자신도 `tradeoff` 에 그렇게 적었다. +- ★**`exception(` 만 훑는다.** `new_class(`·`find_class(` 로 직접 부르는 이름은 범위 밖이다 — + 그쪽은 `Result` 를 호출자에게 돌려주지 «언래핑하지 않으므로» 패닉 축이 아니다. +- ★**등재된 프로토가 런타임에 «초기화»에 실패하는 경우는 통과한다** — 이름의 해석 가능성만 본다. +- ★**이름은 적힌 그대로 대조된다** — 다른 실재 클래스와 우연히 일치하는 오타는 통과한다. + +## ★양방향 — 개악이 «반드시» red 다 + +| 개악 | 결과 | +|---|---| +| ⒜ **현 상태** | `✓ 41 named … all 263 loadable` **rc=0** | +| ⒝ ★**⑶ 실제 사례 재현** — `loader.rs` 에서 `BootstrapMethodError` 등재 1줄 제거 | **rc=1** · `✗ java/lang/BootstrapMethodError — named at jvm-bytecode/src/interpreter.rs:1109` | +| ⒞ 프로토 `name:` 을 오타로(등재는 유지) — `NullPointerException` | **rc=1** · `✗ java/lang/NullPointerException — named at …:85 and 320 more` | +| ⒟ ★**fail-closed** — 해석 불가 등재 항목 추가 | **rc=2** `cannot measure: registered entries with no resolvable name: java::lang::NoSuchTypeHere` | +| ⒠ 전건 복구 후 | **rc=0** | + +★⒟ 가 이 설계의 요지다 — 등재 항목을 이름으로 되짚지 못하면 **「실을 수 있는 집합」을 조용히 줄여 false red** 를 낸다. +그래서 **줄이지 않고 «못 쟀다»(2)** 로 끝낸다. + +★**배선 자체도 양방향으로 쟀다**: CI step 을 지우면 `check-dod-ci-parity.py` 가 **rc=1**(`FAIL 대칭차 있음: 축 A`), +되돌리면 **rc=0**(`명령 7개 · toolchain 2개`). + +## 시간 + +**0.80s · 0.98s · 0.91s**(3회 · 부하 중). ★초판은 **32.7초**였다 — `rglob` 이 `target/` 를 걸어서다. +최상위에서 `target`·`.git` 을 **가지치기**해 그 비용을 없앴다(그 이유를 코드 주석에 남겼다). + +## 잃는 것 — 「없다」로 적지 않는다 + +- ★**DoD 명령이 6 → 7 로 는다.** 로컬 회차마다 ~1초가 붙고, `dod_parity` 가 그 대칭을 **함께** 지고 간다 + (제안이 `tradeoff` 에 적은 그 대가다 — 실측치는 1초이지 32초가 아니다). +- ★**바닥이라 «안심»을 준다** — 리터럴만 보므로 「검사기가 green 이니 패닉이 없다」는 **거짓**이다. + 위 「못 보는 것」 첫 항이 정확히 그 구멍이고, 그것을 닫으려면 **호출부 쪽 표현을 리터럴로 묶는 별 축**이 필요하다. +- ★**패닉을 throw 로 바꾸지 않았다** — 이 회차는 「못 싣는 일이 없게」만 한다. `.unwrap()` 은 그대로 있고, + 위 구멍으로 새는 이름이 생기면 **여전히 패닉한다.** 그 전환은 별 축이다(범위 밖 · 제안 `#p1` 과도 다르다). +- ★**등재 미비 5건은 고치지 않았다**(위 ⓐ) — 그 이름들은 `exception(` 에 쓰이지 않아 이 축이 아니다. + +## 후속 추천 + +⑴★**런타임 조립 이름을 잡는 축**(M) — 위 구멍의 정면. `exception(` 첫 인자가 리터럴이 «아닌» 호출부를 + 세어 그 수를 먼저 재고, 리터럴 강제(상수 테이블)가 값하는지 판단한다. ★지금은 **그 수조차 모른다.** +⑵★**`Jvm::exception` 의 `.unwrap()` 을 throw 로 바꾸는 축**(M) — 이 회차가 명시적으로 **범위 밖**으로 둔 것. + 바닥 검사가 있어도 마지막 방어선은 여전히 없다. diff --git a/scripts/check-named-exception-classes-are-loadable.py b/scripts/check-named-exception-classes-are-loadable.py new file mode 100755 index 00000000..8de5387a --- /dev/null +++ b/scripts/check-named-exception-classes-are-loadable.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Every exception class the Rust code names by string must be one the runtime can load. + +What this answers: *will an error path throw, or panic*. `Jvm::exception` (jvm/src/jvm.rs) ends in + + let instance = self.new_class(r#type, "(Ljava/lang/String;)V", (message_str,)).await.unwrap(); + +so a name the bootstrap loader cannot resolve does not become a Java exception -- it unwraps an +`Err` and aborts the process. That is the one failure mode a JVM must not have, and it is invisible +until something walks that path. It is also self-referential: jvm.rs:842 reports a missing class by +calling `exception("java/lang/NoClassDefFoundError", ...)`, so the error path's own class has to be +loadable or the report itself panics. + +Measured when this was written: 41 distinct `java/`-prefixed names across 812 `exception(...)` call +sites, and 263 distinct class names registered in the loader. Nothing was missing -- the baseline is +0, which is what makes the lock cheap. The one that was missing before, `java/lang/BootstrapMethodError`, +is the reason this exists: removing its registration is the round-trip test (see below). + +HOW THE TWO SETS ARE BUILT + named every string literal in the first argument of an `exception(` call, in any *.rs in the + workspace, whose value starts `java/` or `javax/`. + loadable `rustjava-runtime/src/loader.rs` lists `crate::classes::::::as_proto()`; the + loader returns a proto only if `proto.name == name`, so the loadable set is exactly the + `name:` literal of each *registered* type. Each type is resolved back to its `name:` by + reading its `impl ` block under `rustjava-runtime/src/classes/`. + +WHAT THIS DOES NOT SEE -- it is a floor, not a proof: + * A name built at run time (`format!`, a `const`, a variable, a match arm returning &str) is not a + literal at the call site, so it is invisible here. Only the literal spelling is checked. + * Only `exception(` is scanned. A class named through `new_class(` or `find_class(` directly is + not covered; those paths return Result to their caller rather than unwrapping, which is why the + panic axis is this one. + * A registered proto whose class fails to *initialise* at run time still loads here. This checks + resolvability of the name, not the health of the class. + * Names are compared as written. A typo that happens to match another real class passes. + +DELIBERATELY NOT DONE HERE: turning the `.unwrap()` into a thrown exception. That is a separate +axis -- this one makes sure there is nothing left to unwrap on. + +Exit: 0 every named class is loadable + 1 at least one named class has no registered proto + 2 could not measure (missing file, or a registered entry whose name cannot be resolved) + +It fails closed: if a registered `as_proto()` entry cannot be traced back to a `name:` literal the +check reports 2 rather than silently shrinking the loadable set, which would turn into a false red. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +LOADER = ROOT / "rustjava-runtime" / "src" / "loader.rs" +CLASSES = ROOT / "rustjava-runtime" / "src" / "classes" + +# First argument of `exception(`, when it is a java/ or javax/ string literal. +NAMED = re.compile(r'exception\(\s*"((?:java|javax)/[A-Za-z0-9_$/]+)"') +# `crate::classes::java::lang::BootstrapMethodError::as_proto(),` +REGISTERED = re.compile(r"crate::classes::([A-Za-z0-9_:]+)::as_proto\(\)") +# The `name:` field inside an `impl { ... }` block. +IMPL_BLOCK = re.compile(r"impl\s+([A-Za-z0-9_]+)\s*\{(.*?)\n\}", re.S) +NAME_FIELD = re.compile(r'name:\s*"([^"]+)"') + + +def die(message): + print(f"cannot measure: {message}", file=sys.stderr) + raise SystemExit(2) + + +def read(path): + return path.read_text(encoding="utf-8", errors="replace") + + +# Build artefacts dwarf the source tree, and `rglob` from the root walks them even when the results +# are filtered out afterwards: pruning here took the check from ~33s to well under a second. +SKIP_DIRS = {"target", ".git"} + + +def rust_files(): + for entry in sorted(ROOT.iterdir()): + if entry.name in SKIP_DIRS: + continue + if entry.is_dir(): + yield from sorted(entry.rglob("*.rs")) + elif entry.suffix == ".rs": + yield entry + + +def named_classes(): + """{class name: [file:line, ...]} for every literal exception(...) name.""" + found = {} + for path in rust_files(): + for number, line in enumerate(read(path).splitlines(), 1): + for match in NAMED.finditer(line): + found.setdefault(match.group(1), []).append(f"{path.relative_to(ROOT)}:{number}") + return found + + +def loadable_classes(): + """Names the bootstrap loader can return, i.e. the registered protos.""" + if not LOADER.is_file(): + die(f"{LOADER.relative_to(ROOT)} is missing") + if not CLASSES.is_dir(): + die(f"{CLASSES.relative_to(ROOT)} is missing") + + name_of = {} + for path in sorted(CLASSES.rglob("*.rs")): + text = read(path) + for match in IMPL_BLOCK.finditer(text): + field = NAME_FIELD.search(match.group(2)) + if field: + name_of.setdefault(match.group(1), field.group(1)) + + registered = REGISTERED.findall(read(LOADER)) + if not registered: + die(f"no ::as_proto() entries found in {LOADER.relative_to(ROOT)}") + + names, unresolved = set(), [] + for path in registered: + type_name = path.split("::")[-1] + if type_name in name_of: + names.add(name_of[type_name]) + else: + unresolved.append(path) + if unresolved: + die("registered entries with no resolvable name: " + ", ".join(sorted(set(unresolved)))) + return names + + +def main(): + named = named_classes() + loadable = loadable_classes() + missing = sorted(name for name in named if name not in loadable) + + if missing: + print(f"{len(missing)} named exception class(es) the runtime cannot load:") + for name in missing: + sites = named[name] + print(f" ✗ {name} — named at {sites[0]}" + (f" and {len(sites) - 1} more" if len(sites) > 1 else "")) + print() + print("Jvm::exception unwraps new_class(), so each of these panics instead of throwing.") + print("Add the class under rustjava-runtime/src/classes/ and register its as_proto() in") + print("rustjava-runtime/src/loader.rs, or stop naming it.") + return 1 + + print( + f"✓ {len(named)} named exception class(es) across " + f"{sum(len(v) for v in named.values())} call site(s); all {len(loadable)} loadable" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 89c2e83c8aa58ea4790c1c7a79719c4b198032e4 Mon Sep 17 00:00:00 2001 From: jun0 Date: Fri, 18 Sep 2026 19:49:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[rustjava-lock-every-named-exception-class-?= =?UTF-8?q?is-loadable-fix]=20fix(scripts):=20=EA=B1=B0=EC=A7=93=20?= =?UTF-8?q?=EC=B4=88=EB=A1=9D=20=EB=91=98=EA=B3=BC=20=EA=B1=B0=EC=A7=93=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=95=20=ED=95=98=EB=82=98=EB=A5=BC=20=EC=97=86?= =?UTF-8?q?=EC=95=A4=EB=8B=A4=20=E2=80=94=20=EC=B6=A9=EB=8F=8C=C2=B7?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=A4=84=C2=B7list=5Fproto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- REPORT.md | 16 +++- STATE.md | 11 ++- ...-named-exception-classes-are-loadable.json | 21 +++- ...18-named-exception-classes-are-loadable.md | 80 +++++++++++++++- ...ck-named-exception-classes-are-loadable.py | 96 +++++++++++++------ 5 files changed, 181 insertions(+), 43 deletions(-) diff --git a/REPORT.md b/REPORT.md index 0cc43d0e..fc12941a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,10 +1,22 @@ # REPORT +## [2026-09-18] 검사기의 «거짓 초록» 둘과 «거짓 빨강» 하나 — 게이트² 반려 승계 (rustjava-lock-every-named-exception-class-is-loadable-fix) +- 무엇을: PR #72 의 검사기 결함 **3건** 정정. ★**베이스라인 0 인 검사기라 «거짓 초록 = 검사기 부재»** 다. ★런타임 클래스 추가 **0** · `loader.rs` `protos` **무접촉** · `jvm.rs` **무접촉**. +- ★**F1(거짓 초록)** 짧은 이름 충돌 — `Formatter`(`java/util` ↔ `java/util/logging`) · `JarURLConnection`(`java/net` ↔ `org/rustjava/net`) **2쌍 실재**. ★재현: 한쪽 등재를 지우고 그 이름을 `exception(` 에 넣으면 **전 `✓ … 263 loadable` rc=0**(거짓 초록) → **후 rc=1**. 둘째 쌍도 동일. ★처방 = 키를 **(모듈, 타입, 함수)** 로. +- ★**F2(거짓 초록)** 줄 단위 스캔이 다중 줄 `exception(` 을 못 봤다(rustfmt 가 쪼갠다). ★재현: 다중 줄 호출에 미등재 이름 → **전 rc=0(안 보임) → 후 rc=1**. ★**`.exception(` 846 − 다중 줄 34 = 812** ⇒ 검수자의 846 과 초판의 812 차이가 **F2 그 자체**였다. +- ★**F3(거짓 빨강 + 사실오류)** `as_proto` 전용 정규식이 `list_proto` **3건**을 놓쳤고, 한 `impl` 에 생성자가 둘일 때 첫 `name:` 만 집어 엉뚱한 클래스를 귀속시켰다. ⇒ ★**초판의 「정의됐지만 미등재 5건」은 «틀렸다» — 실제 미등재 «0»**(`name:` 268 = 등재 268). 그 기록을 5곳에서 정정했다. +- ★**수의 전/후와 «왜»**: 이름 **41→43** · 호출부 **812→846**(F2) · loadable **263→268**(F3 +3 · F1 충돌쌍 +2). ★**loadable 이 는 것은 느슨해진 것이 아니라** 종전에 265 등재를 263 으로밖에 해석 못 했다는 뜻이다. +- ★**ⓒ 새 red 위험을 «편집 전»에 쟀다** — F2 로 늘어나는 이름 2개(`InstantiationError`·`UnsupportedClassVersionError`)가 **둘 다 등재** ⇒ **새 red 0 · rc=0 유지**(미등재였으면 멈추고 회신할 자리였다). +- ★**시간**: `real` ×3 전 **1.42/1.67/1.46** ↔ 후 **2.01/1.69/1.19** — ★**구간이 겹친다** ⇒ 「늘지 않았다」가 아니라 **「유의하게 늘지 않았다」**. +- ★**잃는 것**: 검사 대상이 늘어 **앞으로 더 자주 red 가 날 수 있다**(★실패가 아니라 «보이게 된 것») · 해석 로직이 3튜플 키로 **복잡해졌다**(결함 3건이 전부 그 자리였다 — 후속 카드) · ★여전히 **바닥**(런타임 조립 이름·`new_class(` 경로는 그대로 안 보인다). +- 검증: 검사기 `✓ 43 named … all 268 loadable` rc=0 · `check-worklog-json` rc=0 · `check-dod-ci-parity` rc=0(명령 7개) · `cargo fmt` rc=0 · `mbvar-guard` rc=0(위반 0). +- ★후속 추천: **loadable 집합을 «파싱으로 재유도»하지 말고 loader 쪽에서 «내보낼» 것인가**(M — 이번 결함 3건이 전부 그 재유도 자리였다). 상세 = `docs/worklog/2026-09-18-named-exception-classes-are-loadable.md`. + ## [2026-09-18] 이름으로 부르는 예외 클래스가 «실을 수 있는» 것인가 — 한 자리에서 대조한다 (rustjava-lock-every-named-exception-class-is-loadable) - 무엇을: 채택 제안 `2026-09-17-string-concat-recipe-arity#p0`(worklog json `adoptedProposals` 기록). 산출물 = `scripts/check-named-exception-classes-are-loadable.py` **한 자리** + CI job + DoD 한 줄. ★**런타임 클래스 추가 0 · `.unwrap()` 무접촉.** - ★**전제를 코드로 확인했다**(총괄 선실측 없음): `jvm/src/jvm.rs:943-950` 의 `new_class(...).await.`★**`unwrap()`** ⇒ 부트스트랩 로더가 이름을 못 풀면 **Java 예외가 아니라 프로세스가 죽는다**. ★**자기 참조다** — `:842` 가 클래스 부재를 `exception("java/lang/NoClassDefFoundError", …)` 로 보고하므로 **오류 경로 자신의 클래스**가 실려야 한다. -- ★**베이스라인 실측**: `exception(` 리터럴 클래스명 **41 고유 / 호출부 812** ↔ `loader.rs` 등재 **265항목(고유 263) · 이름 해석 265/265** ⇒ ★**못 싣는 이름 «0»**(제안의 「baseline is now 0」 재현). +- ★**베이스라인 실측**(★게이트² 가 결함 3건을 잡아 **정정된 수**다): `exception(` 리터럴 클래스명 **43 고유 / 호출부 846** ↔ `loader.rs` 등재 **268항목**(`as_proto` 265 + `list_proto` 3) ⇒ ★**못 싣는 이름 «0»**(제안의 「baseline is now 0」 재현). ★**초판의 41/812/263 은 전부 «과소»였다** — 줄 단위 스캔이 다중 줄 호출 34건을, `as_proto` 전용 정규식이 `list_proto` 3건을, 짧은 이름 키잉이 충돌쌍을 각각 잃었다. ★**제안의 「72」는 재현되지 않았다** — 내 술어는 「`exception(` 첫 인자 리터럴·고유」로 **41**이다. ★**재현 못 한 수는 인용하지 않았다.** -- ★**실을 수 있는 집합 = «등재분»이지 «`name:` 리터럴 전수»가 아니다** — 정의는 됐는데 미등재인 이름이 **5건** 있다(전부 `exception(` 밖이라 베이스라인은 어느 쪽이든 0). +- ★**실을 수 있는 집합 = «등재분»이다** — 그리고 ★**초판이 적은 「정의됐지만 미등재 5건」은 «틀렸다»**: 그 5건은 전부 등재돼 있고(3건은 `list_proto` 로, 2건은 충돌쌍의 다른 쪽으로) ★**실제 미등재는 «0»** 이다. `name:` 리터럴 **268** = 등재 **268**. - ★**양방향 4축**: ⒜현 상태 **rc=0** ⒝★**⑶ 실제 사례 재현** — `BootstrapMethodError` 등재 1줄 제거 → **rc=1**(`interpreter.rs:1109` 지목) ⒞프로토 이름 오타 → **rc=1**(321 호출부) ⒟★**fail-closed** — 해석 불가 등재 → **rc=2 «못 쟀다»**(집합을 조용히 줄여 false red 를 내지 않는다). 배선도 양방향 — CI step 제거 시 `dod_parity` **rc=1**. - ★**못 보는 것**(바닥이지 증명이 아니다): ★**런타임 조립 이름**(`format!`·상수·변수)은 **안 보인다** · `exception(` 만 훑는다(`new_class(`·`find_class(` 는 `Result` 를 돌려주므로 축이 다르다) · 초기화 실패는 통과 · 다른 실재 클래스와 겹치는 오타는 통과. - ★**잃는 것**: DoD 명령 **6 → 7**(실측 **~1초**/회차 · 초판 32.7초를 `target/` 가지치기로 없앴다) · ★**「green 이니 패닉 없다」는 거짓**(위 구멍) · ★**`.unwrap()` 은 그대로**라 새는 이름이 생기면 여전히 패닉한다(전환은 범위 밖). diff --git a/STATE.md b/STATE.md index 43d118ef..b80cecde 100644 --- a/STATE.md +++ b/STATE.md @@ -7,10 +7,17 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-lock-every-named-exception-class-is-loadable-fix] ★★**검사기의 «거짓 초록» 2건 + «거짓 빨강» 1건 정정**(게이트² 반려 승계 · PR #72). + ★**F1** 짧은 이름 충돌(`Formatter`·`JarURLConnection` **2쌍**) ⇒ 한쪽 등재를 지워도 **전 rc=0(거짓 초록)** → **후 rc=1**. 키를 **(모듈,타입,함수)** 로. + ★**F2** 줄 단위 스캔이 다중 줄 호출을 못 봄 ⇒ **전 rc=0(안 보임) → 후 rc=1**. ★**846 − 34 = 812** 로 검수자 수와의 차이를 설명했다. + ★**F3** `list_proto` 3건 누락 + 한 `impl` 의 둘째 생성자 오귀속 ⇒ ★**초판의 「미등재 5건」은 틀렸다 — 실제 «0»**(5곳 정정). + ★**수 전/후**: 이름 41→**43** · 호출부 812→**846** · loadable 263→**268**(왜인지 기재). + ★**ⓒ 편집 «전»에 새 red 위험 측정** — 늘어나는 2이름 모두 등재 ⇒ **새 red 0**. + ★**시간 유의차 없음**(전 1.42/1.67/1.46 ↔ 후 2.01/1.69/1.19 · 구간 겹침). ★런타임 클래스 추가 0 · `protos` 무접촉. - [rustjava-lock-every-named-exception-class-is-loadable] ★★**이름으로 부르는 예외 클래스가 «실을 수 있는» 것인가 — 대조 한 자리.** 채택 제안 `2026-09-17-string-concat-recipe-arity#p0`(worklog json 기록). ★**전제 확인(코드)**: `jvm/src/jvm.rs:943-950` `new_class(...).await.`**`unwrap()`** ⇒ 못 싣는 이름은 **throw 가 아니라 패닉**. ★자기 참조 — `:842` 가 부재를 `exception("java/lang/NoClassDefFoundError")` 로 보고한다. - ★**베이스라인 0**: `exception(` 리터럴 **41 고유 / 812 호출부** ↔ 등재 프로토 **265(고유 263) · 해석 265/265**. ★제안의 「72」는 **재현 안 됨** ⇒ 내 수(41)를 적었다. - ★**실을 수 있는 집합 = 등재분**(미등재 `name:` 5건 존재 — 전부 `exception(` 밖). + ★**베이스라인 0**(★게이트² 정정 후): `exception(` 리터럴 **43 고유 / 846 호출부** ↔ 등재 **268**(`as_proto` 265 + `list_proto` 3). ★초판의 41/812/263 은 **전부 과소**였다(다중 줄 34 · `list_proto` 3 · 짧은 이름 충돌). ★제안의 「72」는 여전히 **재현 안 됨**. + ★**실을 수 있는 집합 = 등재분** · ★**초판의 「미등재 5건」은 틀렸다 — 실제 미등재 «0»**(`name:` 268 = 등재 268). ★**양방향**: 현 상태 rc=0 · ★`BootstrapMethodError` 등재 제거 → **rc=1**(실제 패닉 사례 재현) · 프로토 이름 오타 → rc=1 · ★해석 불가 등재 → **rc=2(못 쟀다 · fail-closed)** · CI step 제거 → `dod_parity` rc=1. ★**못 보는 것**: **런타임 조립 이름 안 보임**(바닥이지 증명 아님) · `exception(` 만 · 초기화 실패 통과. ★**잃는 것**: DoD **6→7**(~1초 · 초판 32.7초는 `target/` 가지치기로 해소) · ★`.unwrap()` 무접촉(전환은 범위 밖). diff --git a/docs/worklog/2026-09-18-named-exception-classes-are-loadable.json b/docs/worklog/2026-09-18-named-exception-classes-are-loadable.json index 2410b219..74fcd4a7 100644 --- a/docs/worklog/2026-09-18-named-exception-classes-are-loadable.json +++ b/docs/worklog/2026-09-18-named-exception-classes-are-loadable.json @@ -9,10 +9,10 @@ ], "verification": [ "PREMISE CONFIRMED BY READING THE CODE, not by quoting the proposal: jvm/src/jvm.rs:943-950 is `let instance = self.new_class(r#type, \"(Ljava/lang/String;)V\", (message_str,)).await.unwrap();`. new_class returns Result, so an unresolvable name aborts. The axis is self-referential: jvm.rs:842 reports a missing class by calling exception(\"java/lang/NoClassDefFoundError\", ...), so the error path's own class must be loadable or the report panics.", - "BASELINE, measured this round: 41 distinct literal names across 812 exception( call sites; loader.rs registers 265 ::as_proto() entries (263 distinct types), all 265 resolved back to a name: literal; names named but not loadable = 0. The proposal's 'baseline is now 0' reproduces.", + "BASELINE, corrected by the gate-2 round that found three defects in the first draft: 43 distinct literal names across 846 exception( call sites; loader.rs registers 268 entries (as_proto 265 + list_proto 3), all resolved; names named but not loadable = 0. The first draft read 41 / 812 / 263 and every one was an undercount — a line-by-line scan lost 34 calls rustfmt had broken across a newline, an as_proto-only regex lost three list_proto registrations, and keying types by bare name let one of a colliding pair answer for the other. The proposal's 'baseline is now 0' still reproduces.", "THE PROPOSAL'S 72 DID NOT REPRODUCE. This round counts 41, using 'distinct literal first argument of exception('. 72 is presumably a different predicate (call sites, or new_class included); it was not reproduced, so this round writes its own number rather than quoting one it cannot stand behind.", - "LOADABLE SET IS THE REGISTERED ONE, NOT EVERY name: LITERAL. Five names exist under classes/ but are absent from loader.rs — java/util/AbstractList$ListItr, java/util/ArrayList$ListItr, java/util/Vector$ListItr, java/util/logging/Formatter, org/rustjava/net/JarURLConnection — so using every literal would overstate what the loader can return. None of the five is named in exception(, so the baseline is 0 either way.", - "BIDIRECTIONAL: current tree rc=0 (41 named, 263 loadable). Removing the BootstrapMethodError registration from loader.rs — the case that actually panicked — gives rc=1 naming java/lang/BootstrapMethodError at jvm-bytecode/src/interpreter.rs:1109. Renaming the NullPointerException proto while leaving it registered gives rc=1 with 321 call sites. Adding a registered entry whose type cannot be resolved gives rc=2 'cannot measure', not a false red. Restoring gives rc=0.", + "CORRECTION — the first draft's record of 'five names exist under classes/ but are absent from loader.rs' was wrong, and all five are registered. Three (AbstractList$ListItr, ArrayList$ListItr, Vector$ListItr) register through list_proto(), which the old regex did not match; two (java/util/logging/Formatter, org/rustjava/net/JarURLConnection) are the second half of a bare-name collision and were hidden by the other half. Measured with (module, type, function) keying: 268 name: literals, 268 registered, 0 unregistered.", + "BIDIRECTIONAL: current tree rc=0 (43 named, 268 loadable). Removing the BootstrapMethodError registration from loader.rs — the case that actually panicked — gives rc=1 naming java/lang/BootstrapMethodError at jvm-bytecode/src/interpreter.rs:1109. Renaming the NullPointerException proto while leaving it registered gives rc=1 with 321 call sites. Adding a registered entry whose type cannot be resolved gives rc=2 'cannot measure', not a false red. Restoring gives rc=0.", "WIRING MEASURED BOTH WAYS: deleting the new CI step makes check-dod-ci-parity.py rc=1 (FAIL 대칭차 있음: 축 A); restoring gives rc=0 with 'commands 7, toolchains 2'.", "COST: 0.80s / 0.98s / 0.91s over three runs on a loaded machine. The first draft took 32.7s because rglob walked target/; pruning target and .git at the top level removed that, and the reason is in a comment so it does not get re-introduced." ], @@ -24,13 +24,15 @@ "DoD grows from 6 commands to 7, and check-dod-ci-parity.py now carries that symmetry too. Measured cost is ~1s per local round.", "OPEN PR INTERACTION: PR #71 also adds a DoD command and a CI job, touching the same CLAUDE.md block and the same region of rust.yml. Whichever lands second will conflict there, and the resolution must be a union of both commands or dod_parity goes red." ], - "adoptedProposals": ["2026-09-17-string-concat-recipe-arity#p0"], + "adoptedProposals": [ + "2026-09-17-string-concat-recipe-arity#p0" + ], "proposals": [ { "title": "Count the exception() call sites whose class name is not a literal", "plainSummary": "The new check only sees class names written out in full; nobody knows yet how many are built at run time instead, so we cannot say how big the blind spot is.", "userBenefit": "A name assembled at run time can still name a class the runtime cannot load, and that still ends as a crash rather than a Java exception — exactly the failure the new check was meant to close.", - "why": "The check landed this round reads the first argument of exception( only when it is a string literal, and 41 distinct names across 812 call sites pass that filter. What is not known is the denominator: how many call sites pass something else. Until that number exists, 'the check is a floor' is a statement without a size, and nobody can judge whether closing the gap is worth anything.", + "why": "The check landed this round reads the first argument of exception( only when it is a string literal, and 43 distinct names across 846 call sites pass that filter. What is not known is the denominator: how many call sites pass something else. Until that number exists, 'the check is a floor' is a statement without a size, and nobody can judge whether closing the gap is worth anything.", "tradeoff": "Counting is cheap, but acting on the count is not: forcing literals would mean a constant table and touching every non-literal call site, and some of those names genuinely depend on runtime state. The count may also show the gap is tiny, in which case the right answer is to write the number down and stop — which is still worth the measurement.", "effort": "S", "target": "scripts/check-named-exception-classes-are-loadable.py, jvm/src/jvm.rs" @@ -43,6 +45,15 @@ "tradeoff": "exception() currently returns JavaError, not Result, so every caller changes — 812 call sites reference it. There is also a genuine question of what to throw when the class you wanted to throw is itself unavailable, which is the self-referential case at jvm.rs:842; picking a wrong answer there turns one crash into an infinite recursion.", "effort": "M", "target": "jvm/src/jvm.rs, jvm-bytecode/src/interpreter.rs, rustjava-runtime/src/classes/" + }, + { + "title": "Decide whether the loadable set should be read from the loader instead of re-derived", + "plainSummary": "The check works out which classes the runtime can load by re-reading the Rust source; three of this round's four defects came from that re-derivation being subtly wrong.", + "userBenefit": "Every mistake in the re-derivation is a false green or a false red on a check whose whole job is to be trusted when it says nothing is wrong.", + "why": "All three defects gate 2 found were in the mapping from a loader registration back to the class name it produces: bare-name keying let a colliding type answer for its twin, only as_proto() was matched so three list_proto() registrations read as absent, and taking the first name: in an impl block attributed the wrong class when a type holds two constructors. Each fix was small, and each was only found by someone reading the source alongside the script. The alternative is to stop re-deriving: have the Rust side emit the registered names once (a test that prints them, or a generated file) and let the check compare two lists instead of parsing one.", + "tradeoff": "Emitting the list means the check depends on a build step, which is slower and adds a way for the two to drift when the emitter is not re-run; the current version needs nothing but the source text and runs in about a second. It also moves a correctness question into Rust, where it is harder to see. The count that would decide it is how many more re-derivation defects show up — if this round was the last one, the parsing version is cheaper.", + "effort": "M", + "target": "scripts/check-named-exception-classes-are-loadable.py, rustjava-runtime/src/loader.rs" } ] } diff --git a/docs/worklog/2026-09-18-named-exception-classes-are-loadable.md b/docs/worklog/2026-09-18-named-exception-classes-are-loadable.md index c24a8eb5..4a0bc5bc 100644 --- a/docs/worklog/2026-09-18-named-exception-classes-are-loadable.md +++ b/docs/worklog/2026-09-18-named-exception-classes-are-loadable.md @@ -28,8 +28,8 @@ Java 예외가 되는 것이 아니라 **프로세스가 죽는다.** | 축 | 값 | |---|---| -| `exception(` 호출부의 리터럴 클래스명 | **41 고유** · 호출부 **812** | -| `loader.rs` 등재 `::as_proto()` | **265 항목**(고유 타입 **263**) | +| `exception(` 호출부의 리터럴 클래스명 | ★**43 고유** · 호출부 **846** (초판 41/812 — 다중 줄 34건을 잃었다) | +| `loader.rs` 등재 | ★**268 항목**(`as_proto` **265** + `list_proto` **3**) (초판은 `as_proto` 만 봐서 265) | | 등재 타입 → `name:` 해석 | **265/265 성공**(해석 실패 0) | | ★**못 싣는 이름(A − B)** | ★**0 — 베이스라인이 이미 0이다** | @@ -37,7 +37,10 @@ Java 예외가 되는 것이 아니라 **프로세스가 죽는다.** 세고 **고유**로 센다 — 72 는 다른 술어(호출부 총계나 `new_class` 포함)로 보이나 **그 값을 재현하지 못했다.** ★**그래서 인용하지 않고 내 수를 적는다.** 제안의 「baseline is now 0」은 **재현된다.** -★**정의는 됐는데 «등재되지 않은» 이름이 5건 있다**(이 축과 별개 · 고치지 않았다): +★★**[정정 — 게이트² F3] 초판이 적은 「정의됐지만 «등재되지 않은» 이름 5건」은 «틀렸다». 실제 미등재는 «0» 이다.** +3건(`ListItr` 무리)은 `list_proto()` 로 등재돼 있는데 초판 정규식이 `as_proto` 만 봤고, +2건(`logging/Formatter`·`org/rustjava/net/JarURLConnection`)은 짧은 이름 충돌로 다른 쪽에 가려졌다. +아래는 그 «틀린» 목록이다(사료로 남긴다): `java/util/AbstractList$ListItr` · `java/util/ArrayList$ListItr` · `java/util/Vector$ListItr` · `java/util/logging/Formatter` · `org/rustjava/net/JarURLConnection`. ⇒ ★**그래서 검사기는 «`name:` 리터럴 전수»가 아니라 «등재분»을 실을 수 있는 집합으로 쓴다** — 그쪽이 진짜다. @@ -60,7 +63,7 @@ Java 예외가 되는 것이 아니라 **프로세스가 죽는다.** | 개악 | 결과 | |---|---| -| ⒜ **현 상태** | `✓ 41 named … all 263 loadable` **rc=0** | +| ⒜ **현 상태** | `✓ 43 named … all 268 loadable` **rc=0** (초판 41/263) | | ⒝ ★**⑶ 실제 사례 재현** — `loader.rs` 에서 `BootstrapMethodError` 등재 1줄 제거 | **rc=1** · `✗ java/lang/BootstrapMethodError — named at jvm-bytecode/src/interpreter.rs:1109` | | ⒞ 프로토 `name:` 을 오타로(등재는 유지) — `NullPointerException` | **rc=1** · `✗ java/lang/NullPointerException — named at …:85 and 320 more` | | ⒟ ★**fail-closed** — 해석 불가 등재 항목 추가 | **rc=2** `cannot measure: registered entries with no resolvable name: java::lang::NoSuchTypeHere` | @@ -85,7 +88,7 @@ Java 예외가 되는 것이 아니라 **프로세스가 죽는다.** 위 「못 보는 것」 첫 항이 정확히 그 구멍이고, 그것을 닫으려면 **호출부 쪽 표현을 리터럴로 묶는 별 축**이 필요하다. - ★**패닉을 throw 로 바꾸지 않았다** — 이 회차는 「못 싣는 일이 없게」만 한다. `.unwrap()` 은 그대로 있고, 위 구멍으로 새는 이름이 생기면 **여전히 패닉한다.** 그 전환은 별 축이다(범위 밖 · 제안 `#p1` 과도 다르다). -- ★**등재 미비 5건은 고치지 않았다**(위 ⓐ) — 그 이름들은 `exception(` 에 쓰이지 않아 이 축이 아니다. +- ★**「등재 미비 5건」은 애초에 없었다**(위 정정) — 초판 검사기의 결함이 만든 허상이고, 고칠 것이 없었다. ## 후속 추천 @@ -93,3 +96,70 @@ Java 예외가 되는 것이 아니라 **프로세스가 죽는다.** 세어 그 수를 먼저 재고, 리터럴 강제(상수 테이블)가 값하는지 판단한다. ★지금은 **그 수조차 모른다.** ⑵★**`Jvm::exception` 의 `.unwrap()` 을 throw 로 바꾸는 축**(M) — 이 회차가 명시적으로 **범위 밖**으로 둔 것. 바닥 검사가 있어도 마지막 방어선은 여전히 없다. + + +--- + +## ★★[게이트² 반려 승계] 결함 셋 — 둘이 «거짓 초록», 하나가 «거짓 빨강» + +초판 검사기는 «베이스라인 0» 이라 ★**거짓 초록 = 검사기 부재**다. 셋 다 내가 재현하고 고쳤다. + +### F1 — 짧은 이름 충돌이 «진짜 미등재»를 가렸다(거짓 초록) +`name_of` 를 **타입 이름만**으로 키잉하고 `setdefault`(첫 파일 승)를 써서, 충돌쌍의 한쪽이 다른 쪽을 대신 답했다. +★**이 트리의 충돌쌍은 둘**: `Formatter`(`java/util` ↔ `java/util/logging`) · `JarURLConnection`(`java/net` ↔ `org/rustjava/net`). +``` +개악: java::util::Formatter 등재 1줄 제거 + exception("java/util/Formatter", …) + 전 ✓ 42 named exception class(es) across 813 call site(s); all 263 loadable rc=0 ← ★거짓 초록 + 후 ✗ java/util/Formatter — named at … rc=1 +둘째 쌍도 같다: java::net::JarURLConnection 제거 → ✗ java/net/JarURLConnection rc=1 +``` +★**처방**: 키를 ★**(모듈 경로, 타입, 함수)** 로. 모듈 경로는 파일 위치에서 그대로 나오고, 그것이 `loader.rs` 가 적는 `crate::classes::…` 와 같은 축이다. + +### F2 — 여러 줄에 걸친 `exception(` 을 못 봤다(거짓 초록) +`named_classes()` 가 **한 줄씩** 훑어, 정규식의 `\s*` 가 개행에 닿지 못했다(rustfmt 가 긴 호출을 쪼갠다). +``` +개악: 다중 줄 exception( 에 미등재 이름을 넣는다 + 전 ✓ 41 named exception class(es) across 812 call site(s); all 263 loadable rc=0 ← ★안 보인다 + 후 ✗ java/lang/NoSuchClassHereError — named at …:2 rc=1 +``` +★**처방**: 파일 전체를 훑고 줄 번호는 매치 오프셋에서 역산한다(보고 위치는 그대로 유지). + +### F3 — `list_proto()` 등재가 안 보였다(거짓 빨강 + 사실오류) +`REGISTERED` 가 `::as_proto\(\)` 만 잡아 **3건**(`AbstractListItr`·`ArrayListItr`·`VectorItr`)을 놓쳤다. +★**그래서 초판이 「정의됐지만 미등재 5건」이라 «기록»한 것이 틀렸다** — 위 정정 절 참조(실제 미등재 **0**). +※근인이 하나 더 있다: 한 `impl` 블록에 proto 생성자가 **둘**이면(`AbstractListItr::as_proto` → `…$Itr` · +`::list_proto` → `…$ListItr`) 첫 `name:` 만 집어 **엉뚱한 클래스를 귀속**시켰다 ⇒ (모듈, 타입, **함수**) 키가 그것도 함께 고친다. + +## ★ⓑ 세 수 — «술어를 밝혀» 내가 각각 셌다 +| 수 | 값 | 술어 | +|---|---|---| +| `protos[]` 항목 | **268** | `let protos = [` ~ `];` 사이에서 `crate::` 로 시작하는 줄 | +| 그중 비 | `as_proto` **265** · `list_proto` **3** · 그 밖 **0** | 같은 구간에서 함수명으로 분류 | +| `.exception(` 총계 | **846** | 리터럴 `.exception(` 출현 · 인자 무관 | +| 〃 중 첫 인자가 java/javax 리터럴 | **846** | 검사기의 `NAMED` 정규식을 **파일 전체**에 적용 | +| 〃 중 개행을 사이에 둔 것 | **34** | `exception\(\s*\n\s*"…"` | +★**846 − 34 = 812** ⇒ ★**검수자의 846 과 초판의 812 는 «다른 술어»가 아니라 «F2 그 자체»였다.** 그것이 그 차이의 전부다. + +## ★ⓒ F2 를 고치면 «새 red» 가 나는가 — **편집 «전»에 쟀다** +늘어난 이름은 **2개**(`java/lang/InstantiationError` · `java/lang/UnsupportedClassVersionError`)이고 +★**둘 다 등재돼 있다** ⇒ ★**새 red 0 · rc=0 유지.** ⇒ 멈출 사유가 없어 진행했다(티켓 ⓒⅰ 갈래). + +## 수의 전/후 — ★왜 달라졌는지 +| 축 | 전 | 후 | 왜 | +|---|---|---|---| +| 이름(고유) | 41 | **43** | F2 — 다중 줄 호출 34건이 보이게 됐다 | +| 호출부 | 812 | **846** | 〃 | +| loadable | 263 | **268** | F3(+3 `list_proto`) · F1((모듈,타입,함수) 키로 충돌쌍 양쪽이 각각 해석 +2) | +★**loadable 이 «늘었다»는 것은 검사가 느슨해진 것이 아니다** — 종전에는 **265 등재를 263 으로밖에 해석 못 해** +그 차이가 «없는 이름을 있다고» 하거나 «있는 이름을 없다고» 하는 두 오류로 나타났다. + +## 시간 — ★**유의한 증가 없음**(단정하지 않는다) +`real` ×3: 전 **1.42 / 1.67 / 1.46** ↔ 후 **2.01 / 1.69 / 1.19**. ★**구간이 겹친다** — 이 머신에서는 +부하가 판본 차이를 덮는다(이 저장소가 같은 축에서 이미 배운 것). ⇒ 「늘지 않았다」가 아니라 **「유의하게 늘지 않았다」**로 적는다. + +## 이 승계 회차에서 잃는 것 +- ★**검사 대상이 늘었다**(812 → 846) — 앞으로 다중 줄 호출이 새로 생기면 **더 자주 red 가 날 수 있다**. + ★**그것은 실패가 아니라 «검사기가 드디어 보이게 된 것»**이다. 다만 그 red 를 처음 보는 사람에게는 새 비용이다. +- ★**해석 로직이 복잡해졌다** — 키가 1튜플에서 3튜플이 되고 `impl` 블록을 함수 단위로 쪼갠다. + ⇒ ★**파싱으로 재유도하는 방식 자체의 취약함**이 드러났다(결함 3건이 전부 그 자리였다) — 후속 카드로 냈다. +- ★**여전히 «바닥»이다** — 런타임 조립 이름·`new_class(` 경로는 그대로 안 보인다(초판의 한계 그대로). diff --git a/scripts/check-named-exception-classes-are-loadable.py b/scripts/check-named-exception-classes-are-loadable.py index 8de5387a..b32669ec 100755 --- a/scripts/check-named-exception-classes-are-loadable.py +++ b/scripts/check-named-exception-classes-are-loadable.py @@ -11,18 +11,23 @@ calling `exception("java/lang/NoClassDefFoundError", ...)`, so the error path's own class has to be loadable or the report itself panics. -Measured when this was written: 41 distinct `java/`-prefixed names across 812 `exception(...)` call -sites, and 263 distinct class names registered in the loader. Nothing was missing -- the baseline is -0, which is what makes the lock cheap. The one that was missing before, `java/lang/BootstrapMethodError`, +Measured after gate 2 corrected three defects in the first draft: 43 distinct `java/`-prefixed +names across 846 `exception(...)` call sites, and 268 distinct class names registered in the loader. +Nothing is missing -- the baseline is 0, which is what makes the lock cheap. The first draft read +41 / 812 / 263 and every one of those was an undercount: it scanned line by line (losing 34 calls +rustfmt had broken across a newline), matched only `as_proto()` (losing three `list_proto()` +registrations), and keyed types by bare name (letting one of a colliding pair answer for the +other). The one that was missing before, `java/lang/BootstrapMethodError`, is the reason this exists: removing its registration is the round-trip test (see below). HOW THE TWO SETS ARE BUILT named every string literal in the first argument of an `exception(` call, in any *.rs in the workspace, whose value starts `java/` or `javax/`. - loadable `rustjava-runtime/src/loader.rs` lists `crate::classes::::::as_proto()`; the - loader returns a proto only if `proto.name == name`, so the loadable set is exactly the - `name:` literal of each *registered* type. Each type is resolved back to its `name:` by - reading its `impl ` block under `rustjava-runtime/src/classes/`. + loadable `rustjava-runtime/src/loader.rs` lists `crate::classes::::::as_proto()` and + `::list_proto()`; the loader returns a proto only if `proto.name == name`, so the + loadable set is exactly the `name:` literal each *registered* constructor writes. The + resolution key is (module path, type, function) -- see `loadable_classes()` for why all + three are needed. WHAT THIS DOES NOT SEE -- it is a floor, not a proof: * A name built at run time (`format!`, a `const`, a variable, a match arm returning &str) is not a @@ -41,8 +46,8 @@ 1 at least one named class has no registered proto 2 could not measure (missing file, or a registered entry whose name cannot be resolved) -It fails closed: if a registered `as_proto()` entry cannot be traced back to a `name:` literal the -check reports 2 rather than silently shrinking the loadable set, which would turn into a false red. +It fails closed: if a registered entry cannot be traced back to a `name:` literal the check reports +2 rather than silently shrinking the loadable set, which would turn into a false red. """ import re @@ -53,12 +58,20 @@ LOADER = ROOT / "rustjava-runtime" / "src" / "loader.rs" CLASSES = ROOT / "rustjava-runtime" / "src" / "classes" -# First argument of `exception(`, when it is a java/ or javax/ string literal. +# First argument of `exception(`, when it is a java/ or javax/ string literal. `\s*` has to be able +# to cross a newline: rustfmt breaks the call when the line is long, and 34 of the 846 call sites in +# this tree are written that way. Matching is done against the whole file for that reason -- reading +# it line by line made the `\s*` unreachable and lost those 34 silently. NAMED = re.compile(r'exception\(\s*"((?:java|javax)/[A-Za-z0-9_$/]+)"') -# `crate::classes::java::lang::BootstrapMethodError::as_proto(),` -REGISTERED = re.compile(r"crate::classes::([A-Za-z0-9_:]+)::as_proto\(\)") -# The `name:` field inside an `impl { ... }` block. -IMPL_BLOCK = re.compile(r"impl\s+([A-Za-z0-9_]+)\s*\{(.*?)\n\}", re.S) +# `crate::classes::java::lang::BootstrapMethodError::as_proto(),` -- and `list_proto()`, which three +# entries use (AbstractListItr, ArrayListItr, VectorItr). Matching only `as_proto` made the check +# report those three classes as unregistered when they are registered. +REGISTERED = re.compile(r"crate::classes::([A-Za-z0-9_:]+)::((?:as|list)_proto)\(\)") +# `impl {` -- the start of a block, not the whole block: a type can hold more than one proto +# constructor and they name different classes. +IMPL_START = re.compile(r"^impl\s+([A-Za-z0-9_]+)\s*\{", re.M) +# `pub fn as_proto() -> RuntimeClassProto { … }` inside such a block. +PROTO_FN = re.compile(r"pub fn ([a-z_]+)\(\)\s*->\s*RuntimeClassProto\s*\{(.*?)\n \}", re.S) NAME_FIELD = re.compile(r'name:\s*"([^"]+)"') @@ -87,17 +100,38 @@ def rust_files(): def named_classes(): - """{class name: [file:line, ...]} for every literal exception(...) name.""" + """{class name: [file:line, ...]} for every literal exception(...) name. + + Matched against the whole file rather than line by line, because rustfmt breaks a long call + after `exception(` and the pattern's `\\s*` has to cross that newline. Line numbers are + recovered from the match offset so the report still points at a place. + """ found = {} for path in rust_files(): - for number, line in enumerate(read(path).splitlines(), 1): - for match in NAMED.finditer(line): - found.setdefault(match.group(1), []).append(f"{path.relative_to(ROOT)}:{number}") + text = read(path) + for match in NAMED.finditer(text): + line = text.count("\n", 0, match.start()) + 1 + found.setdefault(match.group(1), []).append(f"{path.relative_to(ROOT)}:{line}") return found def loadable_classes(): - """Names the bootstrap loader can return, i.e. the registered protos.""" + """Names the bootstrap loader can return, i.e. the registered protos. + + Keyed by (module path, type, function), not by type alone. Two of each are needed: + + * Bare type names collide. This tree holds two `Formatter`s (`java/util` and + `java/util/logging`) and two `JarURLConnection`s (`java/net` and `org/rustjava/net`), each + registered separately. Keying by type alone let one of a pair answer for the other, so + deleting a registration left the check green -- the exact failure it exists to catch. + * One type can hold more than one proto constructor. `AbstractListItr::as_proto()` names + `java/util/AbstractList$Itr` while its `list_proto()` names `java/util/AbstractList$ListItr`; + taking the first `name:` in the block attributed the wrong class to the registration and + reported the other as absent. + + The module path comes from the file's own location under `classes/`, which is what the + `crate::classes::…` path in the loader spells. + """ if not LOADER.is_file(): die(f"{LOADER.relative_to(ROOT)} is missing") if not CLASSES.is_dir(): @@ -105,23 +139,27 @@ def loadable_classes(): name_of = {} for path in sorted(CLASSES.rglob("*.rs")): + module = "::".join(path.relative_to(CLASSES).parts[:-1]) text = read(path) - for match in IMPL_BLOCK.finditer(text): - field = NAME_FIELD.search(match.group(2)) - if field: - name_of.setdefault(match.group(1), field.group(1)) + starts = [(m.start(), m.group(1)) for m in IMPL_START.finditer(text)] + [(len(text), None)] + for (start, type_name), (end, _) in zip(starts, starts[1:]): + for function in PROTO_FN.finditer(text[start:end]): + field = NAME_FIELD.search(function.group(2)) + if field: + name_of[(module, type_name, function.group(1))] = field.group(1) registered = REGISTERED.findall(read(LOADER)) if not registered: - die(f"no ::as_proto() entries found in {LOADER.relative_to(ROOT)}") + die(f"no proto registrations found in {LOADER.relative_to(ROOT)}") names, unresolved = set(), [] - for path in registered: - type_name = path.split("::")[-1] - if type_name in name_of: - names.add(name_of[type_name]) + for path, function in registered: + parts = path.split("::") + key = ("::".join(parts[:-1]), parts[-1], function) + if key in name_of: + names.add(name_of[key]) else: - unresolved.append(path) + unresolved.append(f"{path}::{function}()") if unresolved: die("registered entries with no resolvable name: " + ", ".join(sorted(set(unresolved)))) return names