Skip to content

Commit edb5738

Browse files
jun0claude
andcommitted
[rustjava-error-path-string-array-hiding-overflows-stack-p1] docs(jvm): cap 은 이 재귀를 «끝낸다» — 지난 회차 원인 기재를 반증한다
채택 제안 2026-09-20-error-path-class-closure#p1 (조사 회차 · 제품 코드 0줄). 재현했다: cap 20 · 기본 스택 → `stack overflow, aborting` rc 134. 그러나 원인 기재가 틀렸다. 「its recursion does not come back through the loader, so the cap cannot end it」는 거짓이다 — 매 턴 로더로 돌아오고, 살아남은 모든 실행에서 `asked = cap + 1` 이다(= `[C` 가 보이는 그 모양). 두 손잡이가 따로 임계를 움직이므로 「유한하지만 깊다」로 확정된다(무한이면 둘 다 안 움직인다): cap 은 스택 2 MiB 고정에서 18 생존 ↔ 19 오버플로, 스택은 cap 20 고정에서 2 MiB 오버플로 ↔ 4 MiB 생존(asked=21). 순환은 백트레이스로 보였다(계측은 되돌렸다): fillInStackTrace → instantiate_array("Ljava/lang/String;") → resolve_class → load_class(None) → exception → new_class → invoke_special ×4 (NoClassDefFoundError → LinkageError → Error → Throwable.<init>) → init_with_message → invoke_virtual → fillInStackTrace. 한 턴 ≈ 57 프레임이고, `[C` 는 from_rust_string 안에서 도는 짧은 경로라 같은 스택에 20턴이 들어간다. 차이는 모양이 아니라 턴당 비용이다. 대조(cap 20): `[C` 만 같이 돌고, `[I`·`[Ljava/lang/Object;`· `[Ljava/lang/StackTraceElement;`·java/lang/Integer·java/lang/StringBuilder 는 asked=0 이다 ⇒ 「배열이면 난다」가 아니라 「오류 경로가 실제로 필요로 하는 두 배열」이다. (`[B` 는 bootstrap_classes 소속이라 형제 #p0 의 결함이다.) 배열 제외는 유지한다 — 그 이유(로더가 합성하므로 어떤 클래스 집합도 배열을 빠뜨릴 수 없다)는 이번 측정과 무관하게 유효하다. 개악 양방향: 해당 없음(동작을 바꾸지 않았다). DoD 9명령 rc 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 97707f2 commit edb5738

5 files changed

Lines changed: 160 additions & 5 deletions

File tree

