This commit is contained in:
EthanShoeDev
2025-09-12 22:43:53 -04:00
parent f9a2a1bfca
commit c0d70ad99c
4 changed files with 4 additions and 1 deletions

View File

@@ -0,0 +1,300 @@
# React Native SSH — iOS simulator limitations & paths forward
*Last updated: 2025-09-12*
## TL;DR
* Our current stack (`react-native-ssh-sftp``NMSSH (aanah0 fork)` → prebuilt OpenSSL/libssh2) **fails on the iOS Simulator** because the vendored native libraries are compiled for **iOS device** only (platform: `iphoneos`), not **iOS Simulator** (platform: `iphonesimulator`).
* The proper modern fix is to use **XCFrameworks** that include **both** device and simulator slices, and have NMSSH link against those instead of raw static `.a` libraries.
* We have two main paths:
1. Keep using the existing RN package, but swap NMSSHs underlying crypto/SSH libs to an **XCFramework** (e.g., from `DimaRU/Libssh2Prebuild`), via a tiny CocoaPods change.
2. Build our **own RN native module** (TurboModule/JSI) that links **libssh2** and **OpenSSL** directly (using or adapting the Libssh2Prebuild scripts), fixing iOS Simulator support and also addressing Android reliability issues (like double “disconnect” callbacks).
---
## Current stack & limitation
### Stack
* App → `react-native-ssh-sftp`
* iOS native → `NMSSH` (via the `aanah0/NMSSH` fork for a newer libssh2 baseline)
* `NMSSH` bundles **precompiled OpenSSL + libssh2** as static libraries.
### Why iOS Simulator builds fail
* Xcode treats **device** and **simulator** as **different platforms**. Since Apple Silicon, both may be `arm64`, but they are **not interchangeable**:
* Device slice: `ios-arm64` (platform: `iphoneos`)
* Simulator slices: `ios-arm64` and/or `ios-x86_64` (platform: `iphonesimulator`)
* NMSSHs vendored libs are device-only (`iphoneos`). When the Simulator build links those, the linker throws:
```
ld: building for 'iOS-simulator', but linking in object file .../libcrypto.a[arm64] built for 'iOS'
```
* **XCFrameworks** solve this: they are a bundle containing the correct slices for both platforms. Xcode picks the proper slice automatically.
> Temporary hack (not recommended): exclude `arm64` for the simulator (`EXCLUDED_ARCHS[sdk=iphonesimulator*]=arm64`) to force an x86\_64 sim under Rosetta. This “works” but loses native-sim performance and is brittle.
---
## Path 1 — Keep current RN package; replace NMSSHs vendored libs with an XCFramework
**Goal:** Do *not* change `react-native-ssh-sftp` or the NMSSH **API**. Only change how NMSSH links to OpenSSL/libssh2.
### What well use
* **`DimaRU/Libssh2Prebuild`**: ships a ready-made **XCFramework** (“CSSH”) that bundles **libssh2 + OpenSSL** with both **device** and **simulator** slices.
* CocoaPods can consume XCFrameworks via a **binary pod** (`vendored_frameworks`), but Libssh2Prebuild is published for **SwiftPM**. So we add a tiny **`CSSH-Binary` podspec** that points to its zipped XCFramework.
### Minimal changes
1. **Fork NMSSH** (from `aanah0/NMSSH`) and edit **`NMSSH.podspec`**
* **Remove** the vendored static libs (the `.a` files).
* **Add** a dependency on `CSSH-Binary` (our binary pod that vends `CSSH.xcframework`).
**Podspec diff (conceptual):**
```diff
@@
Pod::Spec.new do |s|
s.name = 'NMSSH'
# ... (other metadata unchanged)
- s.vendored_libraries = [
- 'NMSSH-iOS/Libraries/lib/libssh2.a',
- 'NMSSH-iOS/Libraries/lib/libssl.a',
- 'NMSSH-iOS/Libraries/lib/libcrypto.a'
- ]
+ s.dependency 'CSSH-Binary', '~> 1.11'
end
```
2. **Create a tiny binary pod for CSSH** (one-file repo): `CSSH-Binary.podspec`
```ruby
Pod::Spec.new do |s|
s.name = 'CSSH-Binary'
s.version = '1.11.0' # match the Libssh2/OpenSSL combo you choose
s.summary = 'libssh2 + OpenSSL as XCFramework (from DimaRU/Libssh2Prebuild)'
s.license = { :type => 'MIT' }
s.homepage = 'https://github.com/your-org/cssh-binary'
s.authors = { 'Your Name' => 'you@example.com' }
s.platform = :ios, '12.0'
s.source = {
:http => 'https://github.com/DimaRU/Libssh2Prebuild/releases/download/1.11.0-OpenSSL-1-1-1w/CSSH.xcframework.zip'
}
s.vendored_frameworks = 'CSSH.xcframework'
end
```
* You can host the zip yourself if you want deterministic supply (recommended for CI).
* Ensure license attributions (OpenSSL 3.x: Apache-2.0; libssh2: BSD-3-Clause) are included in our OSS notices.
3. **Wire via Expo** (`expo-build-properties`) in `app.config.ts`
```ts
[
'expo-build-properties',
{
ios: {
extraPods: [
// Binary pod that vends the XCFramework
{ name: 'CSSH-Binary', podspec: 'https://raw.githubusercontent.com/your-org/cssh-binary/main/CSSH-Binary.podspec' },
// Our NMSSH fork that depends on CSSH-Binary
{ name: 'NMSSH', git: 'https://github.com/your-org/NMSSH.git', branch: 'cssh-xcframework' },
],
},
android: {
packagingOptions: {
pickFirst: ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'],
},
},
},
]
```
4. **Rebuild**
```bash
npx expo prebuild --platform ios --clean
npx pod-install
npx expo run:ios # Simulator should now link correctly
```
**Pros**
* Small surface area; we keep the RN package API intact.
* Correct, future-proof device/simulator packaging via XCFrameworks.
* Easy to maintain once the podspecs are set.
**Cons/Risks**
* We maintain a **fork of NMSSH** (podspec only).
* Potential conflicts with tools like **Flipper** if they also touch OpenSSL (rare; we can disable Flipper if needed).
* We rely on Libssh2Prebuild versions/tags (or build our own artifacts using their scripts).
---
## Path 2 — Build our own RN module (TurboModule/JSI) with libssh2/OpenSSL
**Goal:** Replace `react-native-ssh-sftp` + NMSSH with a **modern RN Native Module** that links libssh2 directly and ships correct device/simulator binaries out of the box.
### Why consider this
* Full control over the native surface (events, error handling, cancellation, reconnects).
* Fix Android reliability issues weve observed (e.g., “disconnect” callback firing multiple times, causing dev crashes).
* Avoid legacy Objective-C wrapper constraints; use **TurboModules/JSI**.
### High-level plan
**iOS**
* Use (or adapt) **Libssh2Prebuild build scripts** to produce our own **`libssh2.xcframework`** and **`OpenSSL.xcframework`** (or one combined framework).
* Publish them as a **binary CocoaPod** (like `CSSH-Binary`) *or* vendor the XCFrameworks in our module pod.
* Write a thin Obj-C++/C++ wrapper exposing the SSH API needed by JS (connect/auth/exec/sftp/streaming).
* Export via **TurboModule** (codegen) or **JSI** (C++), and provide a typed TS API in the RN package.
**Android**
* Build **libssh2** against **OpenSSL** (or MbedTLS) with the **NDK**.
* Package `.so` libs per ABI (`arm64-v8a`, `x86_64`, etc.).
* Implement JNI layer with strict **once-only** callbacks (disconnect must be idempotent), and make the public JS API promise/observable based.
* Add tests that assert no multiple “disconnect” emissions.
**Common**
* Define a **stable JS API**:
* Connection lifecycle (`connect`, `ready`, `disconnect`),
* Auth variants (password, key, agent if supported),
* Exec/PTY streaming,
* SFTP (get/put/mkdir/list/stat),
* Structured errors (error codes, host key checks, timeouts),
* Events (`onData`, `onExit`, `onError`, `onDisconnect`).
* **Testing/CI**: Simulated hosts (Docker) for integration tests; E2E with detox where feasible; CI matrix includes iOS Simulator and Android emulators.
**Pros**
* Clean slate, better DX, fewer legacy constraints.
* We can ensure **simulator** support is first-class.
* Fixes Android issues definitively.
**Cons**
* More initial engineering time.
* We own native maintenance across platforms.
---
## Known Android issue in current package
Weve observed that **disconnect** can trigger the callback **more than once**, which crashes the app in dev (double resolve/reject or repeated event emission). If we keep the current package for now:
* Add a JS-side **guard** to make `disconnect()` idempotent (ignore subsequent calls/notifications after the first).
* If we fork, fix the native layer so it emits exactly once and cleans up listeners predictably.
(If we pursue **Path 2**, well design the native layer so all callbacks/promises are strictly single-shot and lifecycle-scoped.)
---
## Recommendation
* **Short term:** Implement **Path 1**. Its the smallest change to unblock Simulator builds:
* Add a `CSSH-Binary` binary pod (vend `CSSH.xcframework`).
* Fork NMSSH podspec to depend on it.
* Wire both via `expo-build-properties` `ios.extraPods`.
* **Medium term:** Plan **Path 2** to remove legacy NMSSH constraints and resolve Android issues thoroughly using a Nitro/TurboModule with direct libssh2 bindings.
---
## Appendix
### A. `CSSH-Binary.podspec` (example)
```ruby
Pod::Spec.new do |s|
s.name = 'CSSH-Binary'
s.version = '1.11.0'
s.summary = 'libssh2 + OpenSSL as XCFramework (from DimaRU/Libssh2Prebuild)'
s.license = { :type => 'MIT' }
s.homepage = 'https://github.com/your-org/cssh-binary'
s.authors = { 'Your Name' => 'you@example.com' }
s.platform = :ios, '12.0'
s.source = {
:http => 'https://github.com/DimaRU/Libssh2Prebuild/releases/download/1.11.0-OpenSSL-1-1-1w/CSSH.xcframework.zip'
}
s.vendored_frameworks = 'CSSH.xcframework'
end
```
### B. `NMSSH.podspec` diff (replace vendored `.a` libs)
```diff
- s.vendored_libraries = [
- 'NMSSH-iOS/Libraries/lib/libssh2.a',
- 'NMSSH-iOS/Libraries/lib/libssl.a',
- 'NMSSH-iOS/Libraries/lib/libcrypto.a'
- ]
+ s.dependency 'CSSH-Binary', '~> 1.11'
```
### C. `app.config.ts` (Expo)
```ts
plugins: [
// ...
[
'expo-build-properties',
{
ios: {
extraPods: [
{ name: 'CSSH-Binary', podspec: 'https://raw.githubusercontent.com/your-org/cssh-binary/main/CSSH-Binary.podspec' },
{ name: 'NMSSH', git: 'https://github.com/your-org/NMSSH.git', branch: 'cssh-xcframework' },
],
},
android: {
packagingOptions: {
pickFirst: ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'],
},
},
},
],
]
```
### D. Build/Release notes
* **Rebuild steps**
```bash
npx expo prebuild --platform ios --clean
npx pod-install
npx expo run:ios
```
* **Licenses**: Include OpenSSL and libssh2 license texts in our OSS notice.
* **Flipper**: If linking conflicts appear, disable Flipper in iOS debug builds.
### E. Temporary simulator workaround (not recommended long-term)
In both the app target and Pods project:
```
EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64
```
This forces an x86\_64 Simulator under Rosetta, avoiding the immediate link error but losing native-sim performance.
---
**Decision log**
* ✅ Adopt Path 1 now (XCFramework via CSSH + NMSSH podspec tweak).
* 🕒 Plan Path 2 (custom RN module) to address Android bugs and own the full stack.
# Updates
- 1. Implemented option 1. Seems to be working in simulator

