Close a handle by where it sits, not by how the field is spelled (#218) - #386
Conversation
A native handle buried in another value had three different owners depending only on its Rust spelling: handle sits in… data-class field callback argument the field/arg container's close() proxy, after run() an enum payload nobody receiver, by hand a nested data class nobody nobody So swapping a field's type from a handle to an enum carrying that handle moved the free onto the consumer, silently, with nothing in the generated Kotlin saying so. `destructible()` matched only a `Projection`; everything else fell through to `_ => None`. #218 reports the enum × data-class cell; the nested-data-class cell is the same arm and had no issue of its own. The fix is not to teach each use site to walk into wrappers. A sum that reaches an owned handle is now itself `AutoCloseable` — the sealed interface declares it and each variant class overrides `close()`, closing its own payload or doing nothing — so the `when` over the alternatives is emitted ONCE, in the sum, and every position keeps calling plain `close()`. A data-class field then cascades with `outcome.close()`, textually identical to a handle field, and the callback proxy needed only to be told the reassembled arg is owned: the close-unless-taken `finally` already existed for bare handle args. One rule, asked two ways over the same tree: `PlanFieldKind::destructible` recurses through `Sum`/`Nested` for an already-classified field, and `type_close_strategy` answers for a bare type where callers hold no plan. Both refuse a borrowed handle, which is not the holder's to release, so a fold over a borrowed run emits no per-iteration close. BREAKING: a sum-carried handle delivered to a callback is closed when `run` returns. A body meaning to outlive the call must `take()` it — the contract a plain `impl Fn(Handle)` arg has always had. No downstream impact today: zenoh-flat-jni's two sums carry ULong/ByteArray/Timestamp, no handles. It will apply to ReplyResult, which prompted the issue. covertest gains `Verdict` — the sum in data-class-field position, next to `Holder`'s plain handle field — and its JVM harness asserts the cascade, the post-`run` close, and `take()` keeping the payload alive. All 50 sections pass. Also unbreaks the emitted formatting the owned path would otherwise cause: a reassembly is one piece of raw text, so it binds to a `val` rather than being width-broken mid-`when` inside the nested call.
There was a problem hiding this comment.
Pull request overview
This PR fixes Kotlin/JNI handle ownership asymmetry by making generated Kotlin types (sealed interface sums and nested data_classes) AutoCloseable when they reach an owned native handle, so callers always just call .close() in every position (field, callback arg, etc.), independent of whether the handle is spelled directly or wrapped.
Changes:
- Extend close-strategy detection to recurse through
SumandNestedso containers cascade close via a one-linefield.close()/field?.close(). - Emit
AutoCloseable+ per-variantclose()overrides for handle-reaching sealed sums, and close reassembled callback args infinally(close-unless-taken), matching bare-handle behavior. - Update docs and regression tests, and add a Kotlin/JVM runtime harness case (
Verdict) to pin the new ownership behavior.
Reviewed changes
Copilot reviewed 10 out of 12 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| prebindgen-jni/src/jni/tests/sealed.rs | Updates/extends tests to assert cascade + sum closeability, adds nested-data-class and “sum owning nothing native” guards, and pins callback proxy close behavior. |
| prebindgen-jni/src/jni/struct_plan.rs | Makes PlanFieldKind::destructible() recurse through Sum/Nested and adds type_close_strategy() for plan-less close decisions (sealed emitter + callback builder). |
| prebindgen-jni/src/jni/kotlin_emit.rs | Emits AutoCloseable sealed sums when any alternative reaches an owned handle; generates per-variant close() bodies. |
| prebindgen-jni/src/jni/iface.rs | Tracks ownership for reassembled typed groups and closes owned reassemblies in finally; improves formatting stability by binding reassemblies to locals when needed. |
| examples/perftest-flat/src/ext.rs | Updates callback-doc comment to reflect close-after-run behavior; adds Verdict example exercising data-class-field sum cascade. |
| examples/covertest-kotlin/src/generated_bindings.rs | Regenerates bindings to include new Verdict bridge functions and native entrypoint. |
| examples/covertest-kotlin/kotlin/REPORT.md | Adds verdict_new and Verdict entries to the coverage report. |
| examples/covertest-kotlin/kotlin/io/prebindgen/covertest/Test.kt | Updates runtime assertions for callback-delivered sums (closed after run, take() escapes) and adds a Verdict cascade section. |
| examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest/model.kt | Regenerated Kotlin showing Lookup : AutoCloseable, per-variant close(), callback proxy finally { close() }, and Verdict : AutoCloseable. |
| examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest.kt | Regenerated callback proxy code closing optional sums via ?.close() and adds CovNative.verdictNew. |
| examples/covertest-kotlin/build.rs | Updates test package registration/comments to include Verdict and document new closeability behavior. |
| docs/sum-types.md | Rewrites §4.5 ownership rule to “close like a bare handle in the same position” and documents the three positions (return/callback/field). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ReviewThe direction is right and the framing is the good one: making the type closeable so the Verified locally on Points below, roughly in descending order of how much I'd want them addressed before merge. 1. The nested-data-class row has no covertest exerciseThe PR fixes three rows and the body says so, but That's the row with the most machinery behind it that nobody has run: the cascade emits a plain Per the workspace's own rule (every JniGen feature gets a covertest ext.rs + build.rs + Test.kt exercise in the same PR, lib tests insufficient), I think this needs a nested-handle struct beside 2. "The two forms answer alike by construction" is the claim I'd most like pinned by a test
A disagreement in one direction is a Kotlin compile error ( 3.
|
Seven points from the #386 review, in its own order. 1. The nested-data-class row had no JVM exercise. It is the row with the most machinery behind it and nothing running it: the cascade emits a plain `holder.close()` and ASSUMES the inner data class was rendered `AutoCloseable` by render.rs's own pass. An emission test cannot tell the two decisions apart — it never compiles the inner class. covertest gains `Dossier { note, holder: Holder }` beside `Verdict`, and a section that closes two levels down with one `close()`. 51 sections pass under `./gradlew run`. 2. "The two forms answer alike by construction" was the claim doing the most work, so it is a test now: `a_types_close_answer_matches_its_plans` asserts `type_close_strategy(ty).is_some() == classify_field(ty).destructible()` over every field of every declared type in a fixture covering all four ways to reach a handle plus two ways not to. Verified as a guard by reverting the `Nested` arm — it fails, naming the field. The doc comment no longer claims construction: it names the two places the walks genuinely differ (Sum-before-`output_entry` ordering, and totality) and points at the test. 3. `type_close_strategy` peeled the sequence layer to decide and then dropped it, so a `Vec<sum-that-reaches-a-handle>` answered `Base` and would have emitted `close()` on a `List`. The strategy now puts back exactly the layers peeled, `Iterable` included; the guard it borrowed from `classify_field` was one that does not run on this path. 4. The three iface call sites asked for a strategy and threw it away, then hand-rolled `{local}{dot}close()`. `RunArg`/`TypedGroup`/ `GroupDesc` carry `close: Option<FoldStrategy>` and render through `render_handle_close` — one predicate, one rendering. `nullable` does NOT fold into it: a reassembled sum's nullability comes from its SELECTOR, not its type, so `nullable_close` adds the `Optional` layer the type never names. 5. The fold change is documented as what it is: an element that reaches a handle is closed after each `run`, which bites `{ acc, e -> acc + e }` unless the body `take()`s. Not sum-only — a nested `data_class` element lands there too, so it goes in docs/sum-types.md §4.5's list and at the call site. No fold in the acceptance set reassembles an element that reaches a handle (`SummaryFolder` gets decomposed leaves), so there is nothing to pin yet; the generated output is unchanged. 6. Reassembly happened outside the `try`, so an invalid tag in the second `when` stranded the handle the first had already taken. Each owned local now opens its own try/finally with everything after it bound inside, and the innermost `finally` runs first. 7. Nits: the intra-doc link named a method that does not exist (`build_sealed_class` does); `whole_value_close`'s `Boxed` is stated as the fact it is — the receiver is a generated Kotlin reference, and a niche encoding belongs to handle projections, which never reach it; and the depth bound asserts as `build_struct_plan` does rather than answering `None` in the leak direction.
|
All seven addressed in cd44250. Point by point, with the two disagreements marked. 1. Nested-data-class covertest exercise — added
Your reason for wanting it is the right one and worth keeping visible, so it is in the type's own doc: the cascade is a one-line 2. Agreement pinned by a test
Verified as a guard, not decoration: reverting the The doc comment no longer claims construction. It names both differences you found — 3. Sequence layer folded into the answerTook the first option: 4. One predicate, one rendering
One disagreement: it does not subsume 5. Fold change documentedIts own bullet in No test, deliberately. Nothing in the acceptance set reassembles a fold element that reaches a handle — 6. Reassembly moved inside the
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
prebindgen-jni/src/jni/struct_plan.rs:612
- This only recognizes an outer
Optionfollowed by one sequence layer. Supported shapes such asVec<Option<DataClassWithHandle>>(for example as a sealed payload) leavecoreasOption<DataClassWithHandle>, sotype_kindreportsOtherand the generated sum is notAutoCloseable; similarly, deeper sequence nesting loses the close cascade. Walk the wrapper tree recursively and rebuild theFoldStrategyin the original order (e.g.Iterable(Optional(Base))) rather than peeling these two fixed positions.
let bare = ty.optional_inner().unwrap_or(ty);
let core = bare.sequence_elem().unwrap_or(bare);
prebindgen-jni/src/jni/iface.rs:1593
- This does not implement the documented per-iteration close.
fixed_folder_typed_groupsis used for fixed deconstructors, butkotlin_emit.rs:1191-1214deliberately suppresses their typed interface andasRawadapter; the hoisted folder singleton implements the raw twin directly (kotlin_emit.rs:1296-1336,1389-1421). Consequently thisclosevalue is never rendered or executed, while the PR description anddocs/sum-types.mdclaim fold elements are closed after eachrun. Either implement that contract on the actual user-facing fold path (without closing elements appended by fixed return-list builders), or remove the unsupported claim.
close: crate::jni::struct_plan::type_close_strategy(ext, registry, &spec.source, 0),
Closes #218.
The asymmetry
A native handle buried in another value had three different owners, decided only by its Rust spelling:
close()run()sealed_class)So swapping a field's type from a handle to an enum carrying that handle silently moved the free onto the consumer, with nothing in the generated Kotlin saying so.
PlanFieldKind::destructible()matched only aProjection; everything else fell through to_ => None.#218 offers "document it" or "cascade" and leans toward documenting. The decision here is cascade, on the grounds that the field's type is an implementation detail: replacing a struct with an enum must not change who frees the handle. That argument also settles the nested-data-class row, which is the same match arm and had no issue of its own.
The shape of the fix
Not "teach each use site to walk into wrappers". Instead the generated type becomes closeable, so every position keeps calling plain
close():The
whenover the alternatives is emitted once, in the sum. A data-class field then cascades with the same one-liner a handle field gets — nowhenat the container:…and the callback proxy needed only to be told the reassembled arg is owned; the close-unless-taken
finallyalready existed for bare handle args.One rule, asked two ways over the same tree:
PlanFieldKind::destructiblerecurses throughSum/Nestedfor an already-classified field;type_close_strategyanswers for a bare type where the caller holds no plan (the sealed emitter, the callback builder). Both refuse a borrowed handle — not the holder's to release — so a fold over a borrowed run emits no per-iteration close that would double-free.A sum-carried handle delivered to a callback is now closed when
runreturns. A body meaning to outlive the call musttake()it — exactly the contract a plainimpl Fn(Handle)arg has always had.docs/sum-types.md§4.5 is rewritten accordingly: the rule is no longer "the receiver closes, in both positions" but "closed exactly as a bare handle in the same position", across all three.Downstream impact today: none.
zenoh-flat-jni's only two sums (RecoveryMode,InstrumentationTimestamp) carryULong/ByteArray/Timestamp(adata_class!) — no handles. Confirmed byregen-check.sh --with-zenoh-flat-jni: its generated output is byte-identical. It will apply toReplyResult, which is what prompted the issue.Coverage
sealed.rs::a_data_class_field_may_be_a_sum_carrying_a_handle— its negative assertion (and doc-comment rationale) flipped to the cascade.a_data_class_field_may_be_a_nested_data_class_carrying_a_handle— the previously unfiled row. Verified as a real guard: reverting theNestedarm fails it.a_sum_owning_nothing_native_is_not_closeable— the predicate must not over-fire; a sum ofi64payloads stays a plainsealed interface.two_sum_callback_args_keep_their_own_selectors— now also pins that exactly the handle-carrying sum binds a local and closes, and the other does not.examples/covertest-kotlingainsVerdict(sum in data-class-field position, besideHolder's plain handle field). Its JVM harness asserts the cascade, the post-runclose, andtake()keeping the payload alive — 50 sections pass under./gradlew run, which is what proves close-unless-taken at runtime rather than just in the emission.Also
The owned path would otherwise have wrecked the emitted formatting — a reassembly is one piece of raw text, and
wlinewidth-broke it mid-wheninto something valid but unreadable. It now binds to aval, and lambda params stay one-per-line as in the common path, so the golden diff reads as the behaviour change alone.Verified
cargo test --all;cargo clippy --all-targets --all-features -- -D warningsandcargo fmt --check(CI's exact config) on 1.85.0 and stable;examples/regen-check.shclean;examples/regen-check.sh --with-zenoh-flat-jnibyte-identical;covertest-kotlin$ ./gradlew runPASS.