|
| 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. |
0 commit comments