Skip to content

Commit 5d732b1

Browse files
authored
[rustjava-error-path-needs-java-lang-string-measure-first] fix(jvm): 오류 경로의 또 하나 java/lang/String — 재귀한다, 그래서 생성 시점에 막는다 (#84)
[rustjava-error-path-needs-java-lang-string-measure-first] fix(jvm): 오류 경로의 또 하나 java/lang/String — 재귀한다, 그래서 생성 시점에 막는다
2 parents 750d30d + 7029e1b commit 5d732b1

6 files changed

Lines changed: 216 additions & 0 deletions

File tree

‎REPORT.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
11
# REPORT
2+
## [2026-09-19] 오류 경로의 «또 하나»는 `java/lang/String` 이고 — ★**재귀한다**(rustjava-error-path-needs-java-lang-string-measure-first)
3+
- 무엇을: 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`(worklog json 기록). ★**제안의 조건이 「측정이 먼저」였고 그대로 했다** — 선행 회차가 String 에 대해 **아무것도 주장하지 않았고**(자기 worklog 에 「실측 아님」이라 적었다) 셋 중 무엇인지가 열려 있었다.
4+
- ★★**답 = ⒜ 재귀한다**(⒝ 깨끗한 실패도, ⒞ 이미 상주도 아니다). 하니스로 String 을 숨겨 이분법으로 경계를 찾았다: 상한 **116 생존 ↔ 117 `stack overflow, aborting`(SIGABRT · rc 134)** · ★**양쪽 2회씩 재현** · ★**상한 100000 도 abort** ⇒ 상한은 «하니스가 양보하는 지점»이지 **바닥이 아니다**.
5+
- ★**⒞가 아닌 이유를 «구조»로**: `bootstrap_classes` 는 **6개**(`Object`·`Runnable`·`Thread`·`[B`·`Serializable`·`Class`)이고 ★**String 은 없다** · `JavaLangClass::from_rust_class` 는 클래스 이름을 **바이트 배열**(`nameBytes` `[B`)로 넣지 **String 으로 넣지 않는다** ⇒ 구성 중 String 을 처음 필요로 하는 곳은 **프로퍼티 루프**이고 그때는 로더가 유일한 출처다.
6+
- ★**처방**: 기존 `NoClassDefFoundError` 확인과 **같은 관용**(로더에 **직접** 묻고 그 뒤 resolve) — ★**bare `resolve_class` 는 답이 안 된다**(실패를 `Jvm::exception` 에 넘기는데 그것이 곧 순환이다).
7+
★★**자리가 장식이 아니다 — 프로퍼티 루프 «앞»이다.** 기존 확인은 그 루프 **뒤**에 있어 String 형상은 **거기 닿기 전에** 재귀한다.
8+
- ★**양방향 축**(제품 호출부 `jvm/src/jvm.rs` · 사본 아님): 원형상 **green(2 passed)** ↔ 확인 제거 → ★**`stack overflow, aborting` SIGABRT signal 6 — 테스트 «바이너리째» 내려간다** ↔ 복원 **green**.
9+
- ★★**시작 비용 — 수로 적는다**(제안이 「every start-up」을 비용으로 지목했다). 결정론 계수(하니스 counter · `give_up_after=0` = 숨기지 않고 «세기만»): 로더가 String 을 묻는 횟수 ★**후 2회 ↔ 전 1회 = 추가 «1회»**. 곁의 `resolve_class` 는 **추가가 아니라 이동**이다(프로퍼티 루프가 하던 해석이 앞당겨지고, 루프는 등재된 것을 찾는다).
10+
★**벽시계 측정은 «버렸다»** — 형제 레인이 다른 repo 에서 cargo 를 돌고 있어 **같은 형상의 p50 이 69ms~1690ms** 로 흔들렸고 교대 8회 중 **3회는 «확인 있는 쪽»이 더 빨랐다**. ⇒ 그것은 비용이 아니라 «부하»를 잰다. ★**수를 주장하지 않는다**(노이즈 아래라고만 적는다).
11+
- ★**안 한 것**: 이 **두 클래스**만 덮는다 — 두 클래스의 생성자·정적 초기화가 닿는 것은 **미측정**이다(티켓 non-goal · 후속 카드).
12+
- 검증: DoD 9명령 · 아래 절.
13+
- ★후속 추천: **오류 경로의 나머지 클래스 집합을 «한 번에» 측정으로 찾을 것인가**(M · 지금은 회차당 한 클래스씩 «누가 물어봐야» 찾는다). 상세 = `docs/worklog/2026-09-19-string-on-the-error-path.md`.
14+
215
## [2026-09-19] 두 회차를 비교할 수 있게 «순서»를 고정했다 (2026-09-19-partial-clone-blob-vs-absence-p0)
316
- 무엇을: 채택 제안 `2026-09-19-partial-clone-blob-vs-absence#p0`. `scripts/check-merge-dropped-symbols.py` 가 **같은 결과를 회차마다 다른 순서로** 찍어, 두 회차를 diff 하면 ★**없는 차이가 보였다**. ★**코드 1줄**(+주석 8줄).
417
- ★**재현**: `origin/main` 판본 · 범위 `e53b2142^..e53b2142`(8머지 · 6드롭 · rc 1) · `PYTHONHASHSEED=random` **10회** → ★**서로 다른 순서 «2종»**(6회/4회). 차이는 ★**경로 블록의 선후 하나뿐**이다.

‎STATE.md‎

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

99
## 완료
10+
- [rustjava-error-path-needs-java-lang-string-measure-first] ★★**오류 경로의 또 하나 `java/lang/String` — ⒜ «재귀한다»로 확정하고 선재 확인을 넣었다.** 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`. ★제안의 조건이 「측정이 먼저」였고 그대로 했다.
11+
★**실측**: 상한 **116 생존 ↔ 117 SIGABRT «stack overflow, aborting»**(양쪽 2회 재현) · ★**상한 100000 도 abort** ⇒ 바닥이 없다.
12+
★**⒞ 아님을 구조로**: `bootstrap_classes` **6개에 String 없음** · `from_rust_class` 는 이름을 **`[B`(nameBytes)** 로 넣는다 ⇒ 처음 필요한 곳은 프로퍼티 루프.
13+
★**자리**: 프로퍼티 루프 **앞**(기존 `NoClassDefFoundError` 확인은 그 **뒤**라 String 형상엔 **늦다**) · 관용은 동일(로더에 **직접** 질의 후 resolve — bare `resolve_class` 는 순환 자신에게 넘긴다).
14+
★**양방향**(제품 호출부): green ↔ 제거 시 ★**바이너리째 SIGABRT** ↔ 복원 green.
15+
★**시작 비용**: 로더 질의 **1 → 2회**(결정론 계수). ★**벽시계는 버렸다** — 형제 레인 부하로 같은 형상 p50 이 69ms~1690ms 고 교대 8회 중 3회는 확인 있는 쪽이 더 빨랐다 ⇒ 수를 주장하지 않는다.
16+
★**미측정**: 두 클래스의 생성자·정적 초기화가 닿는 나머지(후속 카드).
1017
- [2026-09-19-partial-clone-blob-vs-absence-p0] ★★**`check-merge-dropped-symbols.py` 의 출력 순서를 고정했다 — 두 회차를 diff 할 수 있다.** 채택 제안 `2026-09-19-partial-clone-blob-vs-absence#p0`. ★**코드 1줄**(+주석 8줄) · `.rs` 0줄.
1118
★**재현**: `origin/main` 판본 · `PYTHONHASHSEED=random` **10회** → 순서 **2종**(같은 6건) ⇒ 없는 차이가 diff 에 보였다.
1219
★★**제안 진단은 부정확**: `findings` 는 set 이 아니라 **list** 이고 이름은 이미 정렬돼 있었다 — set 4개 중 출력에 닿는 것은 ★**`changed`(경로) 하나**다. ⇒ 「print site」가 아니라 ★**출처에서** 정렬했다(`check()` 의 **반환값**도 결정적이어야 하므로).
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"date": "2026-09-19",
3+
"taskId": "rustjava-error-path-needs-java-lang-string-measure-first",
4+
"summary": "Measured first, as the proposal required. Hiding java/lang/String from the bootstrap loader recurses with no floor, exactly like NoClassDefFoundError: caps up to 116 survive, 117 aborts with 'stack overflow, aborting' (SIGABRT), and raising the cap to 100000 aborts the same way, so the cap is the harness relenting and not a floor. The boundary reproduced exactly across repeats. That is outcome (a) of the three the proposal listed, so the check goes in - same idiom as the existing NoClassDefFoundError check, placed before the properties loop because that loop is the first thing in construction that needs a String and the existing check sits after it. Start-up cost measured deterministically rather than by wall clock: the loader is asked for String twice instead of once.",
5+
"decision": "String recurses; add the pre-flight check in the NoClassDefFoundError idiom, before the properties loop",
6+
"measurements": {
7+
"cited_tree": "origin/main 64cc4f6440af5790685a633a3c13d4d32ea62ae5",
8+
"outcome": "a-recurses",
9+
"max_surviving_give_up_after": 116,
10+
"min_aborting_give_up_after": 117,
11+
"abort_signal": "SIGABRT (signal 6), rc 134",
12+
"abort_at_cap_100000": true,
13+
"boundary_repeats": 2,
14+
"loader_questions_for_string_with_check": 2,
15+
"loader_questions_for_string_without_check": 1,
16+
"bootstrap_classes_count": 6,
17+
"string_in_bootstrap_classes": false,
18+
"nocdfe_lineage_measured_round_trips": 121
19+
},
20+
"verification": [
21+
"premise (a) confirmed in code: Jvm::exception calls JavaLangString::from_rust_string at jvm/src/jvm.rs:990, before new_class at :995",
22+
"premise (b) confirmed: test_utils::test_jvm_hiding(hidden, give_up_after) matches the hidden name exactly, so it takes java/lang/String",
23+
"outcome measured by bisection on give_up_after between a surviving 60 and an aborting 120; 116 survives (construction ends in a plain NoClassDefFoundError), 117 aborts; both sides reproduced twice",
24+
"not (c): java/lang/String is not in the 6-name bootstrap_classes list, and JavaLangClass::from_rust_class stores the class name as a byte array (nameBytes, [B) rather than a String, so nothing resident before the properties loop supplies it",
25+
"bidirectional axis on the product call site jvm/src/jvm.rs (not a fixture copy): original green (2 passed) -> check deleted: 'fatal runtime error: stack overflow, aborting', SIGABRT signal 6 -> restored: green",
26+
"start-up cost measured with the harness counter at give_up_after=0 (never hides, only counts), which is deterministic and immune to machine load: 2 questions with the check, 1 without; the two test binaries differed by sha256, so the two forms were genuinely different code"
27+
],
28+
"changes": [
29+
"jvm/src/jvm.rs - direct load_class question for java/lang/String plus resolve_class, before the properties loop; comment records the measured numbers and why it cannot sit beside the NoClassDefFoundError check",
30+
"jvm/tests/test_exception_fallback_recursion.rs - second should_panic test for the String arm",
31+
"docs/worklog/2026-09-19-string-on-the-error-path.{md,json}, REPORT.md, STATE.md"
32+
],
33+
"issues": [
34+
"Wall-clock start-up timing was attempted first and discarded as unusable: another lane was running cargo on a sibling repo and the same form's p50 swung between 69ms and 1690ms, with the with-check form faster than the without-check form in 3 of 8 interleaved rounds. The reported cost is the deterministic loader-question count instead. The wall-clock difference is below this host's noise floor and no number is claimed for it.",
35+
"The check costs one loader question on every start-up and that cannot be avoided while keeping the property that makes it work: a bare resolve_class would hand the failure to Jvm::exception, which is the cycle itself. It is the same price the NoClassDefFoundError check already pays.",
36+
"Only these two classes are covered. Nothing was measured about any other class the error path might reach - the proposal's non-goal, left as a follow-up."
37+
],
38+
"adoptedProposals": [
39+
"2026-09-19-fallback-class-absence-fails-at-construction#p0"
40+
],
41+
"proposals": [
42+
{
43+
"title": "Find the rest of the error path's class set by measurement, not one class at a time",
44+
"plainSummary": "Two classes are now known to be required before any error can be reported, and both were found the same way - one at a time, by a round that guessed the next name. Nothing lists what else the reporting path needs.",
45+
"userBenefit": "A host with an incomplete class set would learn every missing name at start-up at once, instead of discovering them one crash and one round at a time.",
46+
"why": "This round and the one before it each closed exactly one class, and each found the next by reading the code and guessing. The harness makes measuring any single name cheap, but nothing enumerates the candidates: Jvm::exception needs String and NoClassDefFoundError today, and the classes reached by their constructors and static initialisers are unmeasured. A sweep that hides each bootstrap-reachable name in turn and records which ones recurse would answer the whole question once.",
47+
"tradeoff": "The sweep costs a test run per candidate name and the candidate list itself has to be derived, which is the work. If it turns out the answer is still just these two, the result is a recorded negative rather than a new check - worth knowing, but it does not change any code. Against that, the current pattern spends a whole round per class and only finds the class after someone thinks to ask.",
48+
"effort": "M",
49+
"target": "jvm/src/jvm.rs, jvm/tests/test_exception_fallback_recursion.rs, test-utils/src/lib.rs"
50+
}
51+
]
52+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# 2026-09-19 — `java/lang/String` is on the error path too, and it recurses
2+
3+
Task: `rustjava-error-path-needs-java-lang-string-measure-first`
4+
Adopted proposal: `2026-09-19-fallback-class-absence-fails-at-construction#p0`
5+
Cited tree: `origin/main` `64cc4f6440af5790685a633a3c13d4d32ea62ae5`
6+
7+
## The question, and why it was a question
8+
9+
The round that closed the `NoClassDefFoundError` cycle deliberately claimed nothing about String. It
10+
said so in its own worklog: no harness run was done for it, and whether String **recurses**, **fails
11+
cleanly**, or is **already resident by construction time** was unknown. The proposal was explicit that
12+
the measurement had to come first — if String were resident well before any error path could run, the
13+
check would be dead weight on every start-up and one more line asserting something that cannot happen.
14+
15+
So this round measured before it decided anything.
16+
17+
## Premises, re-checked
18+
19+
- `Jvm::exception` calls `JavaLangString::from_rust_string` at `jvm/src/jvm.rs:990`, **before**
20+
`new_class` at `:995`. String is on the error path exactly as `NoClassDefFoundError` is. ✓
21+
- `test_utils::test_jvm_hiding(hidden, give_up_after)` matches the hidden name exactly, so it takes
22+
`java/lang/String` without touching the harness. ✓
23+
24+
## The measurement
25+
26+
`give_up_after` is the harness relenting: after that many refusals the real class is handed over, so a
27+
run that would otherwise recurse forever ends and can be observed instead of crashing the runner.
28+
29+
| `give_up_after` | result |
30+
|---|---|
31+
| 5, 20, 60 | survives — construction ends in a plain `NoClassDefFoundError` |
32+
| **116** | survives (reproduced twice) |
33+
| **117** | ★ `fatal runtime error: stack overflow, aborting` — SIGABRT, rc 134 (reproduced twice) |
34+
| 120, 160, 200 | aborts |
35+
| **100000** | aborts — so the cap is the harness relenting, **not a floor** |
36+
37+
Found by bisection between a surviving 60 and an aborting 120. The boundary is sharp and deterministic.
38+
39+
**Answer: ⒜ it recurses.** Not ⒝, not ⒞.
40+
41+
### Why it is not ⒞ (already resident), structurally
42+
43+
- `bootstrap_classes` in `Jvm::new` is **6 names** — `Object`, `Runnable`, `Thread`, `[B`,
44+
`Serializable`, `Class` — and `java/lang/String` is **not among them**.
45+
- `JavaLangClass::from_rust_class` stores the class name as a **byte array** (`nameBytes`, `[B`), not
46+
a String — so setting up the bootstrap classes' `Class` objects does not pull String in either.
47+
- The first thing in construction that needs a String is the **properties loop**, and by then the
48+
loader is the only source.
49+
50+
## The decision: put the check in
51+
52+
Outcome ⒜ is the branch where the proposal's own tradeoff does not apply — String is not resident, and
53+
without the check a host with an incomplete class set gets a SIGABRT rather than a named failure.
54+
55+
Same idiom as the existing `NoClassDefFoundError` check: ask the loader **directly**, then resolve.
56+
The direct question is the part that matters — a bare `resolve_class` would hand the failure to
57+
`Jvm::exception`, which is the cycle itself.
58+
59+
**Placement is not cosmetic.** It goes *before* the properties loop, not beside the existing check.
60+
The existing check sits *after* that loop, so a String-shaped failure reaches the recursion before
61+
anything downstream could report it.
62+
63+
## Cost
64+
65+
The proposal named "every start-up" as the cost, so it is measured rather than asserted.
66+
67+
**Deterministic count** (harness counter at `give_up_after=0` — never hides, only counts):
68+
69+
| form | times the loader is asked for `java/lang/String` |
70+
|---|---|
71+
| with check | **2** |
72+
| without | **1** |
73+
74+
One extra question. The `resolve_class` beside it does not add a second resolution — it *moves* the
75+
one the properties loop already did, which then finds String registered.
76+
77+
**Wall-clock timing was attempted and discarded.** A sibling lane was running cargo on another repo;
78+
the same form's p50 swung between 69 ms and 1690 ms, and across 8 interleaved rounds the with-check
79+
form was *faster* than the without-check form in 3 of them. That measures load, not cost. No wall-clock
80+
number is claimed — only that the difference is below this host's noise floor.
81+
82+
## Bidirectional axis (product call site, `jvm/src/jvm.rs` — not a fixture copy)
83+
84+
| form | result |
85+
|---|---|
86+
| original | green — 2 passed |
87+
| check deleted | ★ `fatal runtime error: stack overflow, aborting` — SIGABRT signal 6 |
88+
| restored | green — 2 passed |
89+
90+
Deleting the check does not merely fail the test: it **takes the test binary down**, which is the
91+
point. An abort is what a host embedding this runtime would get.
92+
93+
## What this round does not claim
94+
95+
- Only these two classes are covered. Whatever else the error path reaches — through the constructors
96+
and static initialisers of `String` and `NoClassDefFoundError` — is **unmeasured**. Out of scope by
97+
the ticket's own non-goal; carried as the follow-up proposal.
98+
- The one extra loader question per start-up is real and unavoidable while keeping the property that
99+
makes the check work. It is the same price the `NoClassDefFoundError` check already pays.

‎jvm/src/jvm.rs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,31 @@ impl Jvm {
105105
class.set_java_class(java_class);
106106
}
107107

108+
// Resolve the *other* class the error path needs, for the same reason and with the same
109+
// shape as the `java/lang/NoClassDefFoundError` check below. `Jvm::exception` builds its
110+
// message with `JavaLangString::from_rust_string` *before* it builds the exception instance,
111+
// so a class set without `java/lang/String` cannot report anything either: reporting the
112+
// missing String needs a String. Measured against a loader that hides it -- 116 round trips
113+
// survived, 117 died of `stack overflow, aborting` (SIGABRT), and the boundary reproduced
114+
// exactly across repeats. The cap is the harness giving the real class up, not a floor:
115+
// raising it to 100000 aborts just the same, so there is no floor.
116+
//
117+
// Here rather than beside that check, because this one has to come *first*: the properties
118+
// loop directly below is the first thing in construction that needs a String, and the check
119+
// below it runs too late to be reached. Same reason it cannot go in `bootstrap_classes`
120+
// above -- resolution runs class initialisation, which needs the thread attached above.
121+
// Asked of the loader directly first, because the reporting path cannot report *this*
122+
// failure: building the report is the thing that is missing.
123+
assert!(
124+
jvm.inner.bootstrap_class_loader.load_class(&jvm, "java/lang/String").await?.is_some(),
125+
"the class set has no java/lang/String, which every raised error needs before it can \
126+
exist: an exception carries a message, and building that message is the first thing \
127+
the reporting path does. Nothing can be raised without it -- reporting the absent \
128+
String would itself need a String, and that recursion has no floor (measured: 117 \
129+
round trips, then the process aborts on a stack overflow). Add it to the class set."
130+
);
131+
jvm.resolve_class("java/lang/String").await?;
132+
108133
// init properties
109134
for (key, value) in properties {
110135
let key = JavaLangString::from_rust_string(&jvm, key).await?;

0 commit comments

Comments
 (0)