View File

@@ -0,0 +1,245 @@
# Android SSH Native Module: Crash Root Cause and Fix Plan
This document explains the dev-only crash observed when navigating back
(unmounting the Shell screen) and disconnecting an SSH session, and details the
native changes required in `@dylankenneally/react-native-ssh-sftp` to resolve it
properly.
## Summary
- Symptom: App crashes in development when leaving the Shell screen while an SSH
shell is active and `disconnect()` runs. Release builds do not crash.
- Root cause: The Android native module sometimes invokes the `startShell`
callback more than once and lets the shell read loop terminate via an
exception. In React Native dev mode, invoking a native callback multiple times
can crash the bridge.
- JS-side mitigations we applied in the app:
- Removed the artificial 3s timeout before disconnect; disconnect immediately
on unmount.
- Replaced the Shell event handler with a no-op on unmount to avoid setState
after unmount.
- Proper native fix (recommended upstream):
1. Ensure `startShell` callback is invoked exactly once (on initial start
only).
2. Make `closeShell` shut down the loop cleanly and null out resources.
3. Guard `sendEvent` against emitting when the bridge is inactive.
4. Optionally remove clients from the `clientPool` after disconnect.
## Where the issue happens
File:
`node_modules/@dylankenneally/react-native-ssh-sftp/android/src/main/java/me/keeex/rnssh/RNSshClientModule.java`
Relevant methods:
- `startShell(String key, String ptyType, Callback callback)`
- Starts the shell on a background thread, calls `callback.invoke()` when
connected, then loops reading lines and emitting `Shell` events.
- On stream termination, the code currently exits via exceptions
(`IOException`, etc.) and in the catch blocks also calls
`callback.invoke(error.getMessage())` a second time.
- `closeShell(String key)`
- Closes output stream, input reader, and the channel, but it does not set the
fields to `null`. The `startShell` loop uses
`while (client._bufferedReader != null && ...)`, which relies on `null` to
terminate cleanly.
- `sendEvent(ReactContext reactContext, String eventName, @Nullable WritableMap params)`
- Emits DeviceEventManager events without checking whether the React bridge is
alive.
- `disconnect(String key)`
- Calls `closeShell(key)` and `disconnectSFTP(key)` and then disconnects the
session but does not remove the entry from `clientPool`.
## Why dev-only
React Natives dev bridge is stricter: invoking a native callback multiple times
can trigger an error that surfaces as an immediate crash during development. In
release builds, this usually doesnt bring the app down the same way, which
matches the behavior observed.
Specifically:
1. `startShell` calls the callback on success (good) but may call the same
callback again inside a catch block when the shell loop ends with an
exception (bad). That is a “callback invoked twice” scenario.
2. Because `closeShell` does not null
`_bufferedReader`/`_dataOutputStream`/`_channel`, the read loop
(`while (client._bufferedReader != null && (line = ...))`) doesnt exit by
the `null` check; it exits by throwing `IOException` when the stream is
closed, sending control flow to the catch block that re-invokes the callback.
3. Emitting events after the bridge is torn down (e.g., during fast
unmount-navigate) can also cause noise in dev logs and compound timing
problems.
## Native changes (proposed)
1. Send the `startShell` callback once only
- Current pattern:
- On success: `callback.invoke();`
- On exceptions: `callback.invoke(error.getMessage());` (second invocation)
- Proposed change:
- Keep the success callback invocation.
- Remove all subsequent `callback.invoke(...)` inside the catch blocks of
`startShell`.
- Log the exception with `Log.e(...)` but do not call the callback again. The
callback is for “start shell” completion, not for “shell ended later”.
2. Make `closeShell` cleanly terminate the read loop
- After closing resources, set fields to `null`:
- `client._channel = null;`
- `client._dataOutputStream = null;`
- `client._bufferedReader = null;`
- With this, the read loop conditional `client._bufferedReader != null` becomes
false and exits without throwing.
3. Harden `sendEvent`
- Before emitting:
- `if (reactContext == null || !reactContext.hasActiveCatalystInstance()) { return; }`
- Wrap `emit` in a try/catch to prevent rare bridge-state races:
```java
try {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(eventName, params);
} catch (Throwable t) {
Log.w(LOGTAG, "Failed to emit event " + eventName, t);
}
```
4. Optionally remove clients from the pool after disconnect
- In `disconnect(String key)`, after `client._session.disconnect();`, call
`clientPool.remove(key);` so future lookups cannot access stale references.
## Example diffs (illustrative)
Note: Line numbers may vary; these are conceptual patches to apply in the
indicated methods.
### In `startShell`
```java
// On success (keep):
callback.invoke();
// In catch blocks (change):
} catch (JSchException error) {
Log.e(LOGTAG, "Error starting shell: " + error.getMessage());
// DO NOT invoke callback again here
} catch (IOException error) {
Log.e(LOGTAG, "Error starting shell: " + error.getMessage());
// DO NOT invoke callback again here
} catch (Exception error) {
Log.e(LOGTAG, "Error starting shell: " + error.getMessage());
// DO NOT invoke callback again here
}
```
### In `closeShell`
```java
if (client._channel != null) {
client._channel.disconnect();
}
if (client._dataOutputStream != null) {
client._dataOutputStream.flush();
client._dataOutputStream.close();
}
if (client._bufferedReader != null) {
client._bufferedReader.close();
}
// Ensure the shell loop terminates cleanly:
client._channel = null;
client._dataOutputStream = null;
client._bufferedReader = null;
```
### In `sendEvent`
```java
private void sendEvent(ReactContext reactContext, String eventName, @Nullable WritableMap params) {
if (reactContext == null || !reactContext.hasActiveCatalystInstance()) {
return;
}
try {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(eventName, params);
} catch (Throwable t) {
Log.w(LOGTAG, "Failed to emit event " + eventName, t);
}
}
```
### In `disconnect`
```java
SSHClient client = clientPool.get(key);
if (client != null) {
client._session.disconnect();
clientPool.remove(key); // optional but recommended
}
```
## Why these changes fix the issue
- Single-callback guarantee: React Native mandates native callbacks are invoked
once per request. Making `startShell` callback only for the initial start
satisfies this and prevents dev crashes.
- Clean loop termination: Nulling the stream references ensures the shell loop
exits by condition rather than by exception, avoiding the catch path that
previously re-invoked the callback.
- Safe event emission: Avoids emitting into a destroyed bridge during fast
navigation/unmount cycles, reducing flakiness in dev.
- Resource hygiene: Removing client entries and nulling references prevents
accidental reuse of stale state and helps GC.
## What we changed in app code (JS)
- In `apps/mobile/src/app/shell.tsx`:
- Removed `setTimeout` and disconnect immediately in the unmount cleanup.
- Replaced the `Shell` event handler with a no-op in the effect cleanup to
avoid setState on an unmounted component while native drains.
- We deliberately did not call `closeShell()` ourselves, since the librarys
`disconnect()` already handles it.
These app-level changes reduce the timing window and stop React state updates
after unmount. They help, but the true fix is in the native module as outlined
above.
## Testing
1. Build a Dev Client (or run a development build) so the native module is
loaded.
2. Connect, navigate to the shell, then press back to unmount while the shell is
active.
3. Capture logs:
- `adb logcat --pid=$(adb shell pidof -s dev.fressh.app) RNSSHClient:V ReactNative:V ReactNativeJS:V AndroidRuntime:E *:S`
4. With the native changes applied, verify:
- No “callback invoked twice” or RN bridge callback violations.
- No crash in dev.
- Events stop cleanly and disconnect completes.
## Upstreaming
These fixes are small, safe, and self-contained. Consider opening a PR to
`@dylankenneally/react-native-ssh-sftp` with:
- Callback discipline in `startShell`.
- Clean resource nulling in `closeShell`.
- Safe `sendEvent`.
- Optional `clientPool.remove(key)` on disconnect.
Until its merged, you can maintain a `patch-package` to keep the project stable
in development.