REPORT.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
# REPORT
2+
## [2026-09-20] `[Ljava/lang/String;` 오버플로 — ★**원인을 찾았고, 지난 회차 기재가 «틀렸다»** (rustjava-error-path-string-array-hiding-overflows-stack-p1)
3+
- 무엇을: 채택 제안 `2026-09-20-error-path-class-closure#p1`(★**조사 회차** — 제품 코드 **0줄**). cap 20 에서 `stack overflow, aborting`(rc 134)을 **재현**하고 원인을 규명했다.
4+
- ★★**결론 — 지난 회차의 원인 기재 「its recursion does not come back through the loader, so the cap cannot end it」은 «반증»됐다.** 매 턴 로더로 **돌아오고**, cap 이 **끝낸다** — 살아남은 전 실행에서 `asked = cap + 1`(= `[C`**같은 모양**). 넘치는 이유는 모양이 아니라 ★**턴당 비용**이다.
5+
-**두 손잡이가 «따로» 임계를 움직인다**(무한 재귀면 둘 다 안 움직인다 ⇒ 가설이 갈린다): ⒜cap(스택 2 MiB 고정) **18 생존 ↔ 19 오버플로** ⒝스택(cap 20 고정) **2 MiB 오버플로 ↔ 4 MiB 생존**(asked=21 · 8/32/128 MiB 도 생존).
6+
-**순환을 «백트레이스로» 보였다**(추측 아님 · 계측은 되돌렸다): `fillInStackTrace``instantiate_array("Ljava/lang/String;")``resolve_class``load_class`(None) → `exception``new_class`**`invoke_special` ×4**(`NoClassDefFoundError``LinkageError``Error``Throwable.<init>`) → `init_with_message``invoke_virtual``fillInStackTrace`. ★**한 턴 ≈ 57 프레임**`[C``from_rust_string` **안에서** 도는 짧은 경로라 같은 스택에 20턴이 들어간다.
7+
-**「이 타입이 특별한가」를 대조로 답했다**(cap 20): `[C` 재귀·생존 · `[B``bootstrap_classes` 소속이라 **형제 `#p0` 의 결함** · `[I`·`[Ljava/lang/Object;`·`[Ljava/lang/StackTraceElement;`·`java/lang/Integer`·`java/lang/StringBuilder`**asked=0**(구성이 묻지도 않는다). ⇒ ★**「배열이면 난다」가 아니다** — 오류 경로가 실제로 필요로 하는 **두 배열**만 돈다.
8+
-**cap 20 의 출처**: `test_error_path_class_sweep.rs``const GIVE_UP_AFTER: u32 = 20``test_jvm_hiding``HidesOneClass`. ★**테스트 전용**이고 **제품에는 상한이 없다**.
9+
-**고친 것 = 스윕 헤더의 거짓 문장 하나.** 배열 제외는 **그대로** 둔다(이유는 원래부터 「로더가 **합성**하므로 어떤 클래스 집합도 배열을 못 빠뜨린다」였고 이번 측정과 무관하게 유효하다). ★**개악 양방향은 «해당 없음»** — 동작을 바꾸지 않았다.
10+
-**대가**: 새 잠금 0. 순환은 **제품에 바닥이 없는 채로 남는다**(오늘 닿을 수 없을 뿐) ⇒ 후속 **1건**: **`Jvm::exception` 에 바닥을 주라**(M). 상세 = `docs/worklog/2026-09-20-string-array-hiding-overflows-stack.{md,json}`.
11+
- 검증: DoD 9명령 rc 0 · 실행 31회(전부 1초 미만) · 빌드 1회(3m26s · 워밍된 target 재사용).
12+
213
## [2026-09-20] 오류 경로의 클래스 집합을 «한 번에» 쟀다 — ★**답은 «둘»이 아니었다**(rustjava-string-on-the-error-path-p0)
314
- 무엇을: 채택 제안 `2026-09-19-string-on-the-error-path#p0`. 후보를 **유도**해(기록 로더로 정상 구성 1회 — 요청 **51** · 서로 다른 이름 **42** · 배열 **5** 제외 ⇒ **37**) 하나씩 숨겨 재구성하는 **스윕**을 만들고 돌렸다.
415
- ★★**결과 — 제안이 「still just these two」면 «기록된 음성»이라 했던 그 가정이 «반증»됐다**: ★**5개가 더 재귀한다**(바닥 없음) — `java/lang/Throwable` · `Error` · `LinkageError` · `CharSequence` · `Comparable`. ★**우연이 아니다** — 기존 두 이름의 **상위형·인터페이스 폐포**다(클래스를 resolve 하면 상위형도 resolve 되고, 거기 결손은 «아직 resolve 중인 그 두 클래스»로 보고된다).

STATE.md

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

