appium-ios-tuntap 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.4.3](https://github.com/appium/appium-ios-tuntap/compare/v0.4.2...v0.4.3) (2026-06-10)
2
+
3
+ ### Bug Fixes
4
+
5
+ * Improve raw transfer performance ([#48](https://github.com/appium/appium-ios-tuntap/issues/48)) ([f5cf38f](https://github.com/appium/appium-ios-tuntap/commit/f5cf38f1156fec4dfbd8a71840d616a17f728106))
6
+
1
7
  ## [0.4.2](https://github.com/appium/appium-ios-tuntap/compare/v0.4.1...v0.4.2) (2026-06-01)
2
8
 
3
9
  ### Miscellaneous Chores
package/lib/TunTap.d.ts CHANGED
@@ -73,7 +73,15 @@ export declare class TunTap {
73
73
  * @throws {TypeError} if `callback` is not a function
74
74
  * @throws {RangeError} if `bufferSize` is out of range
75
75
  */
76
- startPolling(callback: PacketCallback, bufferSize?: number): void;
76
+ startPolling(callback: PacketCallback, bufferSize?: number, queueDepth?: number): void;
77
+ /**
78
+ * Pause libuv-driven polling without tearing down the receive callback.
79
+ */
80
+ pausePolling(): void;
81
+ /**
82
+ * Resume polling after {@link TunTap.pausePolling}.
83
+ */
84
+ resumePolling(): void;
77
85
  /**
78
86
  * Configure IPv6 address and MTU on this interface using the platform backend (must run as root on Darwin/Linux).
79
87
  *
package/lib/TunTap.js CHANGED
@@ -184,7 +184,7 @@ export class TunTap {
184
184
  * @throws {TypeError} if `callback` is not a function
185
185
  * @throws {RangeError} if `bufferSize` is out of range
186
186
  */
187
- startPolling(callback, bufferSize = MAX_BUFFER_SIZE) {
187
+ startPolling(callback, bufferSize = MAX_BUFFER_SIZE, queueDepth = 8) {
188
188
  this.assertReady();
189
189
  if (typeof callback !== 'function') {
190
190
  throw new TypeError('Callback must be a function');
@@ -192,7 +192,24 @@ export class TunTap {
192
192
  if (bufferSize <= 0 || bufferSize > MAX_BUFFER_SIZE) {
193
193
  throw new RangeError(`Buffer size must be between 1 and ${MAX_BUFFER_SIZE} bytes`);
194
194
  }
195
- this.device.startPolling(callback, bufferSize);
195
+ if (queueDepth <= 0 || queueDepth > 64) {
196
+ throw new RangeError('Queue depth must be between 1 and 64');
197
+ }
198
+ this.device.startPolling(callback, bufferSize, queueDepth);
199
+ }
200
+ /**
201
+ * Pause libuv-driven polling without tearing down the receive callback.
202
+ */
203
+ pausePolling() {
204
+ this.assertReady();
205
+ this.device.pausePolling();
206
+ }
207
+ /**
208
+ * Resume polling after {@link TunTap.pausePolling}.
209
+ */
210
+ resumePolling() {
211
+ this.assertReady();
212
+ this.device.resumePolling();
196
213
  }
197
214
  /**
198
215
  * Configure IPv6 address and MTU on this interface using the platform backend (must run as root on Darwin/Linux).
@@ -1,5 +1,13 @@
1
1
  /** CDTunnel lockdown handshake MTU (IPv6 minimum). */
2
2
  export declare const CD_TUNNEL_MTU = 1280;
3
+ /** Upper bound for native TUN poll read size (matches N-API addon). */
4
+ export declare const MAX_TUN_POLL_BUFFER = 65535;
5
+ /** Preferred poll read size when L4 packet tap is off. */
6
+ export declare const LARGE_TUN_POLL_BUFFER: number;
7
+ /** Default ThreadSafeFunction queue depth for TUN polling. */
8
+ export declare const DEFAULT_TUN_POLL_QUEUE_DEPTH = 8;
9
+ /** Deeper TSFN queue when packet tap is off (bulk transfer). */
10
+ export declare const FAST_TUN_POLL_QUEUE_DEPTH = 16;
3
11
  export declare const CD_TUNNEL_MAGIC = "CDTunnel";
4
12
  export declare const CD_TUNNEL_MAGIC_SIZE = 8;
5
13
  export declare const CD_TUNNEL_HEADER_SIZE: number;
@@ -1,5 +1,13 @@
1
1
  /** CDTunnel lockdown handshake MTU (IPv6 minimum). */
2
2
  export const CD_TUNNEL_MTU = 1280;
3
+ /** Upper bound for native TUN poll read size (matches N-API addon). */
4
+ export const MAX_TUN_POLL_BUFFER = 65_535;
5
+ /** Preferred poll read size when L4 packet tap is off. */
6
+ export const LARGE_TUN_POLL_BUFFER = 64 * 1024;
7
+ /** Default ThreadSafeFunction queue depth for TUN polling. */
8
+ export const DEFAULT_TUN_POLL_QUEUE_DEPTH = 8;
9
+ /** Deeper TSFN queue when packet tap is off (bulk transfer). */
10
+ export const FAST_TUN_POLL_QUEUE_DEPTH = 16;
3
11
  export const CD_TUNNEL_MAGIC = 'CDTunnel';
4
12
  export const CD_TUNNEL_MAGIC_SIZE = 8;
5
13
  export const CD_TUNNEL_HEADER_SIZE = CD_TUNNEL_MAGIC_SIZE + 2;
@@ -14,6 +14,7 @@ export declare class TunnelManager extends EventEmitter<TunnelManagerEvents> {
14
14
  private readonly packetConsumers;
15
15
  private deviceConn;
16
16
  private cleanupPromise;
17
+ private tunReadPausedForBackpressure;
17
18
  /**
18
19
  * Register a listener for parsed tunnel packets (in addition to the `data` event).
19
20
  *
@@ -61,6 +62,7 @@ export declare class TunnelManager extends EventEmitter<TunnelManagerEvents> {
61
62
  private tapL4Packet;
62
63
  private dispatchPacketData;
63
64
  private startTunReadLoop;
65
+ private writeTunPacketToDevice;
64
66
  private _performStop;
65
67
  }
66
68
  /**
@@ -2,7 +2,7 @@ import { log } from '../logger.js';
2
2
  import { TunTap } from '../TunTap.js';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import { Buffer } from 'node:buffer';
5
- import { CD_TUNNEL_HANDSHAKE_TIMEOUT_MS, CD_TUNNEL_HEADER_SIZE, CD_TUNNEL_MAGIC, CD_TUNNEL_MAGIC_SIZE, CD_TUNNEL_MTU, IPV6_HEADER_SIZE, IPV6_VERSION, IPPROTO_TCP, IPPROTO_UDP, } from './constants.js';
5
+ import { CD_TUNNEL_HANDSHAKE_TIMEOUT_MS, CD_TUNNEL_HEADER_SIZE, CD_TUNNEL_MAGIC, CD_TUNNEL_MAGIC_SIZE, CD_TUNNEL_MTU, DEFAULT_TUN_POLL_QUEUE_DEPTH, FAST_TUN_POLL_QUEUE_DEPTH, IPV6_HEADER_SIZE, LARGE_TUN_POLL_BUFFER, MAX_TUN_POLL_BUFFER, IPV6_VERSION, IPPROTO_TCP, IPPROTO_UDP, } from './constants.js';
6
6
  import { appendBuffer } from './buffer-utils.js';
7
7
  /**
8
8
  * Bridges a CoreDevice tunnel `Socket` and a {@link TunTap} interface: IPv6 framing, TUN I/O, and packet fan-out.
@@ -16,6 +16,7 @@ export class TunnelManager extends EventEmitter {
16
16
  packetConsumers = new Set();
17
17
  deviceConn = null;
18
18
  cleanupPromise = null;
19
+ tunReadPausedForBackpressure = false;
19
20
  /**
20
21
  * Register a listener for parsed tunnel packets (in addition to the `data` event).
21
22
  *
@@ -200,15 +201,20 @@ export class TunnelManager extends EventEmitter {
200
201
  offset += frame.length;
201
202
  }
202
203
  if (offset > 0) {
203
- this.buffer = this.buffer.subarray(offset);
204
+ if (offset >= this.buffer.length) {
205
+ this.buffer = Buffer.alloc(0);
206
+ }
207
+ else {
208
+ this.buffer = this.buffer.subarray(offset);
209
+ }
204
210
  }
205
211
  }
206
212
  writeDeviceFrameToTun(tun, packet, nextHeader) {
207
- const bytesWritten = tun.write(packet);
213
+ tun.write(packet);
208
214
  if (!this.hasPacketTap()) {
209
- log.debug(`Device → TUN: ${bytesWritten} bytes`);
210
215
  return;
211
216
  }
217
+ const bytesWritten = packet.length;
212
218
  const { src, dst } = ipv6Endpoints(packet);
213
219
  log.debug(`Device → TUN: ${bytesWritten} bytes, IPv6 src=${src}, dst=${dst}`);
214
220
  this.tapL4Packet(packet, nextHeader, src, dst);
@@ -256,18 +262,42 @@ export class TunnelManager extends EventEmitter {
256
262
  if (!this.tun) {
257
263
  return;
258
264
  }
265
+ const tapOn = this.hasPacketTap();
266
+ const pollBuffer = tapOn
267
+ ? this.mtu
268
+ : Math.min(MAX_TUN_POLL_BUFFER, Math.max(this.mtu, LARGE_TUN_POLL_BUFFER));
269
+ const queueDepth = tapOn ? DEFAULT_TUN_POLL_QUEUE_DEPTH : FAST_TUN_POLL_QUEUE_DEPTH;
259
270
  this.tun.startPolling((data) => {
260
271
  if (this.cancelled || !data.length || deviceConn.destroyed) {
261
272
  return;
262
273
  }
263
- if (this.hasPacketTap() && data.length >= IPV6_HEADER_SIZE) {
274
+ if (tapOn && data.length >= IPV6_HEADER_SIZE) {
264
275
  log.debug(`TUN → Device: ${data.length} bytes, IPv6 src=${formatIPv6Address(data.subarray(8, 24))}, dst=${formatIPv6Address(data.subarray(24, 40))}`);
265
276
  }
266
- else if (this.hasPacketTap()) {
277
+ else if (tapOn) {
267
278
  log.debug(`TUN → Device: ${data.length} bytes (too small for IPv6 header)`);
268
279
  }
269
- deviceConn.write(data);
270
- }, this.mtu);
280
+ this.writeTunPacketToDevice(deviceConn, data);
281
+ }, pollBuffer, queueDepth);
282
+ }
283
+ writeTunPacketToDevice(deviceConn, data) {
284
+ if (deviceConn.destroyed) {
285
+ return;
286
+ }
287
+ const canWriteMore = deviceConn.write(data);
288
+ if (canWriteMore || this.tunReadPausedForBackpressure || !this.tun) {
289
+ return;
290
+ }
291
+ this.tunReadPausedForBackpressure = true;
292
+ this.tun.pausePolling();
293
+ const onDrain = () => {
294
+ if (this.cancelled || deviceConn.destroyed) {
295
+ return;
296
+ }
297
+ this.tunReadPausedForBackpressure = false;
298
+ this.tun?.resumePolling();
299
+ };
300
+ deviceConn.once('drain', onDrain);
271
301
  }
272
302
  async _performStop() {
273
303
  const tunName = this.tun ? this.tun.name : 'unknown';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-tuntap",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Native TUN/TAP interface module for Node.js",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -47,6 +47,10 @@ public:
47
47
 
48
48
  void StopReceiveLoop() override { poll_loop_.Stop(); }
49
49
 
50
+ void PauseReceiveLoop() override { poll_loop_.Pause(); }
51
+
52
+ void ResumeReceiveLoop() override { poll_loop_.Resume(); }
53
+
50
54
  int GetNativeFd() const override { return fd_.get(); }
51
55
 
52
56
  protected:
@@ -59,6 +59,7 @@ void PosixUvPollLoop::Stop() {
59
59
  return;
60
60
  }
61
61
 
62
+ paused_ = false;
62
63
  uv_poll_stop(handle_);
63
64
  handle_->data = nullptr;
64
65
  uv_close(reinterpret_cast<uv_handle_t*>(handle_),
@@ -67,6 +68,22 @@ void PosixUvPollLoop::Stop() {
67
68
  state_.reset();
68
69
  }
69
70
 
71
+ void PosixUvPollLoop::Pause() {
72
+ if (!handle_ || paused_) {
73
+ return;
74
+ }
75
+ paused_ = true;
76
+ uv_poll_stop(handle_);
77
+ }
78
+
79
+ void PosixUvPollLoop::Resume() {
80
+ if (!handle_ || !paused_) {
81
+ return;
82
+ }
83
+ paused_ = false;
84
+ uv_poll_start(handle_, UV_READABLE, &PosixUvPollLoop::OnPoll);
85
+ }
86
+
70
87
  void PosixUvPollLoop::OnPoll(uv_poll_t* handle, int status, int events) {
71
88
  auto* state = static_cast<State*>(handle->data);
72
89
  if (!state) {
@@ -33,8 +33,11 @@ public:
33
33
  std::string& error);
34
34
 
35
35
  void Stop();
36
+ void Pause();
37
+ void Resume();
36
38
 
37
39
  private:
40
+ bool paused_ = false;
38
41
  struct State {
39
42
  size_t buffer_size = 0;
40
43
  ReadFn read_fn;
@@ -72,6 +72,10 @@ public:
72
72
  std::string& error) = 0;
73
73
  virtual void StopReceiveLoop() = 0;
74
74
 
75
+ // Temporarily stop delivering packets without tearing down the receive loop.
76
+ virtual void PauseReceiveLoop() {}
77
+ virtual void ResumeReceiveLoop() {}
78
+
75
79
  // Returns the underlying POSIX file descriptor when one exists. Backends
76
80
  // without a numeric fd (e.g. Wintun on Windows) return `-1`.
77
81
  virtual int GetNativeFd() const { return -1; }
@@ -238,9 +238,11 @@ public:
238
238
  // Either never started or already cleaned up. Reset the event in case
239
239
  // it was created without ever spawning a thread.
240
240
  quit_event_.reset();
241
+ receive_paused_.store(false);
241
242
  return;
242
243
  }
243
244
  worker_running_.store(false);
245
+ receive_paused_.store(false);
244
246
  if (quit_event_.is_valid()) {
245
247
  ::SetEvent(quit_event_.get());
246
248
  }
@@ -248,6 +250,10 @@ public:
248
250
  quit_event_.reset();
249
251
  }
250
252
 
253
+ void PauseReceiveLoop() override { receive_paused_.store(true); }
254
+
255
+ void ResumeReceiveLoop() override { receive_paused_.store(false); }
256
+
251
257
  // WinTun exposes no POSIX file descriptor: its readable object is a Win32
252
258
  // event `HANDLE`, not a numeric fd. Always -1 — the N-API layer treats -1
253
259
  // as "no pollable fd" and drives delivery through `StartReceiveLoop`.
@@ -293,11 +299,18 @@ private:
293
299
  HANDLE wait_handles[2] = {read_event_, quit_event_.get()};
294
300
 
295
301
  while (worker_running_.load()) {
302
+ while (receive_paused_.load() && worker_running_.load()) {
303
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
304
+ }
305
+ if (!worker_running_.load()) {
306
+ return;
307
+ }
308
+
296
309
  // Drain everything available before going back to wait. WinTun's
297
310
  // read-wait event is auto-reset on signal, so we must consume all
298
311
  // queued packets before re-arming.
299
312
  bool drained = false;
300
- while (worker_running_.load()) {
313
+ while (worker_running_.load() && !receive_paused_.load()) {
301
314
  DWORD packet_size = 0;
302
315
  BYTE* packet = api.ReceivePacket(session_, &packet_size);
303
316
  if (packet) {
@@ -358,6 +371,7 @@ private:
358
371
  Handle quit_event_;
359
372
  std::thread worker_;
360
373
  std::atomic<bool> worker_running_{false};
374
+ std::atomic<bool> receive_paused_{false};
361
375
  std::string interface_name_;
362
376
  };
363
377
 
package/src/tuntap.cc CHANGED
@@ -28,6 +28,8 @@ private:
28
28
  Napi::Value GetName(const Napi::CallbackInfo& info);
29
29
  Napi::Value GetFd(const Napi::CallbackInfo& info);
30
30
  Napi::Value StartPolling(const Napi::CallbackInfo& info);
31
+ Napi::Value PausePolling(const Napi::CallbackInfo& info);
32
+ Napi::Value ResumePolling(const Napi::CallbackInfo& info);
31
33
 
32
34
  std::unique_ptr<TunPlatformBackend> backend_;
33
35
  std::string requested_name_;
@@ -56,6 +58,8 @@ Napi::Object TunDevice::Init(Napi::Env env, Napi::Object exports) {
56
58
  InstanceMethod("getName", &TunDevice::GetName),
57
59
  InstanceMethod("getFd", &TunDevice::GetFd),
58
60
  InstanceMethod("startPolling", &TunDevice::StartPolling),
61
+ InstanceMethod("pausePolling", &TunDevice::PausePolling),
62
+ InstanceMethod("resumePolling", &TunDevice::ResumePolling),
59
63
  });
60
64
 
61
65
  constructor = Napi::Persistent(func);
@@ -209,6 +213,15 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
209
213
  buffer_size = size;
210
214
  }
211
215
 
216
+ size_t queue_depth = 8;
217
+ if (info.Length() > 2 && info[2].IsNumber()) {
218
+ queue_depth = info[2].As<Napi::Number>().Uint32Value();
219
+ if (queue_depth == 0 || queue_depth > 64) {
220
+ Napi::RangeError::New(env, "Queue depth must be between 1 and 64").ThrowAsJavaScriptException();
221
+ return env.Null();
222
+ }
223
+ }
224
+
212
225
  // Queue depth > 1 lets the poll thread post the next packet while JS is still
213
226
  // handling the previous callback (still serialized on the main thread).
214
227
  tsfn_ = Napi::ThreadSafeFunction::New(
@@ -216,7 +229,7 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
216
229
  info[0].As<Napi::Function>(),
217
230
  "TunDeviceDataCallback",
218
231
  0,
219
- 8);
232
+ queue_depth);
220
233
 
221
234
  uv_loop_t* loop = nullptr;
222
235
  napi_status napi_st = napi_get_uv_event_loop(env, &loop);
@@ -265,6 +278,30 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
265
278
  return env.Undefined();
266
279
  }
267
280
 
281
+ Napi::Value TunDevice::PausePolling(const Napi::CallbackInfo& info) {
282
+ Napi::Env env = info.Env();
283
+ std::lock_guard<std::mutex> lock(device_mutex_);
284
+
285
+ if (!polling_ || !backend_ || !backend_->IsOpen()) {
286
+ return env.Undefined();
287
+ }
288
+
289
+ backend_->PauseReceiveLoop();
290
+ return env.Undefined();
291
+ }
292
+
293
+ Napi::Value TunDevice::ResumePolling(const Napi::CallbackInfo& info) {
294
+ Napi::Env env = info.Env();
295
+ std::lock_guard<std::mutex> lock(device_mutex_);
296
+
297
+ if (!polling_ || !backend_ || !backend_->IsOpen()) {
298
+ return env.Undefined();
299
+ }
300
+
301
+ backend_->ResumeReceiveLoop();
302
+ return env.Undefined();
303
+ }
304
+
268
305
  Napi::Object Init(Napi::Env env, Napi::Object exports) {
269
306
  return TunDevice::Init(env, exports);
270
307
  }