@mentra/crust 3.2.1 → 3.3.0-beta.954

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.
Files changed (55) hide show
  1. package/README.md +27 -0
  2. package/android/build.gradle +24 -9
  3. package/android/src/main/AndroidManifest.xml +1 -0
  4. package/android/src/main/java/com/mentra/crust/CrustModule.kt +44 -15
  5. package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
  6. package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
  7. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +69 -54
  8. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
  9. package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
  10. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +4 -0
  11. package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
  12. package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
  13. package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
  14. package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +199 -0
  15. package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
  16. package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
  17. package/build/Crust.types.d.ts +25 -11
  18. package/build/Crust.types.d.ts.map +1 -1
  19. package/build/Crust.types.js.map +1 -1
  20. package/build/CrustModule.d.ts +9 -2
  21. package/build/CrustModule.d.ts.map +1 -1
  22. package/build/CrustModule.js.map +1 -1
  23. package/build/CrustModule.web.d.ts +3 -1
  24. package/build/CrustModule.web.d.ts.map +1 -1
  25. package/build/CrustModule.web.js +20 -8
  26. package/build/CrustModule.web.js.map +1 -1
  27. package/build/index.d.ts +1 -0
  28. package/build/index.d.ts.map +1 -1
  29. package/build/index.js +1 -0
  30. package/build/index.js.map +1 -1
  31. package/build/nativeHttpRequest.d.ts +13 -0
  32. package/build/nativeHttpRequest.d.ts.map +1 -0
  33. package/build/nativeHttpRequest.js +51 -0
  34. package/build/nativeHttpRequest.js.map +1 -0
  35. package/build/nativeHttpRequest.test.d.ts +2 -0
  36. package/build/nativeHttpRequest.test.d.ts.map +1 -0
  37. package/build/nativeHttpRequest.test.js +88 -0
  38. package/build/nativeHttpRequest.test.js.map +1 -0
  39. package/ios/Crust.podspec +1 -1
  40. package/ios/CrustModule.swift +18 -7
  41. package/ios/PermissionStatusReceipt.swift +105 -0
  42. package/ios/Resources/startup.js +154 -68
  43. package/ios/Source/JSCPolyfillBridge.swift +26 -12
  44. package/package.json +2 -2
  45. package/plugin/build/index.d.ts +2 -0
  46. package/plugin/build/index.js +2 -0
  47. package/plugin/build/withAndroid.js +39 -0
  48. package/src/Crust.types.ts +26 -12
  49. package/src/CrustModule.ts +9 -1
  50. package/src/CrustModule.web.ts +18 -7
  51. package/src/index.ts +1 -0
  52. package/src/nativeHttpRequest.test.ts +94 -0
  53. package/src/nativeHttpRequest.ts +74 -0
  54. package/android/src/internal/AndroidManifest.xml +0 -9
  55. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +0 -46
@@ -1,6 +1,7 @@
1
1
  package com.mentra.crust.jsc
2
2
 
3
3
  import android.content.Context
4
+ import android.os.SystemClock
4
5
  import android.util.Log
5
6
  import com.dokar.quickjs.QuickJs
6
7
  import com.dokar.quickjs.binding.function
@@ -11,6 +12,7 @@ import java.util.concurrent.Executors
11
12
  import java.util.concurrent.ScheduledExecutorService
12
13
  import java.util.concurrent.ScheduledFuture
13
14
  import java.util.concurrent.TimeUnit
15
+ import java.util.concurrent.atomic.AtomicLong
14
16
  import kotlinx.coroutines.Dispatchers
15
17
  import kotlinx.coroutines.runBlocking
16
18
 
@@ -133,6 +135,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
133
135
  val dispatcher: JSCDispatcher = JSCDispatcher(appContext)
134
136
 
135
137
  private val contexts = ConcurrentHashMap<String, ContextRecord>()
138
+ private val nextContextId = AtomicLong()
136
139
  private val polyfillBundle: String by lazy { loadPolyfillBundle() }
137
140
 
138
141
  // Schedule pool for timers; per-context the executor is the same as the
@@ -146,6 +149,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
146
149
  val packageName: String,
147
150
  val qjs: QuickJs,
148
151
  val executor: ExecutorService,
152
+ val contextId: Long,
149
153
  val pendingTimers: MutableMap<Int, ScheduledFuture<*>> = ConcurrentHashMap(),
150
154
  @Volatile var readyAcked: Boolean = false,