99
## 완료
10+
- [rustjava-error-path-string-array-hiding-overflows-stack-p1] ★★**`[Ljava/lang/String;` 오버플로의 원인 — 지난 회차 기재가 «틀렸다».** 채택 제안 `2026-09-20-error-path-class-closure#p1` · ★**조사 회차 · 제품 코드 0줄**.
11+
**재현**: cap 20 · 기본 스택 → `stack overflow, aborting` rc **134**.
12+
★★**반증**: 「로더로 안 돌아와 cap 이 못 끝낸다」는 **거짓** — 살아남은 전 실행에서 `asked = cap+1`(= `[C` 와 같은 모양). 원인은 ★**턴당 비용**.
13+
**두 손잡이가 따로 움직인다**(가설 분리): cap **18↔19** · 스택 **2 MiB↔4 MiB**(cap 20 고정).
14+
**순환을 백트레이스로**: `fillInStackTrace``instantiate_array``resolve_class``load_class`(None)→`exception``new_class``invoke_special`×4→`Throwable.<init>``fillInStackTrace` · ★**한 턴 ≈ 57 프레임**.
15+
**대조**: `[C` 만 같이 돈다 · `[I`·`[Ljava/lang/Object;`·`java/lang/Integer` 등은 **asked=0****「배열이면 난다」가 아니다**.
16+
**고침 = 스윕 헤더의 거짓 문장 하나** · 배열 제외는 유지(이유가 다르고 여전히 유효) · **개악 양방향 해당 없음**(동작 무변경).
17+
**남긴 것**: 순환은 제품에 **바닥이 없다**(오늘 닿을 수 없을 뿐) ⇒ 후속 카드 「`Jvm::exception` 에 바닥을」(M).
1018
- [rustjava-string-on-the-error-path-p0] ★★**오류 경로의 클래스 집합을 한 번에 쟀다 — 답은 «둘»이 아니었다.** 채택 제안 `2026-09-19-string-on-the-error-path#p0`.
1119
**후보를 «유도»했다**(손 목록 아님): 기록 로더로 정상 구성 1회 → 요청 **51** · 서로 다른 이름 **42** · 배열 **5** 제외 ⇒ 후보 **37**.
1220
★★**5개가 더 재귀한다**: `Throwable`·`Error`·`LinkageError`·`CharSequence`·`Comparable` = 기존 두 이름의 **상위형·인터페이스 폐포**.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"date": "2026-09-20",
3+
"taskId": "rustjava-error-path-string-array-hiding-overflows-stack-p1",
4+
"summary": "Adopted 2026-09-20-error-path-class-closure#p1, an investigation. Hiding [Ljava/lang/String; at a cap of 20 does abort with `stack overflow` -- reproduced -- but the reason recorded last round is wrong. The recursion does come back through the loader on every turn and the cap does end it; what differs from [C is the cost of one turn. The sweep's doc comment said the opposite and is corrected; no product code changed.",
5+
"decision": "Cause found, nothing to fix in the product. The cycle is Throwable.fillInStackTrace -> instantiate_array(\"Ljava/lang/String;\") -> resolve_class -> load_class -> exception -> new_class -> Throwable.<init> -> fillInStackTrace, and it is bounded by whatever ends the failure. No host can enter it through a class set, because the bootstrap loader synthesises array classes. The false sentence in the sweep's header is the only thing this round changes.",
6+
"measurements": {
7+
"cited_tree": "origin/main 97707f26",
8+
"reproduced": "yes -- cap 20, default stack: `thread 'probe' has overflowed its stack` / `fatal runtime error: stack overflow, aborting`, rc 134",
9+
"where_the_cap_comes_from": "GIVE_UP_AFTER: u32 = 20, a const in jvm/tests/test_error_path_class_sweep.rs, passed to test_utils::test_jvm_hiding -> HidesOneClass.give_up_after. Test-only; the product has no such cap",
10+
"cap_axis_default_2MiB_stack": {
11+
"0": "survived, asked=1, construction OK (cap 0 never hides)",
12+
"1": "survived, asked=2", "2": "survived, asked=3", "3": "survived, asked=4", "5": "survived, asked=6",
13+
"10": "survived, asked=11", "11": "survived, asked=12", "12": "survived, asked=13", "13": "survived, asked=14",
14+
"14": "survived, asked=15", "15": "survived, asked=16", "16": "survived, asked=17", "17": "survived, asked=18",
15+
"18": "survived, asked=19", "19": "STACK OVERFLOW", "20": "STACK OVERFLOW"
16+
},
17+
"threshold_cap": "18 survives / 19 overflows",
18+
"stack_axis_at_cap_20": { "2MiB": "overflow", "4MiB": "survived, asked=21", "8MiB": "survived, asked=21", "32MiB": "survived, asked=21", "128MiB": "survived, asked=21" },
19+
"asked_is_always_cap_plus_one": "every surviving run, which is the same shape [C shows at cap 20 (asked=21)",
20+
"frames_per_cycle": "~57 backtrace frames between one fillInStackTrace and the next",
21+
"contrast_at_cap_20": {
22+
"[C": "recursed, asked=21 (survives -- cheaper turn, reached inside from_rust_string)",
23+
"[Ljava/lang/String;": "stack overflow at 2 MiB, asked=21 at 4 MiB",
24+
"[B": "panics (it is in bootstrap_classes -- that is the sibling #p0 defect, not this one)",
25+
"[I, [Ljava/lang/Object;, [Ljava/lang/StackTraceElement;, java/lang/Integer, java/lang/StringBuilder": "asked=0, construction succeeds -- construction never asks for them"
26+
},
27+
"runs": "31 process runs total, each a sub-second JVM construction; one 3m26s build reusing the warm target dir of the main checkout instead of a ~11m cold build"
28+
},
29+
"verification": {
30+
"hypotheses_separated_by_experiment": "unbounded recursion would be insensitive to both knobs. Both knobs move the outcome independently (cap 18/19 at fixed stack; 2 MiB/4 MiB at fixed cap 20), so the recursion is bounded and merely deep",
31+
"cycle_shown_not_inferred": "backtrace from a scratch panic at the 3rd entry to Throwable::fill_in_stack_trace, RUST_BACKTRACE=1: fill_in_stack_trace -> instantiate_array -> resolve_class -> resolve_class_internal -> load_class -> exception -> new_class -> invoke_special x4 (NoClassDefFoundError -> LinkageError -> Error -> Throwable) -> init_with_message -> invoke_virtual -> fill_in_stack_trace. The scratch instrumentation was reverted; nothing of it is committed",
32+
"bidirectional": "not applicable -- no behaviour was changed. The only change is a doc comment that was false; the measurements above are what makes it true",
33+
"dod": "all 9 DoD commands rc 0"
34+
},
35+
"changes": [
36+
"jvm/tests/test_error_path_class_sweep.rs: the header paragraph claiming the cap cannot end this recursion is replaced by what was measured. Arrays stay excluded, for the reason that was always the real one -- no class set can lack a synthesised class"
37+
],
38+
"issues": [
39+
"The cycle has no floor in the product: the cap that ends it is a test-only device. It is unreachable through a class set, but that is an argument about one axis, not about the cycle. Recorded as proposal p0.",
40+
"The frame count (~57) and the 2 MiB stack are this host's numbers on a debug build. A release build inlines and an OS with a different default thread stack moves the threshold; the shape does not move, which is the part that matters."
41+
],
42+
"adoptedProposals": [
43+
"2026-09-20-error-path-class-closure#p1"
44+
],
45+
"proposals": [
46+
{
47+
"title": "Give Jvm::exception a floor, so a failure while reporting a failure cannot recurse",
48+
"plainSummary": "If something goes wrong while the runtime is building an error message, it can start building another one, and so on until the process dies.",
49+
"userBenefit": "A host sees the first failure reported instead of a process that aborts with `stack overflow` and says nothing about what actually went wrong.",
50+
"why": "Measured this round: Jvm::exception -> new_class -> Throwable.<init> -> fillInStackTrace -> instantiate_array -> resolve_class -> load_class -> Jvm::exception is a closed cycle, ~57 stack frames per turn, with nothing in the product that ends it -- the cap that ends it in tests is a test-only device in test_utils::HidesOneClass. The class-set axis is already closed (Jvm::new walks the error path's closure, and array classes are synthesised so no class set can lack one), which is why this is not reachable today. But that closes one entrance, not the cycle: any failure raised from inside exception construction re-enters it.",
51+
"tradeoff": "A re-entrancy guard has to decide what to return when it trips, and the honest answer is a non-Java error -- which `JavaError` cannot currently express (single variant, carries a ClassInstance). That is the same obstacle the sibling round hit, so the two should probably be decided together rather than each inventing an answer.",
52+
"effort": "M",
53+
"target": "jvm/src/jvm.rs, jvm/src/error.rs"
54+
}
55+
]
56+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# 2026-09-20 — `[Ljava/lang/String;` 를 숨기면 왜 스택이 넘치나 (조사)
2+
3+
채택 제안 `2026-09-20-error-path-class-closure#p1` (티켓 `rustjava-error-path-string-array-hiding-overflows-stack-p1`).
4+
5+
## 재현 — 했다
6+
`origin/main 97707f26` · cap **20** · 기본 스택(2 MiB) ⇒
7+
`thread 'probe' has overflowed its stack` / `fatal runtime error: stack overflow, aborting` · **rc 134**.
8+
9+
## cap 20 은 어디서 오는 수인가
10+
`jvm/tests/test_error_path_class_sweep.rs`**`const GIVE_UP_AFTER: u32 = 20`**
11+
`test_utils::test_jvm_hiding(name, give_up_after)``HidesOneClass.give_up_after`.
12+
**테스트 전용 상수**다 — 제품에는 이런 상한이 **없다**.
13+
14+
## ★★결론 — 지난 회차가 적은 원인은 «틀렸다»
15+
> 지난 회차 문안: 「its recursion does not come back through the loader, so the cap cannot end it」
16+
17+
**반증됐다. 매 턴 로더로 돌아오고, cap 이 끝낸다** — 살아남은 모든 실행에서 `asked = cap + 1`
18+
(= `[C` 가 보이는 바로 그 모양). 넘치는 이유는 **모양이 아니라 턴당 비용**이다.
19+
20+
**두 손잡이가 «따로» 임계를 움직인다** — 이것이 「무한」과 「유한하지만 깊다」를 가른다:
21+
22+
| 손잡이 | 고정 | 결과 |
23+
|---|---|---|
24+
| cap | 스택 2 MiB | **18 생존**(asked=19) ↔ ★**19 오버플로** |
25+
| 스택 | cap 20 | 2 MiB **오버플로** ↔ ★**4 MiB 생존**(asked=**21**) · 8/32/128 MiB 도 생존 |
26+
27+
무한 재귀였다면 **둘 중 어느 것도** 결과를 바꾸지 못한다.
28+
29+
## 순환 — 추측이 아니라 백트레이스
30+
`Throwable::fill_in_stack_trace` 3회 진입 시점에 스크래치 패닉 + `RUST_BACKTRACE=1`(계측은 되돌렸다):
31+
32+
```
33+
fill_in_stack_trace
34+
└ instantiate_array("Ljava/lang/String;")
35+
└ resolve_class → resolve_class_internal → load_class ← 로더가 None(숨김)
36+
└ exception("java/lang/NoClassDefFoundError", …)
37+
└ new_class → invoke_special ×4
38+
(NoClassDefFoundError → LinkageError → Error → Throwable.<init>)
39+
└ init_with_message → invoke_virtual "fillInStackTrace"
40+
└ fill_in_stack_trace ← 같은 자리, 다음 턴
41+
```
42+
한 턴 ≈ **57 프레임**. `[C``JavaLangString::from_rust_string` **안에서** 도는 훨씬 짧은 경로라
43+
같은 2 MiB 에 20턴이 들어간다. ⇒ ★**`[Ljava/lang/String;` 는 「배열이라서」가 아니라
44+
「오류 경로의 «더 깊은 곳»에서 필요해서」 비싸다.**
45+
46+
## 이 타입이 특별한가 — 대조로 답한다 (cap 20)
47+
| 숨긴 이름 | 결과 |
48+
|---|---|
49+
| `[C` | 재귀 · asked=**21** · 생존 |
50+
| `[Ljava/lang/String;` | 재귀 · 2 MiB **오버플로** / 4 MiB asked=**21** |
51+
| `[B` | `Option::unwrap()` 패닉 — ★`bootstrap_classes` 소속이라 **형제 `#p0` 의 결함**이다(이 회차 아님) |
52+
| `[I` · `[Ljava/lang/Object;` · `[Ljava/lang/StackTraceElement;` · `java/lang/Integer` · `java/lang/StringBuilder` | asked=**0** · 구성 성공 — 구성이 아예 묻지 않는다 |
53+
54+
⇒ ★**「배열이면 난다」가 아니다.** 오류 경로가 실제로 필요로 하는 **두 배열**만 돈다.
55+
56+
## 고쳤나
57+
**제품 코드 0줄.** 고친 것은 **스윕 헤더의 거짓 문장 하나**다 — 위 측정이 그것을 참으로 만든다.
58+
배열 제외는 **그대로 둔다**: 이유는 원래부터 「로더가 **합성**하므로 어떤 클래스 집합도 배열을
59+
빠뜨릴 수 없다」였고, 그 이유는 이번 측정과 **무관하게** 유효하다.
60+
61+
## 왜 지금인가 · 대가
62+
- **왜 지금**: 그 거짓 문장이 **제품 트리에 커밋돼** 있었고, 「이 축은 잴 수 없다」고 선언해
63+
**다음 회차가 재보지 않게 만드는** 형태였다.
64+
- **대가**: 조사 회차라 새 잠금은 없다. 순환 자체는 **제품에 바닥이 없는 채로 남는다**
65+
오늘 닿을 수 없을 뿐이다(제안 카드).

