diff --git a/REPORT.md b/REPORT.md index 5462d274..56de54de 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,23 @@ # REPORT +## [2026-09-17] `StringConcatFactory.makeConcat` 도 링크한다 — 단 «이유는 제안이 적은 것이 아니다» (rustjava-adopt-link-stringconcatfactory-p0) +- 무엇을: 레시피 없는 진입점 `makeConcat` 을 링크한다. ★**실행기 무접촉** — 콜사이트 인자 수로 **레시피를 합성**한다. +- 왜: 채택 제안 `2026-09-16-link-stringconcatfactory#p0`. +- 사용자 영향: ★`makeConcat` 에 묶인 콜사이트가 **거부 대신 실행**된다. +- ★★**제안의 전제는 «틀렸다»**(실측 javac 26.0.1 · 같은 소스 `a + b`): `--release` **9·11·17·21·26 전부 + `makeConcatWithConstants`** 를 낸다 — ★**상수 텍스트가 «없어도»** 그렇다(레시피가 자리표시자 둘일 뿐). + `makeConcat` 은 **비기본 내부 플래그 `-XDstringConcat=indy`** 에서만 나온다. + ⇒ ★**「javac 이 상수 텍스트 없이 연결할 때 쓴다」·「javac 산출물이 더 많이 돈다」는 둘 다 거짓**이다(기본 산출물은 **이미 전부 링크된다**). +- ★**그래도 한 이유를 «바꿔서» 적는다**: ⑴**만들 수 있는 형상**이고(그 플래그로 직접 만들었다 — 이 리니지가 `ldc` 지원을 + 정당화한 「ASM 이 실제로 낸다」와 **같은 기준**) ⑵**문서화된 공개 진입점**이며 ⑶★**실행기에 새 경로가 필요 없다** + (`makeConcat(n)` ≡ 레시피 `\u{1}`×n 인 `makeConcatWithConstants`). +- ★**설계**: 상수 튜플 **둘**뿐이고 **레지스트리로 만들지 않았다**(제안 tradeoff 준수) · + ★**이름과 서술자를 «쌍»으로** 맞춰 ★**#55 의 name 축 근접 실패 픽스처가 «살아 있다»**(그 파일은 이름만 `makeConcat`) · + 레시피 합성은 **콜사이트마다**(한 부트스트랩을 서술자가 다른 콜사이트들이 공유할 수 있다). +- ★★**개악 4종 중 «둘»이 처음엔 살아남았다** — M3(쌍 검사를 이름만으로) · M4(정적 인자 가드 제거). + ★**직전 회차가 세운 「죽었다 ≠ 그 가지가 전부 덮였다」가 내 새 코드에 그대로 적용됐다** ⇒ 축마다 근접 실패를 더해 + **4종 전건 KILLED** 로 만들었다(`MakeConcatWrongDescriptor` · `MakeConcatWithArgument`). +- ★**픽스처가 «출력»한다**(`ab`) — ★레시피를 틀린 길이로 합성해도 **링크되고 실행된다**. 값을 버리면 그 오류가 안 보인다(M2 가 그 증거). +- 검증: `cargo test --all` **573 → 574 / 0 failed** · 픽스처 재생성 **멱등**(기존 4개 바이트 동일) · DoD 7명령 rc=0. ## [2026-09-17] 신원 4축을 «각각» 관측 가능하게 했다 — 감사의 「고칠 것이 없다」를 정정한다 (rustjava-adopt-cp-tag-passthrough-detectable-p0-fix) - 무엇을: `string_concat.rs` 의 부트스트랩 신원 **4축**(kind·class·name·descriptor) 중 ★**3축이 «관측되지 않고» 있었다** — 근접 실패 픽스처가 **class 축 하나**뿐이었기 때문이다. 나머지 3축의 픽스처를 만들었다. ★**제품 코드 무접촉.** diff --git a/STATE.md b/STATE.md index dbe798e2..551f882b 100644 --- a/STATE.md +++ b/STATE.md @@ -4,6 +4,43 @@ (없음 — 2026-09-16 실측: 착수 시 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 회차 PR 착지 시점 기준**이다 — 회신 시점에는 그 PR 자신이 열려 있다) ## 완료 +- [rustjava-adopt-link-stringconcatfactory-p0] ★★**`StringConcatFactory.makeConcat` 도 링크한다 — 단 «이유는 제안이 적은 것이 아니다».** + 채택 제안 `2026-09-16-link-stringconcatfactory#p0`(worklog json `adoptedProposals` 기록). + ★**제품 동작 변경**: `makeConcat` 콜사이트가 **거부 대신 실행**된다. ★**실행기(`concat_with_constants`)는 한 줄도 안 바뀌었다.** + ★★**ⓒ 제안의 전제가 «거짓»이다**(실측 javac 26.0.1 · 같은 소스 `a + b`): + `--release` **9 · 11 · 17 · 21 · 26 전부 `makeConcatWithConstants`** — ★**상수 텍스트가 «없는» 경우에도** 그렇다 + (레시피가 «자리표시자 둘 · 리터럴 0»일 뿐이다). `makeConcat` 은 **비기본 내부 플래그 `-XDstringConcat=indy`** 에서만 나오고, + `-XDstringConcat=inline` 은 `StringBuilder` 를 낸다. + ⇒ ★**「javac 이 상수 텍스트 없이 연결할 때 쓴다」도, 「javac 산출물이 더 많이 돈다」도 거짓**이다 — + ★**기본 javac 산출물은 이미 «전부» 링크되고 있었다.** + ★★**그래도 한 이유를 «바꿔서» 적는다**(제안의 근거를 그대로 베끼지 않았다): + ⑴★**만들 수 있는 형상**이다 — 그 플래그로 **직접 만들었다**. 이 리니지가 `ldc` 태그 지원을 정당화한 기준 + (「ASM 이 실제로 낸다」)과 ★**같은 기준**이다 ⑵**문서화된 공개 진입점**이라 바이트코드 생성기가 택할 수 있다 + ⑶★★**실행기에 «새 경로»가 필요 없다** — `makeConcat(n개 인자)` ≡ 레시피가 `\u{1}` **n개**인 `makeConcatWithConstants` 다 + ⇒ 콜사이트 서술자의 인자 수로 **레시피를 합성**하면 끝. ★제안이 「a recipe-free path through the same executor」로 본 것보다 **더 싸다**. + ★**설계 — 제안의 「짧은 명시 목록을 유지하라」를 지켰다**: 상수 튜플 **둘**뿐 · **레지스트리 아님** · + ★**이름과 서술자를 «쌍»으로 매칭**한다 ⇒ ★**#55 가 넣은 name 축 근접 실패(`NotMakeConcatWithConstants`)가 «살아 있다»** + (그 파일은 이름만 `makeConcat` 이고 서술자는 `makeConcatWithConstants` 의 것이라 여전히 거부된다). + ★**레시피 합성은 «콜사이트마다»** 한다 — 한 부트스트랩 항목을 **서술자가 다른 여러 콜사이트가 공유**할 수 있어 + 해석 단계에서 고정할 수 없다(그래서 `LinkedFactory::{WithConstants, NoRecipe}` 로 «인식»만 한다). + ★★**개악 대조 4종 — 처음엔 «둘»이 살아남았고, 그것이 이 회차의 교훈이다**: + **M1** 튜플 제거 → KILLED · **M2** 레시피 길이 `repeat(1)` → KILLED · ★**M3** 쌍 검사를 «이름만»으로 → **SURVIVED** · + ★**M4** 「정적 인자 없음」 가드 제거 → **SURVIVED**. + ⇒ ★**직전 `…-p0-fix` 가 세운 「죽었다 ≠ 그 가지가 «전부» 덮였다」가 «내 새 코드»에 그대로 적용됐다** — + 축마다 근접 실패를 더해(`MakeConcatWrongDescriptor` = 서술자만 다르고 정적 인자 0 · `MakeConcatWithArgument` = 이름·서술자 맞고 정적 인자 1) + ★**4종 전건 KILLED** 로 만들었다. + ★**픽스처가 «출력»한다**(`ab`) — ★**레시피를 틀린 길이로 합성해도 링크되고 실행된다.** 값을 버리면 그 오류가 «안 보인다» + (M2 가 그 증거다) ⇒ 「링크됐다」가 아니라 **「무엇이 연결됐나」**를 단언한다. + ★**잃는 것**: 링크 수용 범위가 넓어졌다 — 「콜사이트 «하나»를 링크한다」가 이제 **둘**이다(제안이 경고한 그대로 · 명시 목록으로 억제). + ★`cargo test --all` **573 → 574 / 0 failed / 1 ignored** · 픽스처 재생성 **멱등** · DoD **7줄 전건 rc=0**. + ★★**게이트③ 착지 — PR #59 · `--merge`**(등재 repo · `merge_strategy: merge` 선언분). 게이트② **approve** · + 핀 `3b04712e` **불이동**(착수 실측 2026-09-17T01:40:32Z) · ★**`MERGEABLE/CLEAN` · base 뒤처짐 «0»** ⇒ 충돌 해소·base 당김 **둘 다 불요**. + ★핀에서 `ci-presence` **rc=0 CI_GREEN** · 자식 PR **0건** · 배포 **0**(이 저장소에 배포 워크플로 없음) · 주기 자동 커밋 **0건**. + ★**동봉은 이 기록 한 줄뿐** — 원장(worklog 쌍·`STATE`·`REPORT`)은 구현 회차가 이미 실었다. + ★★**착지 순서 고지 — 이 회차가 «새 `.class` 3개»를 들여온다**(`MakeConcat`·`MakeConcatWithArgument`·`MakeConcatWrongDescriptor`). + 열린 PR **#57**(ci-pending)이 「`.class` 미등재는 핀이 **실패**시킨다」(`test-data/class-file-versions.txt`)를 세우므로, + ★**이 PR 이 먼저 착지하면 #57 의 표에 이 셋이 «없어»** 그 회차가 red 가 된다(해소 = `record-class-file-versions.py` 재생성). + ★**텍스트 충돌 0 이라 `mergeable` 로는 보이지 않는다** — 같은 고지가 #60 회신에도 있다(그쪽은 다른 3개). - [rustjava-adopt-cp-tag-passthrough-detectable-p0-fix] ★★**신원 4축을 «각각» 관측 가능하게 했다 — 감사의 「고칠 것이 없다」를 정정한다.** 게이트② **request-changes** 승계(PR #55 · 핀 `ab13a3c7`). ★**제품 코드 무접촉** — 없던 것은 **픽스처**다. ★★**무엇이 틀렸나**: 직전 감사의 **M7**(「신원 4축 검사 제거」)은 네 비교를 ★**한꺼번에** 지운다 ⇒ 그 red 가 증명하는 것은 diff --git a/docs/worklog/2026-09-17-link-makeconcat.json b/docs/worklog/2026-09-17-link-makeconcat.json new file mode 100644 index 00000000..80ea2afd --- /dev/null +++ b/docs/worklog/2026-09-17-link-makeconcat.json @@ -0,0 +1,33 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-17", + "taskId": "rustjava-adopt-link-stringconcatfactory-p0", + "summary": "Linked StringConcatFactory.makeConcat, the recipe-free entry point, by synthesising the recipe from the call site's arity — the executor needed nothing new. The proposal's stated reason was wrong: javac never emits makeConcat, at any release from 9 to 26.", + "changes": [ + "jvm-bytecode/src/string_concat.rs: a second name/descriptor pair, matched as a pair so one entry point's name with the other's descriptor does not link; resolve_bootstrap_methods returns LinkedFactory::{WithConstants, NoRecipe}, because a recipe-free call site's recipe depends on the call site rather than the bootstrap", + "test-data/src/indy/make_indy_fixtures.py: make_concat_call_site builds a linkable makeConcat class that prints its result, and takes parameters for the two near misses of the new pair", + "test-data/indy/MakeConcat.class, MakeConcatWrongDescriptor.class, MakeConcatWithArgument.class: new fixtures", + "tests/test_class_format.rs: test_the_recipe_free_factory_concatenates_every_argument, plus the two new near misses in the identity-axis test" + ], + "verification": [ + "javac 26.0.1 measured on the same source at --release 9, 11, 17, 21 and 26: all five emit makeConcatWithConstants, including for a + b with no literal text, where the recipe is two placeholders and nothing else; only -XDstringConcat=indy emits makeConcat, and -XDstringConcat=inline emits StringBuilder", + "end to end: the fixture prints 'ab', so the synthesised recipe consumed both arguments in order", + "mutation M1, removing the makeConcat pair: red", + "mutation M2, synthesising a recipe of the wrong length: red — which is why the fixture prints rather than discarding the value", + "mutation M3, loosening the pair check to the name alone: SURVIVED at first, killed after adding MakeConcatWrongDescriptor", + "mutation M4, dropping the no-static-arguments guard: SURVIVED at first, killed after adding MakeConcatWithArgument", + "the near-miss fixtures added by PR #55 still refuse, because name and descriptor are compared as a pair", + "cargo test --all --no-fail-fast: 574 passed / 0 failed / 1 ignored, up from 573", + "fixture regeneration is idempotent: the four pre-existing indy fixtures come out byte-identical", + "DoD 7 commands all rc=0" + ], + "issues": [ + "The linker now accepts two bootstraps rather than one — exactly the widening the proposal warned about. It is kept as a written-out pair list rather than a registry, and the doc comment says why.", + "The benefit is not the one the proposal claimed: default javac output was already fully linked. This helps producers that choose the recipe-free entry point, including javac under -XDstringConcat=indy.", + "M3 and M4 surviving the first pass is the same shape the sibling audit round documented: a coarse mutation proves one axis is observed, not all of them. Each axis of the new pair now has its own near miss." + ], + "adoptedProposals": [ + "2026-09-16-link-stringconcatfactory#p0" + ], + "proposals": [] +} diff --git a/docs/worklog/2026-09-17-link-makeconcat.md b/docs/worklog/2026-09-17-link-makeconcat.md new file mode 100644 index 00000000..37a1c09c --- /dev/null +++ b/docs/worklog/2026-09-17-link-makeconcat.md @@ -0,0 +1,74 @@ +# 2026-09-17 — `StringConcatFactory.makeConcat` 도 링크한다 — 단 «이유는 제안이 적은 것이 아니다» + +티켓 `rustjava-adopt-link-stringconcatfactory-p0` — 채택 제안 `2026-09-16-link-stringconcatfactory#p0`. + +★**제품 동작이 바뀐다**: `makeConcat` 부트스트랩에 묶인 `invokedynamic` 콜사이트가 이제 **실행된다** +(종전 `UnsupportedOperationException: invokedynamic`). + +## ⓒ ★제안의 전제는 «틀렸다» — 그리고 그 사실이 이 회차의 절반이다 + +제안 `plainSummary`: 「the sibling factory with no recipe — **used when javac concatenates without constant text**」 +제안 `userBenefit`: 「**More javac-emitted** string concatenation runs instead of being refused」 + +★**실측(javac 26.0.1 · 같은 소스 `a + b`)**: + +| 대상 | 내는 부트스트랩 | +|---|---| +| `--release 9` · `11` · `17` · `21` · `26` | ★**전부 `makeConcatWithConstants`** | +| `-XDstringConcat=indy`(비기본 내부 플래그) | `makeConcat` | +| `-XDstringConcat=inline` | `StringBuilder` | + +★**상수 텍스트가 «없는» `a + b` 도 `makeConcatWithConstants` 다** — 레시피가 자리표시자 둘(``)일 뿐 리터럴이 0인 것이다. +⇒ ★**「javac 이 상수 텍스트 없이 연결할 때 쓴다」는 거짓**이고, ★**「javac 산출물이 더 많이 돈다」도 거짓**이다 +(기본 javac 산출물은 **이미 전부 링크된다**). + +## 그래도 «왜» 했나 — 근거를 바꿔서 적는다 + +⑴★**만들 수 있는 형상이다** — `-XDstringConcat=indy` 로 **실제로 만들었다**. + 이 리니지가 `ldc` 태그 지원을 정당화한 기준(「ASM 이 실제로 낸다」)과 **같은 기준**이다. +⑵★**`makeConcat` 은 `StringConcatFactory` 의 «문서화된 공개 진입점»** 이고, 바이트코드 생성기는 그것을 택할 수 있다. +⑶★★**실행기에 «새 경로가 필요 없다»** — `makeConcat(n개 인자)` ≡ 레시피가 `\u{1}` **n개**인 `makeConcatWithConstants` 다. + ⇒ 콜사이트 서술자의 인자 수로 **레시피를 합성**하면 끝이고, `concat_with_constants` 는 **한 줄도 안 바뀐다**. + ★제안이 「a recipe-free path through the same executor」라고 본 것보다 **더 싸다**(경로가 아예 안 생긴다). + +## 설계 — 제안의 「짧은 명시 목록을 유지하라」를 지켰다 + +- 상수 튜플 **둘**(이름+서술자 쌍)뿐이고 **레지스트리로 만들지 않았다**(제안 tradeoff 준수). +- ★**이름과 서술자를 «쌍»으로 맞춘다** — `(name, descriptor)` 를 함께 매칭하므로 + ★**한쪽 진입점의 이름에 다른 쪽의 서술자를 붙인 것은 링크되지 않는다.** + ⇒ 이 결정이 ★**#55 가 넣은 name 축 근접 실패 픽스처(`NotMakeConcatWithConstants`)를 «살렸다»** — 그 파일은 + 이름이 `makeConcat` 이지만 서술자가 `makeConcatWithConstants` 의 것이라 **여전히 거부된다**. +- ★**레시피 합성은 «콜사이트마다»** 한다 — 한 부트스트랩 항목을 서술자가 다른 여러 콜사이트가 공유할 수 있어 + 부트스트랩 해석 단계에서는 **고정할 수 없다**. 그래서 해석은 `LinkedFactory::{WithConstants, NoRecipe}` 로 «인식»만 하고, + 합성은 하강 루프에서 한다. + +## 픽스처 — ★**결과를 «출력»하게 만들었다**(이유가 있다) + +`test-data/indy/MakeConcat.class`(생성기 · 합성): `ldc "a"; ldc "b"; invokedynamic concat(String,String)String; println`. +★**「링크됐다」가 아니라 「무엇이 연결됐나」를 단언**한다 — ★**레시피를 «틀린 길이»로 합성해도 링크는 되고 실행도 된다.** +값을 버리면 그 오류가 **안 보인다**(실측: 아래 M2). +★**javac 이 안 내는 형상이라 손으로 조립했다** — 비기본 내부 플래그에 픽스처 재생성을 의존시키는 것이 더 나쁘다. + +## ★★개악 대조 — 4종 중 «둘»이 처음엔 살아남았고, 그래서 픽스처를 더 만들었다 + +| 개악 | 처음 | 픽스처 보강 후 | +|---|---|---| +| **M1** `makeConcat` 튜플 제거(= 제안 이전) | **KILLED** | KILLED | +| **M2** 레시피 길이를 `repeat(1)` 로 | **KILLED**(출력 대조가 잡는다) | KILLED | +| **M3** 쌍 검사를 느슨하게(`(name, _)`) | ★**SURVIVED** | ★**KILLED** | +| **M4** 「정적 인자 없음」 가드 제거 | ★**SURVIVED** | ★**KILLED** | + +★**M3·M4 가 살아남은 이유는 «축마다 픽스처가 없어서»다** — 직전 `…-p0-fix` 회차가 세운 +「**죽었다 ≠ 그 가지가 «전부» 덮였다**」가 ★**내 새 코드에 그대로 적용됐다.** +⇒ 근접 실패 둘을 더했다: `MakeConcatWrongDescriptor`(이름은 맞고 **서술자만** 다르며 **정적 인자 0** ⇒ 서술자 비교만이 거부할 수 있다) · +`MakeConcatWithArgument`(이름·서술자 맞고 **정적 인자 1** ⇒ 인자 수 가드만이 거부할 수 있다). +★**둘 다 «그 축 하나»만 다르다** — 그래야 그 축이 관측된다. + +## 잃는 것 / 안 하면 무엇이 나쁜가 + +⒜**잃는 것**: ⑴★**링크 수용 범위가 넓어진다** — 제안이 경고한 그대로이고, 「이 런타임은 콜사이트 «하나»를 링크한다」가 + 더 이상 참이 아니다(이제 **둘**이다). ⇒ 상수 튜플을 **명시 목록**으로 유지하고 doc 에 그 이유를 적었다. + ⑵픽스처 **3개** · 테스트 1개 · `cargo test --all` **573 → 574**. + ⑶`resolve_bootstrap_methods` 의 반환형이 튜플 → 작은 enum 으로 바뀌었다(호출부 1곳). +⒝**안 하면**: `makeConcat` 에 묶인 콜사이트는 계속 **「이 런타임이 아직 못 한다」**로 거부된다 — + ★**실행기는 그것을 «이미 할 수 있는데»** 그렇다(레시피 합성 한 줄이면 되는 일이었다). diff --git a/jvm-bytecode/src/string_concat.rs b/jvm-bytecode/src/string_concat.rs index f05c644f..c07bde1c 100644 --- a/jvm-bytecode/src/string_concat.rs +++ b/jvm-bytecode/src/string_concat.rs @@ -1,8 +1,8 @@ -//! Resolving the one `invokedynamic` bootstrap this runtime links: -//! `java.lang.invoke.StringConcatFactory.makeConcatWithConstants`. +//! Resolving the `invokedynamic` bootstraps this runtime links — both of +//! `java.lang.invoke.StringConcatFactory`'s entry points, and nothing else. //! //! javac 9+ lowers `+` on strings to an `invokedynamic` call site bound to that factory, so an -//! ordinary one-liner needs it. Everything else — `makeConcat`, `LambdaMetafactory`, +//! ordinary one-liner needs it. Everything else — `LambdaMetafactory`, //! `ConstantBootstraps` — stays unsupported, and that is deliberate: the verifier rejects any //! `Opcode::Invokedynamic` that is still an `Invokedynamic` by the time it runs, so *not* //! recognising a call site here is what keeps the refusal honest. @@ -17,15 +17,32 @@ use alloc::{collections::BTreeMap, string::String, sync::Arc, vec::Vec}; use classfile::{AttributeInfo, ClassInfo, ConstantPoolReference, MethodHandleKind, Opcode, StringConcatCallSite}; +use jvm::JavaType; -/// The bootstrap method this runtime links, spelled out in full. +/// The bootstrap methods this runtime links, spelled out in full. /// /// All four axes are compared. Matching on fewer — say, the method name alone — would link call /// sites bound to some other class's `makeConcatWithConstants`, which is exactly the "linked /// everything" change this is meant not to be. +/// +/// Two entries, and it stays a written-out list rather than growing into a registry: every +/// addition makes "this runtime links string concatenation" less precise, so the list has to be +/// short enough to read. const FACTORY_CLASS: &str = "java/lang/invoke/StringConcatFactory"; const FACTORY_NAME: &str = "makeConcatWithConstants"; const FACTORY_DESCRIPTOR: &str = "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;"; +/// The recipe-free sibling: the call site descriptor alone says what to concatenate, so it takes +/// no static arguments and its own descriptor is shorter by exactly those two parameters. +/// +/// Measured, because the reason to support it is not the one that is usually given: javac does +/// *not* emit this. Targets 9, 11, 17, 21 and 26 all emit `makeConcatWithConstants`, even for +/// `a + b` with no literal text — the recipe is then just `\u0001\u0001`. Only the non-default +/// `-XDstringConcat=indy` produces `makeConcat`. It is linked because it is a documented entry +/// point of the same factory that any bytecode generator may target, and because the executor +/// needs nothing new for it (below). +const FACTORY_NAME_NO_RECIPE: &str = "makeConcat"; +const FACTORY_DESCRIPTOR_NO_RECIPE: &str = + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;"; /// Rewrite every recognised `makeConcatWithConstants` call site in `class` into a resolved opcode. /// @@ -51,22 +68,46 @@ pub(crate) fn lower(class: &mut ClassInfo) { else { continue; }; - let Some((recipe, constants)) = resolved.get(bootstrap_method_attr_index) else { + let Some(linked) = resolved.get(bootstrap_method_attr_index) else { continue; }; + // `makeConcat` is `makeConcatWithConstants` whose recipe is "every argument, in + // order, nothing else" — so it is expressed as that recipe rather than as a second + // path through the executor. The arity comes from this call site, which is why the + // synthesis happens here and not where the bootstrap was recognised. + let (recipe, constants) = match linked { + LinkedFactory::WithConstants { recipe, constants } => (recipe.clone(), constants.clone()), + LinkedFactory::NoRecipe => { + let method_type = JavaType::parse(descriptor); + let (parameters, _) = method_type.as_method(); + (Arc::new("\u{1}".repeat(parameters.len())), Vec::new()) + } + }; + *opcode = Opcode::InvokedynamicStringConcat(StringConcatCallSite { - recipe: recipe.clone(), + recipe, descriptor: descriptor.clone(), - constants: constants.clone(), + constants, }); } } } } -/// Bootstrap index -> (recipe, constants), for the entries that are this factory and nothing else. -fn resolve_bootstrap_methods(class: &ClassInfo) -> BTreeMap, Vec>)> { +/// What a linked bootstrap tells us, which is not the same for the two entry points. +/// +/// `makeConcatWithConstants` carries its recipe as a static argument, so it is known here. +/// `makeConcat` has none: the recipe follows from the *call site's* descriptor, and one bootstrap +/// entry can be shared by call sites with different descriptors — so it cannot be resolved to a +/// recipe at this level, only recognised. +enum LinkedFactory { + WithConstants { recipe: Arc, constants: Vec> }, + NoRecipe, +} + +/// Bootstrap index -> what it links to, for the entries that are this factory and nothing else. +fn resolve_bootstrap_methods(class: &ClassInfo) -> BTreeMap { let Some(bootstrap_methods) = class.attributes.iter().find_map(|attribute| match attribute { AttributeInfo::BootstrapMethods(methods) => Some(methods), _ => None, @@ -78,22 +119,30 @@ fn resolve_bootstrap_methods(class: &ClassInfo) -> BTreeMap, V .iter() .enumerate() .filter_map(|(index, bootstrap)| { - if bootstrap.method.kind != MethodHandleKind::InvokeStatic - || bootstrap.method.member.class.as_str() != FACTORY_CLASS - || bootstrap.method.member.name.as_str() != FACTORY_NAME - || bootstrap.method.member.descriptor.as_str() != FACTORY_DESCRIPTOR - { + if bootstrap.method.kind != MethodHandleKind::InvokeStatic || bootstrap.method.member.class.as_str() != FACTORY_CLASS { return None; } - // The recipe is static argument 0; the rest fill the recipe's `\u{2}` slots. Both must - // be String constants — a recipe that is not a string is not a shape javac emits, and - // guessing at it would be linking something we have not read. - let (recipe, constants) = bootstrap.arguments.split_first()?; - let recipe = string_constant(class, *recipe)?; - let constants = constants.iter().map(|x| string_constant(class, *x)).collect::>>()?; + // Name and descriptor are still compared together, one pair per entry point: matching a + // name against the other entry point's descriptor must not link. + let linked = match (bootstrap.method.member.name.as_str(), bootstrap.method.member.descriptor.as_str()) { + (FACTORY_NAME, FACTORY_DESCRIPTOR) => { + // The recipe is static argument 0; the rest fill the recipe's `\u{2}` slots. Both + // must be String constants — a recipe that is not a string is not a shape javac + // emits, and guessing at it would be linking something we have not read. + let (recipe, constants) = bootstrap.arguments.split_first()?; + let recipe = string_constant(class, *recipe)?; + let constants = constants.iter().map(|x| string_constant(class, *x)).collect::>>()?; + + LinkedFactory::WithConstants { recipe, constants } + } + // Static arguments are not merely unused here, they are a contradiction: this entry + // point is defined as taking none, so anything else is not the shape it claims. + (FACTORY_NAME_NO_RECIPE, FACTORY_DESCRIPTOR_NO_RECIPE) if bootstrap.arguments.is_empty() => LinkedFactory::NoRecipe, + _ => return None, + }; - Some((index as u16, (recipe, constants))) + Some((index as u16, linked)) }) .collect() } diff --git a/test-data/indy/MakeConcat.class b/test-data/indy/MakeConcat.class new file mode 100644 index 00000000..77be8772 Binary files /dev/null and b/test-data/indy/MakeConcat.class differ diff --git a/test-data/indy/MakeConcatWithArgument.class b/test-data/indy/MakeConcatWithArgument.class new file mode 100644 index 00000000..432df81f Binary files /dev/null and b/test-data/indy/MakeConcatWithArgument.class differ diff --git a/test-data/indy/MakeConcatWrongDescriptor.class b/test-data/indy/MakeConcatWrongDescriptor.class new file mode 100644 index 00000000..fbc024f1 Binary files /dev/null and b/test-data/indy/MakeConcatWrongDescriptor.class differ diff --git a/test-data/src/indy/make_indy_fixtures.py b/test-data/src/indy/make_indy_fixtures.py index d8a414ef..482322a2 100644 --- a/test-data/src/indy/make_indy_fixtures.py +++ b/test-data/src/indy/make_indy_fixtures.py @@ -61,6 +61,9 @@ def name_and_type(self, name, descriptor): def methodref(self, class_index, nat_index): return self.add(u1(10) + u2(class_index) + u2(nat_index)) + def fieldref(self, class_index, nat_index): + return self.add(u1(9) + u2(class_index) + u2(nat_index)) + def bytes(self): return u2(len(self.entries) + 1) + b"".join(self.entries) @@ -102,6 +105,69 @@ def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descrip ) +MAKECONCAT_DESCRIPTOR = "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;" + + +def make_concat_call_site(name, left, right, bootstrap_descriptor=None, static_arguments=0): + """A call site bound to `StringConcatFactory.makeConcat` — the recipe-free sibling. + + It takes no static arguments: the call site descriptor alone says what to concatenate. The + class prints the result, so a test can assert *what* was concatenated rather than only that + the call site linked — a synthesised recipe of the wrong length would still link and still + run, and would go unnoticed if the result were discarded. + + Hand-assembled because javac does not emit this shape: targets 9 through 26 all emit + `makeConcatWithConstants`, and only the non-default `-XDstringConcat=indy` produces + `makeConcat`. Depending on an internal compiler flag to regenerate a fixture would be worse + than assembling it here. + + `bootstrap_descriptor` defaults to the real one. Passing another is the near miss for this + entry point's descriptor axis: with no static arguments, the argument-count guard cannot + refuse it, so only the descriptor comparison can — which is what makes that comparison + observable. Measured: without this fixture, loosening the check to match the name alone left + every test green. + + `static_arguments` is the near miss for the other half of the same rule: this entry point is + defined as taking none, so a bootstrap that names it *and* carries one is a contradiction. With + the descriptor correct, only the argument-count guard can refuse it.""" + cp = Pool() + this_class = cp.klass(name) + super_class = cp.klass("java/lang/Object") + main_name, main_desc, code_name = cp.utf8("main"), cp.utf8("([Ljava/lang/String;)V"), cp.utf8("Code") + + bootstrap = cp.add( + u1(15) # kind 6 = REF_invokeStatic, as the real factory is + + u1(6) + + u2(cp.methodref(cp.klass(FACTORY_CLASS), cp.name_and_type("makeConcat", bootstrap_descriptor or MAKECONCAT_DESCRIPTOR))) + ) + call_site = cp.add(u1(18) + u2(0) + u2(cp.name_and_type("concat", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"))) + out = cp.fieldref(cp.klass("java/lang/System"), cp.name_and_type("out", "Ljava/io/PrintStream;")) + println = cp.methodref(cp.klass("java/io/PrintStream"), cp.name_and_type("println", "(Ljava/lang/String;)V")) + left_index, right_index = cp.string(left), cp.string(right) + + body = ( + u1(0xB2) + u2(out) # getstatic System.out + + u1(0x12) + u1(left_index) # ldc left + + u1(0x12) + u1(right_index) # ldc right + + u1(0xBA) + u2(call_site) + u2(0) # invokedynamic concat(String, String)String + + u1(0xB6) + u2(println) # invokevirtual println(String)V + + b"\xb1" # return + ) + code_attr = u2(3) + u2(1) + u4(len(body)) + body + u2(0) + u2(0) + method = u2(0x0009) + u2(main_name) + u2(main_desc) + u2(1) + u2(code_name) + u4(len(code_attr)) + code_attr + + arguments = b"".join(u2(left_index) for _ in range(static_arguments)) + bootstrap_body = u2(1) + u2(bootstrap) + u2(static_arguments) + arguments # one bootstrap method + class_attributes = [u2(cp.utf8("BootstrapMethods")) + u4(len(bootstrap_body)) + bootstrap_body] + + return ( + b"\xca\xfe\xba\xbe" + u2(0) + u2(52) + cp.bytes() + + u2(0x0021) + u2(this_class) + u2(super_class) + + u2(0) + u2(0) + u2(1) + method + + u2(len(class_attributes)) + b"".join(class_attributes) + ) + + FIXTURES = { # Differs from the real factory in the owning class alone. Everything else — kind 6, the # name, the descriptor, a String first static argument — matches, so only the identity @@ -145,8 +211,21 @@ def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descrip ), } +# The linkable counterpart of the near misses above: this one *is* the factory, so it must run. +LINKED = { + "MakeConcat.class": ("MakeConcat", "a", "b"), + # Same shape, but the bootstrap claims the *other* entry point's descriptor. It takes no static + # arguments, so nothing but the descriptor comparison stands between it and being linked. + "MakeConcatWrongDescriptor.class": ("MakeConcatWrongDescriptor", "a", "b", FACTORY_DESCRIPTOR), + # Correct name and descriptor, but carrying a static argument this entry point does not take. + "MakeConcatWithArgument.class": ("MakeConcatWithArgument", "a", "b", None, 1), +} + if __name__ == "__main__": OUT.mkdir(parents=True, exist_ok=True) for filename, args in FIXTURES.items(): (OUT / filename).write_bytes(near_miss_call_site(*args)) print(f"wrote {OUT / filename}") + for filename, args in LINKED.items(): + (OUT / filename).write_bytes(make_concat_call_site(*args)) + print(f"wrote {OUT / filename}") diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 4551ac57..0e58e9b7 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -100,6 +100,28 @@ async fn test_only_the_string_concat_bootstrap_is_linked() { } } +// The factory's other entry point. `makeConcat` takes no static arguments — the call site +// descriptor alone says what to concatenate — so the recipe is synthesised from its arity rather +// than read from the bootstrap. +// +// The fixture prints its result, and this asserts *that* rather than only that the class ran: a +// recipe synthesised at the wrong length still links and still runs, and would concatenate the +// wrong number of arguments unnoticed if the value were discarded. +// +// javac does not emit this shape — targets 9 through 26 all emit `makeConcatWithConstants`, even +// for `a + b` with no literal text — so the fixture is hand-assembled +// (test-data/src/indy/make_indy_fixtures.py). +#[tokio::test] +async fn test_the_recipe_free_factory_concatenates_every_argument() { + let path = Path::new("test-data/indy/MakeConcat.class"); + + let output = run_class(path, &[Path::new("./test-data/indy/")], &[]) + .await + .expect("a makeConcat call site should link and run"); + + assert_eq!(output.trim_end(), "ab", "both arguments must be concatenated, in order"); +} + // The identity check above is four comparisons, and the test above can only observe one of them. // `NotStringConcatFactory` differs in the owning class, so deleting *that* comparison links it and // the test fails — but deleting any of the other three changes nothing any fixture can see. Measured @@ -116,6 +138,13 @@ async fn test_each_axis_of_the_factory_identity_is_observable() { ("NotMakeConcatWithConstants", "method name"), ("NotFactoryDescriptor", "descriptor"), ("NotInvokeStaticFactory", "reference kind"), + // The same axis for the recipe-free entry point. It carries no static arguments, so the + // argument-count guard cannot refuse it and the descriptor comparison is all that is left + // — which is exactly what makes that comparison observable. + ("MakeConcatWrongDescriptor", "descriptor, on the recipe-free entry point"), + // And the other half of that entry point's rule: it takes no static arguments, so one that + // carries a static argument is not the shape it claims to be. + ("MakeConcatWithArgument", "a static argument the recipe-free entry point does not take"), ] { let path = PathBuf::from(format!("test-data/indy/{name}.class"));