151
155
  @Volatile var readyNackTimer: ScheduledFuture<*>? = null,
@@ -273,6 +277,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
273
277
  packageName = packageName,
274
278
  qjs = qjs,
275
279
  executor = executor,
280
+ contextId = nextContextId.incrementAndGet(),
276
281
  )
277
282
  contexts[packageName] = record
278
283
 
@@ -288,10 +293,10 @@ class JSCRuntime private constructor(private val appContext: Context) {
288
293
  runBlocking {
289
294
  installGlobals(qjs, packageName)
290
295
  if (bundle.isNotEmpty()) {
291
- qjs.evaluate<Any?>(bundle, filename = "mentrajs:startup.js")
296
+ qjs.evaluateForEffect(bundle, filename = "mentrajs:startup.js")
292
297
  }
293
298
  if (miniappJs.isNotEmpty()) {
294
- qjs.evaluate<Any?>(miniappJs, filename = "mentrajs:miniapp.js")
299
+ qjs.evaluateForEffect(miniappJs, filename = "mentrajs:miniapp.js")
295
300
  }
296
301
  }
297
302
  Log.i(TAG, "spawned $packageName")
@@ -415,7 +420,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
415
420
  record.executor.submit {
416
421
  runBlocking {
417
422
  try {
418
- record.qjs.evaluate<Any?>(
423
+ record.qjs.evaluateForEffect(
419
424
  "globalThis.__deliverTimer && globalThis.__deliverTimer($token);",
420
425
  filename = "mentrajs:timer-$token.js",
421
426
  )
@@ -457,10 +462,23 @@ class JSCRuntime private constructor(private val appContext: Context) {
457
462
  * Push a `{kind: "event"|"response", …}` envelope into the named
458
463
  * context's globalThis.__deliver. Hops onto the per-context executor.
459
464
  */
460
- fun dispatchToJs(packageName: String, envelopeJson: String) {
461
- val record = contexts[packageName] ?: return
465
+ fun dispatchToJs(packageName: String, envelopeJson: String, deliveryId: Long? = null) {
466
+ val record = contexts[packageName]
467
+ // Only host-generated PINGs carry this ID. Ordinary media/event deliveries stay quiet.
468
+ val diagnostic = deliveryId?.let {
469
+ JSDeliveryDiagnostics(it, record?.contextId, SystemClock::elapsedRealtime) { metadata ->
470
+ reportDeliveryDiagnostic(packageName, metadata)
471
+ }
472
+ }
473
+ diagnostic?.entry()
474
+ if (record == null) {
475
+ diagnostic?.dropped("context-missing")
476
+ return
477
+ }
478
+ diagnostic?.queued()
462
479
  try {
463
480
  record.executor.submit {
481
+ diagnostic?.started()
464
482
  // Arm only once evaluate is about to run. Queue wait during a
465
483
  // long previous turn (WHIP start, etc.) must not count — that
466
484
  // was firing ready_nack while the context was merely busy.
@@ -489,14 +507,26 @@ class JSCRuntime private constructor(private val appContext: Context) {
489
507
  kill(packageName)
490
508
  }, WATCHDOG_KILL_MS, TimeUnit.MILLISECONDS)
491
509
  record.watchdogTimer = killTimer
510
+ var failure: Throwable? = null
492
511
  try {
493
512
  val source = "globalThis.__deliver(${jsStringLiteral(envelopeJson)});"
494
513
  runBlocking {
495
- record.qjs.evaluate<Any?>(source, filename = "mentrajs:deliver.js")
514
+ record.qjs.evaluateForEffect(source, filename = "mentrajs:deliver.js")
496
515
  }
497
516
  } catch (e: Throwable) {
517
+ failure = e
498
518
  Log.w(TAG, "dispatchToJs threw in $packageName: ${e.message}", e)
519
+ if (diagnostic == null) {
520
+ reportDeliveryDiagnostic(packageName, mapOf(
521
+ "event" to "native-delivery-failed",
522
+ "contextId" to record.contextId,
523
+ "phase" to "evaluate-failed",
524
+ "errorClass" to e.javaClass.simpleName,
525
+ "causeClass" to e.cause?.javaClass?.simpleName,
526
+ ))
527
+ }
499
528
  } finally {
529
+ diagnostic?.finished(failure)
500
530
  // Success or throw: the host observed the turn end.
501
531
  record.readyNackTimer?.cancel(false)
502
532
  record.readyNackTimer = null
@@ -505,8 +535,26 @@ class JSCRuntime private constructor(private val appContext: Context) {
505
535
  if (record.watchdogTimer === killTimer) record.watchdogTimer = null
506
536
  }
507
537
  }
508
- } catch (_: java.util.concurrent.RejectedExecutionException) {
509
- // Context killed mid-flight — drop. dispatchToJs is best-effort.
538
+ } catch (e: java.util.concurrent.RejectedExecutionException) {
539
+ diagnostic?.dropped("executor-rejected", e)
540
+ Log.w(TAG, "dispatchToJs rejected for $packageName", e)
541
+ }
542
+ }
543
+
544
+ private fun reportDeliveryDiagnostic(packageName: String, metadata: Map<String, Any?>) {
545
+ val line = org.json.JSONObject(metadata as Map<*, *>).toString()
546
+ Log.i(TAG, line)
547
+ // Use the existing miniapp log route so console interception includes this in reports.
548
+ try {
549
+ deliverOrDrop(OutboundMessage(packageName, mapOf(
550
+ "packageName" to packageName,
551
+ "iface" to "__log",
552
+ "method" to if (metadata["errorClass"] == null) "log" else "warn",
553
+ "argsJson" to org.json.JSONArray().put(org.json.JSONObject(metadata as Map<*, *>)).toString(),
554
+ )))
555
+ } catch (e: Throwable) {
556
+ // Observability cannot change delivery or hide its original failure.
557
+ Log.w(TAG, "delivery diagnostic forwarding failed: ${e.javaClass.simpleName}")
510
558
  }
511
559
  }
512
560
 
@@ -542,6 +590,15 @@ class JSCRuntime private constructor(private val appContext: Context) {
542
590
 
543
591
  // ------------------------------------------------------------------
544
592
 
593
+ /**
594
+ * Evaluate for side effects. A completion value is never converted to
595
+ * Kotlin: quickjs-kt 1.0.15 hangs converting a Set, Map or array that
596
+ * contains itself, and miniapp code chooses its bundle's last value.
597
+ */
598
+ private suspend fun QuickJs.evaluateForEffect(source: String, filename: String) {
599
+ evaluate<Any?>("$source\n;undefined", filename = filename)
600
+ }
601
+
545
602
  private fun parseArgsEnvelope(argsJson: String): Pair<List<Any?>, String?> {
546
603
  return try {
547
604
  if (argsJson.startsWith("{")) {
@@ -0,0 +1,44 @@
1
+ package com.mentra.crust.jsc
2
+
3
+ /** Metadata for one host PING. Never retains the envelope or exception message. */
4
+ internal class JSDeliveryDiagnostics(
5
+ private val deliveryId: Long,
6
+ private val contextId: Long?,
7
+ private val clockMs: () -> Long,
8
+ private val emit: (Map<String, Any?>) -> Unit,
9
+ ) {
10
+ private val enteredAtMs = clockMs()
11
+ private var queuedAtMs: Long? = null
12
+ private var startedAtMs: Long? = null
13
+
14
+ fun entry() = report("native-entry")
15
+
16
+ fun queued() {
17
+ queuedAtMs = clockMs()
18
+ report("queued")
19
+ }
20
+
21
+ fun started() {
22
+ startedAtMs = clockMs()
23
+ report("started")
24
+ }
25
+
26
+ fun finished(error: Throwable? = null) = report(if (error == null) "finished" else "evaluate-failed", error)
27
+
28
+ fun dropped(reason: String, error: Throwable? = null) = report(reason, error)
29
+
30
+ private fun report(phase: String, error: Throwable? = null) {
31
+ val now = clockMs()
32
+ emit(mapOf(
33
+ "event" to "native-ping-delivery",
34
+ "deliveryId" to deliveryId,
35
+ "contextId" to contextId,
36
+ "phase" to phase,
37
+ "elapsedMs" to now - enteredAtMs,
38
+ "queueWaitMs" to queuedAtMs?.let { (startedAtMs ?: now) - it },
39
+ "evaluateMs" to startedAtMs?.let { now - it },
40
+ "errorClass" to error?.javaClass?.simpleName,
41
+ "causeClass" to error?.cause?.javaClass?.simpleName,
42
+ ))
43
+ }
44
+ }
@@ -461,6 +461,7 @@ object NavigationManager {
461
461
  override fun onRoutesReady(routes: List<NavigationRoute>, routerOrigin: String) {
462
462
  if (!sessionGuard.acceptsCallback(session)) return
463
463
  if (routes.isEmpty()) {
464
+ stop()
464
465
  callbacks.onError("no route found")
465
466
  return
466
467
  }
@@ -477,12 +478,15 @@ object NavigationManager {
477
478
  if (!sessionGuard.acceptsCallback(session)) return
478
479
  val msg = reasons.firstOrNull()?.message ?: "route request failed"
479
480
  Log.e(TAG, "requestRoutes failed: $msg")
481
+ stop()
480
482
  callbacks.onError(msg)
481
483
  }
482
484
 
483
485
  override fun onCanceled(routeOptions: RouteOptions, routerOrigin: String) {
484
486
  if (!sessionGuard.acceptsCallback(session)) return
485
487
  Log.w(TAG, "route request canceled")
488
+ stop()
489
+ callbacks.onError("route request canceled")
486
490
  }
487
491
  },
488
492
  )
@@ -0,0 +1,76 @@
1
+ package com.mentra.crust.preview
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Bitmap
5
+ import android.graphics.Rect
6
+ import android.net.Uri
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.view.PixelCopy
10
+ import android.view.View
11
+ import java.io.File
12
+ import kotlin.coroutines.resumeWithException
13
+ import kotlinx.coroutines.Dispatchers
14
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
15
+ import kotlinx.coroutines.suspendCancellableCoroutine
16
+ import kotlinx.coroutines.withContext
17
+
18
+ /** Copies the displayed miniapp pixels without invoking WebView's software renderer. */
19
+ object PixelCopyPreview {
20
+ @OptIn(ExperimentalCoroutinesApi::class)
21
+ suspend fun capture(activity: Activity, viewTag: Int): String =
22
+ withContext(Dispatchers.Main.immediate) {
23
+ check(!activity.isFinishing && !activity.isDestroyed) { "Screenshot activity is unavailable" }
24
+ val window = activity.window
25
+ val decor = window.decorView
26
+ val view = decor.findViewById<View>(viewTag)
27
+ ?: error("Screenshot view $viewTag is unavailable")
28
+ check(view.isAttachedToWindow && view.isLaidOut && view.width > 0 && view.height > 0) {
29
+ "Screenshot view is not ready"
30
+ }
31
+
32
+ val location = IntArray(2)
33
+ view.getLocationInWindow(location)
34
+ val bounds = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
35
+ check(Rect(0, 0, decor.width, decor.height).contains(bounds)) {
36
+ "Screenshot view must be fully inside the window"
37
+ }
38
+
39
+ val bitmap = Bitmap.createBitmap(bounds.width(), bounds.height(), Bitmap.Config.ARGB_8888)
40
+ val captured = suspendCancellableCoroutine<Bitmap> { continuation ->
41
+ try {
42
+ PixelCopy.request(window, bounds, bitmap, { result ->
43
+ if (result == PixelCopy.SUCCESS) {
44
+ // A cancelled request still owns its bitmap until PixelCopy finishes.
45
+ continuation.resume(bitmap) { bitmap.recycle() }
46
+ } else {
47
+ bitmap.recycle()
48
+ continuation.resumeWithException(IllegalStateException("PixelCopy failed: $result"))
49
+ }
50
+ }, Handler(Looper.getMainLooper()))
51
+ } catch (error: Exception) {
52
+ bitmap.recycle()
53
+ continuation.resumeWithException(error)
54
+ }
55
+ }
56
+
57
+ try {
58
+ withContext(Dispatchers.IO) {
59
+ val file = File.createTempFile("miniapp-preview-", ".jpg", activity.cacheDir)
60
+ try {
61
+ file.outputStream().use { output ->
62
+ check(captured.compress(Bitmap.CompressFormat.JPEG, 50, output)) {
63
+ "Unable to encode miniapp screenshot"
64
+ }
65
+ }
66
+ Uri.fromFile(file).toString()
67
+ } catch (error: Exception) {
68
+ file.delete()
69
+ throw error
70
+ }
71
+ }
72
+ } finally {
73
+ captured.recycle()
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,44 @@
1
+ package com.mentra.crust.http
2
+
3
+ import java.io.IOException
4
+ import java.io.InterruptedIOException
5
+ import java.net.ConnectException
6
+ import java.net.NoRouteToHostException
7
+ import java.net.SocketException
8
+ import java.net.SocketTimeoutException
9
+ import java.net.UnknownHostException
10
+ import javax.net.ssl.SSLHandshakeException
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Test
13
+
14
+ class NativeHttpFailureTest {
15
+ @Test fun `preserves failure category without exception text`() {
16
+ val privateMessage = "https://private.example?token=secret SSID private-address"
17
+ val cases = listOf(
18
+ UnknownHostException(privateMessage) to "E_NATIVE_HTTP_DNS",
19
+ ConnectException(privateMessage) to "E_NATIVE_HTTP_CONNECT",
20
+ NoRouteToHostException(privateMessage) to "E_NATIVE_HTTP_CONNECT",
21
+ SocketTimeoutException(privateMessage) to "E_NATIVE_HTTP_TIMEOUT",
22
+ SSLHandshakeException(privateMessage) to "E_NATIVE_HTTP_TLS",
23
+ SocketException(privateMessage) to "E_NATIVE_HTTP_SOCKET",
24
+ InterruptedIOException(privateMessage) to "E_NATIVE_HTTP_INTERRUPTED",
25
+ IOException(privateMessage) to "E_NATIVE_HTTP_IO",
26
+ IllegalStateException(privateMessage) to "E_NATIVE_HTTP_OTHER",
27
+ )
28
+ cases.forEach { (error, expected) -> assertEquals(expected, nativeHttpFailureCode(error)) }
29
+ }
30
+
31
+ @Test fun `retains a specific cause inside a generic native wrapper`() {
32
+ val wrapped = IOException("private wrapper", UnknownHostException("private hostname"))
33
+ assertEquals("E_NATIVE_HTTP_DNS", nativeHttpFailureCode(wrapped))
34
+ assertEquals("E_NATIVE_HTTP_IO", nativeHttpFailureCode(IllegalStateException(IOException("private"))))
35
+ }
36
+
37
+ @Test fun `never parses a category from private error text and bounds cyclic causes`() {
38
+ assertEquals("E_NATIVE_HTTP_OTHER", nativeHttpFailureCode(Exception("E_NATIVE_HTTP_DNS")))
39
+ val first = Exception("private one")
40
+ val second = Exception("private two", first)
41
+ first.initCause(second)
42
+ assertEquals("E_NATIVE_HTTP_OTHER", nativeHttpFailureCode(first))
43
+ }
44
+ }
@@ -0,0 +1,133 @@
1
+ package com.mentra.crust.http
2
+
3
+ import java.io.IOException
4
+ import java.net.ServerSocket
5
+ import java.util.concurrent.CountDownLatch
6
+ import java.util.concurrent.TimeUnit
7
+ import okhttp3.Call
8
+ import okhttp3.Callback
9
+ import okhttp3.OkHttpClient
10
+ import okhttp3.Protocol
11
+ import okhttp3.Request
12
+ import okhttp3.Response
13
+ import okhttp3.ResponseBody.Companion.toResponseBody
14
+ import okio.Timeout
15
+ import org.junit.Assert.*
16
+ import org.junit.Test
17
+
18
+ class NativeHttpRequestsTest {
19
+ private val request = Request.Builder().url("https://example.com/report").build()
20
+
21
+ private class PendingCall(private val request: Request) : Call {
22
+ var callback: Callback? = null
23
+ var cancelled = false
24
+ var onEnqueue: (() -> Unit)? = null
25
+ override fun request() = request
26
+ override fun execute(): Response = error("Test only exercises asynchronous calls")
27
+ override fun enqueue(responseCallback: Callback) {
28
+ callback = responseCallback
29
+ onEnqueue?.invoke()
30
+ }
31
+ override fun cancel() {cancelled = true}
32
+ override fun isExecuted() = callback != null
33
+ override fun isCanceled() = cancelled
34
+ override fun timeout() = Timeout.NONE
35
+ override fun clone(): Call = PendingCall(request)
36
+ }
37
+
38
+ @Test fun `register before enqueue and cancel only the requested call`() {
39
+ val calls = mutableListOf<PendingCall>()
40
+ val owner = NativeHttpRequests(Call.Factory { req -> PendingCall(req).also {calls.add(it)} })
41
+ owner.enqueue("one", request, {}, {throw it})
42
+ calls[0].onEnqueue = {assertEquals(2, owner.activeCount)}
43
+ owner.enqueue("two", request, {}, {throw it})
44
+ assertEquals(2, owner.activeCount)
45
+ owner.cancel("one")
46
+ assertTrue(calls[0].cancelled)
47
+ assertFalse(calls[1].cancelled)
48
+ assertEquals(1, owner.activeCount)
49
+ owner.cancel("not-owned")
50
+ assertEquals(1, owner.activeCount)
51
+ owner.cancelAll()
52
+ assertTrue(calls[1].cancelled)
53
+ assertEquals(0, owner.activeCount)
54
+ }
55
+
56
+ @Test fun `completion removes the active request and preserves the response`() {
57
+ val call = PendingCall(request)
58
+ val owner = NativeHttpRequests(Call.Factory {call})
59
+ var result: NativeHttpRequests.Result? = null
60
+ call.onEnqueue = {assertEquals(1, owner.activeCount)}
61
+ owner.enqueue("complete", request, {result = it}, {throw it})
62
+ call.callback!!.onResponse(call, Response.Builder().request(request).protocol(Protocol.HTTP_1_1)
63
+ .code(200).message("OK").header("Content-Type", "application/json").body("{}".toResponseBody()).build())
64
+ assertEquals("{}", result!!.body)
65
+ assertEquals("application/json", result!!.headers["content-type"])
66
+ assertEquals(0, owner.activeCount)
67
+ owner.cancel("complete")
68
+ assertFalse(call.cancelled)
69
+ }
70
+
71
+ @Test fun `network failure and synchronous enqueue failure leave no registry entries`() {
72
+ val call = PendingCall(request)
73
+ val owner = NativeHttpRequests(Call.Factory {call})
74
+ var failures = 0
75
+ owner.enqueue("failed", request, {}, {failures++})
76
+ call.callback!!.onFailure(call, IOException("offline"))
77
+ assertEquals(0, owner.activeCount)
78
+ call.onEnqueue = {throw IllegalStateException("dispatcher closed")}
79
+ owner.enqueue("enqueue-failed", request, {}, {failures++})
80
+ assertEquals(0, owner.activeCount)
81
+ assertEquals(2, failures)
82
+ }
83
+
84
+ @Test fun `duplicate IDs cannot replace existing ownership and destruction prevents later requests`() {
85
+ val calls = mutableListOf<PendingCall>()
86
+ val owner = NativeHttpRequests(Call.Factory {req -> PendingCall(req).also {calls.add(it)} })
87
+ var failures = 0
88
+ owner.enqueue("one", request, {}, {failures++})
89
+ owner.enqueue("one", request, {}, {failures++})
90
+ assertEquals(1, failures)
91
+ assertEquals(1, owner.activeCount)
92
+ assertNull(calls[1].callback)
93
+ owner.cancelAll()
94
+ assertTrue(calls[0].cancelled)
95
+ owner.enqueue("later", request, {}, {failures++})
96
+ assertEquals(2, failures)
97
+ assertEquals(2, calls.size)
98
+ assertEquals(0, owner.activeCount)
99
+ }
100
+
101
+ @Test fun `cancel interrupts an actual OkHttp response body and closes the server socket`() {
102
+ val client = OkHttpClient()
103
+ val owner = NativeHttpRequests(client)
104
+ val bodyStarted = CountDownLatch(1)
105
+ val socketClosed = CountDownLatch(1)
106
+ val failed = CountDownLatch(1)
107
+ ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress()).use {server ->
108
+ val thread = Thread {
109
+ server.accept().use {socket ->
110
+ socket.soTimeout = 5_000
111
+ val reader = socket.getInputStream().bufferedReader()
112
+ while (!reader.readLine().isNullOrEmpty()) {}
113
+ socket.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray())
114
+ socket.getOutputStream().flush()
115
+ bodyStarted.countDown()
116
+ if (socket.getInputStream().read() == -1) socketClosed.countDown()
117
+ }
118
+ }
119
+ thread.isDaemon = true
120
+ thread.start()
121
+ val req = Request.Builder().url("http://127.0.0.1:${server.localPort}/report").build()
122
+ owner.enqueue("stalled-body", req, {fail("Cancelled body must not complete")}, {failed.countDown()})
123
+ assertTrue("server started body", bodyStarted.await(5, TimeUnit.SECONDS))
124
+ owner.cancel("stalled-body")
125
+ assertTrue("native request rejected", failed.await(5, TimeUnit.SECONDS))
126
+ assertTrue("socket closed", socketClosed.await(5, TimeUnit.SECONDS))
127
+ assertEquals(0, owner.activeCount)
128
+ thread.join(5_000)
129
+ }
130
+ client.dispatcher.executorService.shutdownNow()
131
+ client.connectionPool.evictAll()
132
+ }
133
+ }