jvm/tests/test_error_path_class_sweep.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,26 @@
1717
//!
1818
//! Array names are skipped, and the reason is not squeamishness: the bootstrap loader *synthesises*
1919
//! them (`define_array_class`), so no class set can be missing one and hiding them measures a loader
20-
//! that refuses to build an array rather than a gap a host can have. It is not a free skip -- hiding
21-
//! `[C` recurses (21 questions at a cap of 20) and hiding `[Ljava/lang/String;` overflows the stack
22-
//! *at that same cap*, i.e. its recursion does not come back through the loader at all, so the cap
23-
//! cannot end it. That is the one candidate class this sweep cannot run in-process, and it is out of
24-
//! reach of the class-set axis, so it is recorded here rather than guarded.
20+
//! that refuses to build an array rather than a gap a host can have. It is not a free skip -- two of
21+
//! them recurse. `[C` is reached through the message (`JavaLangString::from_rust_string`) and
22+
//! `[Ljava/lang/String;` through `Throwable`'s `stackTrace` field, which `fillInStackTrace` fills
23+
//! with `instantiate_array` while the exception reporting the *first* gap is still being built.
24+
//!
25+
//! Hiding `[Ljava/lang/String;` at this cap aborts with `stack overflow`, and the round that first
26+
//! saw that wrote down the wrong reason -- "its recursion does not come back through the loader, so
27+
//! the cap cannot end it". Measured since (`rustjava-error-path-string-array-hiding-overflows-stack-p1`):
28+
//! it does come back, every turn, and the cap ends it exactly like `[C`. Two knobs move the
29+
//! threshold independently, which is what tells bounded-but-deep apart from unbounded: at the
30+
//! default 2 MiB test-thread stack it survives a cap of 18 (19 questions) and overflows at 19, and
31+
//! at this cap of 20 it overflows on 2 MiB but survives on 4 MiB with `asked = 21` -- the same
32+
//! `cap + 1` shape `[C` shows. The difference between the two is cost per turn, not shape: one turn
33+
//! of the `[Ljava/lang/String;` cycle is ~57 stack frames because it runs the whole exception
34+
//! construction (`exception` -> `new_class` -> four nested `invoke_special` for
35+
//! `NoClassDefFoundError` -> `LinkageError` -> `Error` -> `Throwable.<init>` -> `fillInStackTrace`),
36+
//! where `[C` turns inside `from_rust_string`.
37+
//!
38+
//! So arrays stay skipped for the reason at the top of this paragraph -- no class set can lack one --
39+
//! and not because anything here is unmeasurable.
2540
2641
use std::{
2742
future::Future,

0 commit comments

Comments
 (0)