refactor: add RNS/LXMF binding interfaces and Chaquopy implementations#669
refactor: add RNS/LXMF binding interfaces and Chaquopy implementations#669torlando-tech wants to merge 6 commits intomainfrom
Conversation
Strangler Fig Phase 1-3: Create the binding interface layer that mirrors
reticulum-kt's API shape, enabling a mechanical swap from Chaquopy to
native Kotlin later.
Phase 1 - Binding interfaces (15 files in :reticulum bindings/):
RNS layer: RnsReticulum, RnsIdentity, RnsIdentityProvider,
RnsDestination, RnsDestinationProvider, RnsLink, RnsLinkProvider,
RnsTransport, RnsAnnounceHandler, RnsInterfaceInfo
LXMF layer: LxmfRouter, LxmfMessage, LxmfMessageState,
LxmfMessageFactory, LxmfAppDataParser
Add DestinationType.LINK to match reticulum-kt
Phase 2 - Python thin layer:
Expand rns_api.py with binding methods for all RNS/LXMF operations
(Identity, Destination, Link, Transport, LXMF Router, Message)
Phase 3 - Chaquopy implementations (12 files in app/service/rns/):
ChaquopyRns* and ChaquopyLxmf* classes wrapping live Python objects
via PyObject with proper close() lifecycle management
Not wired into DI yet — zero runtime impact. Callback bridging
(TODOs in Destination, Link, Router, Message) deferred to Phase 4.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR implements Phase 1–3 of a strangler-fig migration, introducing 15 Kotlin binding interfaces in the Key observations:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant KCaller as Kotlin Caller (IO Dispatcher)
participant KBinding as Binding Interface<br/>(RnsIdentity / RnsDestination / RnsLink)
participant ChImpl as Chaquopy Impl<br/>(ChaquopyRns* / ChaquopyLxmf*)
participant PyApi as rns_api.py<br/>(RnsApi instance)
participant RNS as RNS / LXMF<br/>(Python library)
KCaller->>KBinding: sign(message)
KBinding->>ChImpl: sign(message)
ChImpl->>PyApi: callAttr("identity_sign", pyIdentity, message)
PyApi->>RNS: identity.sign(bytes(message))
RNS-->>PyApi: signature bytes
PyApi-->>ChImpl: PyObject (bytes)
ChImpl->>ChImpl: result.toJava(ByteArray) + result.close()
ChImpl-->>KCaller: ByteArray
KCaller->>KBinding: create(identity, direction, type, appName, aspects)
KBinding->>ChImpl: create(...)
ChImpl->>PyApi: callAttr("create_destination", pyIdentity, 0x11, 0, appName, pyAspects)
Note over ChImpl: pyAspects closed in finally
PyApi->>RNS: RNS.Destination(identity, direction, dest_type, app_name, *aspects)
RNS-->>PyApi: live Destination object
PyApi-->>ChImpl: PyObject (Destination)
ChImpl-->>KCaller: ChaquopyRnsDestination (wraps PyObject)
KCaller->>KBinding: getPropagationState()
KBinding->>ChImpl: getPropagationState()
ChImpl->>PyApi: callAttr("lxmf_get_propagation_state")
PyApi->>RNS: lxmf_router.propagation_transfer_state
RNS-->>PyApi: dict {state, state_name, progress, messages_received}
PyApi-->>ChImpl: PyObject (dict)
ChImpl->>ChImpl: parsePropagationDict — iterate once, close each K/V
ChImpl-->>KCaller: PropagationState(data class)
Prompt To Fix All With AIThis is a comment left during a code review.
Path: python/rns_api.py
Line: 161-162
Comment:
**Incorrect constant values documented in `create_destination` docstring**
The docstring documents the wrong numeric values for both `direction` and `dest_type`. Kotlin's `ChaquopyRnsDestinationProvider` passes the actual RNS constants:
- `Direction.IN → 0x11` (17), `Direction.OUT → 0x12` (18)
- `DestinationType.SINGLE → 0`, `GROUP → 1`, `PLAIN → 2`, `LINK → 3`
But the docstring claims `IN=1, OUT=2` and `SINGLE=1, GROUP=2, PLAIN=3, LINK=4`, both off from the real values. A future maintainer reading the docstring and writing a direct Python caller would pass the wrong integers.
```suggestion
direction: int (RNS.Destination.IN=0x11 (17) or RNS.Destination.OUT=0x12 (18))
dest_type: int (SINGLE=0, GROUP=1, PLAIN=2, LINK=3)
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: python/rns_api.py
Line: 257-258
Comment:
**`reason` parameter silently dropped in `link_teardown`**
`reason` is accepted as a parameter (and passed by the Kotlin caller via `link_teardown(pyLink, reason)`) but `link.teardown()` is called without it. `RNS.Link.teardown()` does accept an optional `reason` integer — silently dropping it means the remote peer always receives the default reason code regardless of what the Kotlin side requested.
```suggestion
def link_teardown(link, reason=0):
link.teardown(reason)
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/service/rns/ChaquopyLxmfMessage.kt
Line: 85-95
Comment:
**`asMap()` double-iteration leaks PyObjects from `associate` lambda**
The `associate { (k, v) -> k.toInt() to v.toJava(...) }` call materialises one set of `PyObject` key/value wrappers and extracts the primitives from them. The `finally` block then iterates `pyDict` a **second time**, materialising a fresh set of wrappers and closing those. Chaquopy creates a new wrapper instance on each access into the dict view, so the original set from `associate` is never explicitly closed — it leaks until the JVM GC eventually finalises them.
The fix is a single, manual loop so each wrapper is closed in the same iteration it was opened:
```kotlin
val pyDict = result.asMap() as Map<PyObject, PyObject>
buildMap {
for ((k, v) in pyDict) {
try {
put(k.toInt(), v.toJava(Any::class.java) as Any)
} finally {
k.close()
v.close()
}
}
}
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: d0cd739 |
The createLinkCallback parameter is intentionally unused now — it will be wired when we bridge Python callbacks to Kotlin in Phase 4. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- Fix RNS constant mapping: IN=0x11, OUT=0x12, SINGLE=0, GROUP=1, PLAIN=2, LINK=3 - Remove broken createLinkCallback that returned api as callable (would TypeError) - Replace null source/dest in LxmfMessageFactory with UnsupportedOperationException - Close intermediate PyObject keys/values in getInterfaces() and fields getter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| responseGenerator: (path: String, data: ByteArray?, requestId: ByteArray) -> ByteArray?, | ||
| ) { | ||
| // TODO: Bridge Python request handler callback to Kotlin lambda | ||
| api | ||
| .callAttr( | ||
| "destination_register_request_handler", | ||
| pyDestination, | ||
| path, | ||
| null as Any?, // Will wire callback in Phase 4 | ||
| )?.close() | ||
| } |
There was a problem hiding this comment.
Bug: Calling registerRequestHandler will cause a runtime crash because it passes a null callback to a Python function that does not permit None as a value.
Severity: HIGH
Suggested Fix
The Kotlin implementation of registerRequestHandler should be updated to not pass null for the response_generator. If this functionality is not yet implemented, it should throw an UnsupportedOperationException or be stubbed in a way that does not cause a crash.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
app/src/main/java/com/lxmf/messenger/service/rns/ChaquopyRnsDestination.kt#L79-L91
Potential issue: The `registerRequestHandler` function in `ChaquopyRnsDestination.kt`
passes a `null` value for the `response_generator` parameter to the underlying Python
API. This `null` is converted to `None` in Python. The Python
`Destination.register_request_handler` function explicitly checks if this parameter is
callable and raises a `ValueError` if it is not, as is the case with `None`. This will
cause a runtime crash whenever this public API method is invoked.
Did we get this right? 👍 / 👎 to inform future reviews.
…tubs - registerRequestHandler: skip Python call (RNS validates callable, raises ValueError for None) - setClosedCallback/setPacketCallback: only call Python to clear (null); skip for non-null (Phase 4) Same pattern as setLinkEstablishedCallback fix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| // Close all PyObject keys and values from the dict view | ||
| for ((k, v) in dict) { | ||
| k.close() | ||
| v.close() | ||
| } | ||
| } |
There was a problem hiding this comment.
Bug: The code closes individual keys and values from a Python dictionary, which can include singleton objects like True or False, potentially causing runtime crashes.
Severity: CRITICAL
Suggested Fix
Remove the loop that iterates through the dictionary entries and calls k.close() and v.close(). The individual keys and values should not be closed. Only the top-level PyObject container returned from the Python call should be closed, which is already handled correctly.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
app/src/main/java/com/lxmf/messenger/service/rns/ChaquopyRnsTransport.kt#L108-L113
Potential issue: The `getInterfaces()` method retrieves a list of dictionaries from
Python. A `finally` block then iterates through each dictionary's entries and calls
`close()` on every key and value `PyObject`. Some of these values are Python singletons,
such as the boolean `True` and `False`. According to Chaquopy documentation, closing a
`PyObject` that wraps a singleton will invalidate all other references to that singleton
across the entire Python runtime. This can lead to non-deterministic crashes when any
subsequent Python code attempts to access the invalidated object.
…ded LOG_DEBUG - ChaquopyLxmfRouter: close dict keys/values after extracting primitives - ChaquopyRnsDestinationProvider: close pyAspects after passing to create_destination - rns_api.py: parameterize loglevel (default LOG_WARNING instead of LOG_DEBUG) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| val pyDict = result.asMap() as Map<PyObject, PyObject> | ||
| try { | ||
| pyDict.entries.associate { (k, v) -> | ||
| k.toInt() to (v.toJava(Any::class.java) as Any) | ||
| } | ||
| } finally { | ||
| // Close all PyObject keys and values from the dict view | ||
| for ((k, v) in pyDict) { | ||
| k.close() | ||
| v.close() | ||
| } |
There was a problem hiding this comment.
asMap() double-iteration leaks PyObjects from associate lambda
The associate { (k, v) -> k.toInt() to v.toJava(...) } call materialises one set of PyObject key/value wrappers and extracts the primitives from them. The finally block then iterates pyDict a second time, materialising a fresh set of wrappers and closing those. Chaquopy creates a new wrapper instance on each access into the dict view, so the original set from associate is never explicitly closed — it leaks until the JVM GC eventually finalises them.
The fix is a single, manual loop so each wrapper is closed in the same iteration it was opened:
val pyDict = result.asMap() as Map<PyObject, PyObject>
buildMap {
for ((k, v) in pyDict) {
try {
put(k.toInt(), v.toJava(Any::class.java) as Any)
} finally {
k.close()
v.close()
}
}
}Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/service/rns/ChaquopyLxmfMessage.kt
Line: 85-95
Comment:
**`asMap()` double-iteration leaks PyObjects from `associate` lambda**
The `associate { (k, v) -> k.toInt() to v.toJava(...) }` call materialises one set of `PyObject` key/value wrappers and extracts the primitives from them. The `finally` block then iterates `pyDict` a **second time**, materialising a fresh set of wrappers and closing those. Chaquopy creates a new wrapper instance on each access into the dict view, so the original set from `associate` is never explicitly closed — it leaks until the JVM GC eventually finalises them.
The fix is a single, manual loop so each wrapper is closed in the same iteration it was opened:
```kotlin
val pyDict = result.asMap() as Map<PyObject, PyObject>
buildMap {
for ((k, v) in pyDict) {
try {
put(k.toInt(), v.toJava(Any::class.java) as Any)
} finally {
k.close()
v.close()
}
}
}
```
How can I resolve this? If you propose a fix, please make it concise.
Summary
:reticulummodule (bindings/rns/+bindings/lxmf/) mirroring reticulum-kt's live-object API shape. AddDestinationType.LINK.rns_api.pywith thin pass-through methods for all RNS/LXMF operations (Identity, Destination, Link, Transport, LXMF Router, Message).app/service/rns/wrapping live Python objects via PyObject with properclose()lifecycle.Zero runtime impact — not wired into DI yet. Callback bridging deferred to Phase 4.
Architecture
Interfaces are blocking (not suspend) to match reticulum-kt — callers dispatch to
Dispatchers.IO.Test plan
:reticulum:compileDebugKotlinpasses:app:compileNoSentryDebugKotlinpasses:reticulum:testDebugUnitTestpasses (no regressions)🤖 Generated with Claude Code