@keepsnappy/react-native 0.1.0-alpha.17 → 0.1.0-alpha.19

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/README.md CHANGED
@@ -72,6 +72,7 @@ All values are read at **bundle time** (Expo CLI loads `.env*` into the Metro pr
72
72
  | `EXPO_PUBLIC_KEEPSNAPPY_SAMPLER` | `KEEPSNAPPY_SAMPLER` | `auto` / `manual` / `off` | Native resource sampler default. Unset: `auto` when `KEEPSNAPPY_AUTO=1`, else `off`. |
73
73
  | `EXPO_PUBLIC_KEEPSNAPPY_SAMPLER_MS` | `KEEPSNAPPY_SAMPLER_MS` | ms, 250–60000 | Auto sampling interval (default 4000; windows also get a sample at open and at close). |
74
74
  | `EXPO_PUBLIC_KEEPSNAPPY_PROFILE` | `KEEPSNAPPY_PROFILE` | `1` = on | Default of `profile`: per-component update causes per window (any renderer), plus self/total ms and updaters with the profiling renderer (`react.profile`). |
75
+ | `EXPO_PUBLIC_KEEPSNAPPY_TAP_TEXT` | `KEEPSNAPPY_TAP_TEXT` | `0` = off, anything else = on (default) | Privacy switch for tap names. On: a tap with no `testID` and no `accessibilityLabel` falls back to the text under it (first 40 characters) in its legacy name. Off: that text is never read; the tap is named by its nearest named component, else `tag:<n>`. Place keys never contain text either way. Set `0` when screens show personal data in buttons. |
75
76
  | `EXPO_PUBLIC_KEEPSNAPPY_REACT_EXPORT` | `KEEPSNAPPY_REACT_EXPORT` | `1` = on | Default of `rexport`: a React DevTools v5 export per window (`Documents/keepsnappy/react-<process>-<window id>-<name>.json`, TraceSift's input). Inside a run (a run token is set) the files are held and written at `flush=1`; otherwise at window end. |
76
77
  | `EXPO_PUBLIC_KEEPSNAPPY_CPUPROF`, `..._CPUPROF_HZ` | `KEEPSNAPPY_CPUPROF` | `1` = on; Hz (default 250) | Default of `cpuprof`/`cpuhz`: Hermes sampling profiler (iOS), `Documents/keepsnappy/cpu-<process>-<span>.cpuprofile`. Inside a run it samples from the first window to `flush=1` and stops and dumps there, so the synchronous dump never lands in a measured window; otherwise one file per span of overlapping windows, dumped when the last one ends. `<process>` is the launch's `processId` (startup line), so launches never overwrite each other. |
77
78
  | `KEEPSNAPPY_REACT_PROFILING` (Metro process; also read by the config plugin) | | `1` / `switch` / unset | `1`: `withKeepSnappy` resolves RN's production renderer to `ReactFabric-profiling` (fiber durations, updaters), flag or not; for dedicated perf builds (docs/plans/37). `switch` (flag on only, ignored with the flag off): both Fabric renderers go into the bundle and each process loads one at launch (below). Unset: production renderer only. |
@@ -17,10 +17,12 @@ import android.view.View
17
17
  import android.view.ViewGroup
18
18
  import android.widget.FrameLayout
19
19
  import android.widget.TextView
20
+ import expo.modules.kotlin.Promise
20
21
  import expo.modules.kotlin.modules.Module
21
22
  import expo.modules.kotlin.modules.ModuleDefinition
22
23
  import java.io.File
23
24
  import java.io.FileOutputStream
25
+ import java.io.InterruptedIOException
24
26
  import java.io.RandomAccessFile
25
27
  import java.net.HttpURLConnection
26
28
  import java.net.URL
@@ -29,6 +31,12 @@ import java.security.MessageDigest
29
31
  import java.util.concurrent.ConcurrentHashMap
30
32
  import java.util.concurrent.ExecutorService
31
33
  import java.util.concurrent.Executors
34
+ import java.util.concurrent.LinkedBlockingQueue
35
+ import java.util.concurrent.ScheduledExecutorService
36
+ import java.util.concurrent.ScheduledThreadPoolExecutor
37
+ import java.util.concurrent.ThreadPoolExecutor
38
+ import java.util.concurrent.TimeUnit
39
+ import java.util.concurrent.atomic.AtomicReference
32
40
  import java.util.zip.GZIPOutputStream
33
41
 
34
42
  private const val TAG = "KEEPSNAPPY"
@@ -53,6 +61,11 @@ private const val SPOOL_READ_MAX = 16L * 1024 * 1024
53
61
  // The API parses at most 64 MiB of a raw profile file (MAX_RAW_BYTES in apps/api cpu-worker.ts).
54
62
  private const val ARTIFACT_MAX_BYTES = 64L * 1024 * 1024
55
63
  private const val COPY_BUFFER = 64 * 1024
64
+ // Upload workers, one per SDK lane (ARTIFACT_UPLOAD_CONCURRENCY in js/sdk/artifacts.ts; test/artifacts.test.ts keeps
65
+ // them equal). Expo runs every AsyncFunction of a module on one shared thread, which would PUT the lanes' files one
66
+ // after the other.
67
+ private const val ARTIFACT_UPLOAD_LANES = 8
68
+ private const val POOL_IDLE_SECONDS = 30L
56
69
 
57
70
  // Files that lived in external storage before the move to filesDir.
58
71
  private val LEGACY_STATE_FILES = listOf("activation.json", "push-spool.jsonl")
@@ -60,7 +73,6 @@ private val LEGACY_STATE_FILES = listOf("activation.json", "push-spool.jsonl")
60
73
  // Created by Expo at app start whether or not the SDK measures: nothing here may
61
74
  // do work before a function is called (a dormant build calls activationRead only).
62
75
  // UNTESTED ON DEVICE (activation, spool): compiled only, like the sampler.
63
- // NOT YET COMPILED: moveLegacyState and the streamed uploadArtifact (night of 2026-10-14).
64
76
  class KeepSnappyModule : Module() {
65
77
  private val mainHandler = Handler(Looper.getMainLooper())
66
78
 
@@ -162,9 +174,27 @@ class KeepSnappyModule : Module() {
162
174
  samplerCreated = true
163
175
  KeepSnappySampler({ appContext.reactContext }, { appContext.currentActivity }, ::writeLog)
164
176
  }
165
- private val io: ExecutorService by lazy {
166
- Executors.newSingleThreadExecutor { r -> Thread(r, "keepsnappy-io") }
177
+ // The three pools below are created on first use and shut down in OnDestroy (a JS reload creates a new module); their
178
+ // idle threads also time out, so a pool used once does not keep a thread.
179
+ private val ioPool = lazy { Executors.newSingleThreadExecutor { r -> Thread(r, "keepsnappy-io") } }
180
+ private val io: ExecutorService get() = ioPool.value
181
+ private val uploadsPool = lazy {
182
+ ThreadPoolExecutor(
183
+ ARTIFACT_UPLOAD_LANES, ARTIFACT_UPLOAD_LANES, POOL_IDLE_SECONDS, TimeUnit.SECONDS, LinkedBlockingQueue<Runnable>(),
184
+ ) { r -> Thread(r, "keepsnappy-upload").apply { isDaemon = true } }.apply { allowCoreThreadTimeOut(true) }
167
185
  }
186
+ private val uploads: ExecutorService get() = uploadsPool.value
187
+ // Ends a PUT at its deadline: HttpURLConnection's timeouts bound one connect or read, not a write or the whole upload.
188
+ private val uploadTimerPool = lazy {
189
+ ScheduledThreadPoolExecutor(1) { r -> Thread(r, "keepsnappy-upload-timer").apply { isDaemon = true } }.apply {
190
+ setKeepAliveTime(POOL_IDLE_SECONDS, TimeUnit.SECONDS)
191
+ allowCoreThreadTimeOut(true)
192
+ removeOnCancelPolicy = true
193
+ }
194
+ }
195
+ private val uploadTimer: ScheduledExecutorService get() = uploadTimerPool.value
196
+ // Set once the module is destroyed: a worker still running then must not touch the (gone) JS context.
197
+ @Volatile private var destroyed = false
168
198
 
169
199
  // activation.json and the push journal (run tokens) live in internal storage: on Android 9
170
200
  // and older another app with storage permission could read or plant files in external storage.
@@ -231,7 +261,9 @@ class KeepSnappyModule : Module() {
231
261
  // sha256 of the gzip, PUT to the signed URL the API handed out. status 0: no HTTP answer.
232
262
  // The gzip is streamed to a temp file (hashed while written) and PUT from it, so neither
233
263
  // the file nor its gzip is held in memory; a file over the API's cap is not read at all.
264
+ // `timeoutMs` bounds gzip + PUT from here, whatever the call waited for a worker.
234
265
  private fun uploadArtifact(fileName: String, url: String, headers: Map<String, String>, timeoutMs: Double): Map<String, Any?> {
266
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs.toLong()
235
267
  val context = appContext.reactContext
236
268
  val base = context?.getExternalFilesDir(null)
237
269
  val file = base?.let { File(File(it, "keepsnappy"), fileName.replace('/', '_')) }
@@ -259,25 +291,38 @@ class KeepSnappyModule : Module() {
259
291
  val sha = digest.digest().joinToString("") { "%02x".format(it) }
260
292
  val gzBytes = gzFile.length()
261
293
  val out = mutableMapOf<String, Any?>("bytes" to length.toInt(), "gzBytes" to gzBytes.toInt(), "sha256" to sha)
262
- var connection: HttpURLConnection? = null
294
+ val left = deadline - SystemClock.elapsedRealtime()
295
+ if (left <= 0) {
296
+ out["status"] = 0
297
+ out["error"] = "timeout before the PUT"
298
+ return out
299
+ }
300
+ val connection = AtomicReference<HttpURLConnection?>(null)
301
+ val cut = uploadTimer.schedule({ connection.get()?.disconnect() }, left, TimeUnit.MILLISECONDS)
263
302
  try {
264
303
  val conn = URL(url).openConnection() as HttpURLConnection
265
- connection = conn
304
+ connection.set(conn)
266
305
  conn.requestMethod = "PUT"
267
306
  conn.doOutput = true
268
- conn.connectTimeout = timeoutMs.toInt()
269
- conn.readTimeout = timeoutMs.toInt()
307
+ conn.connectTimeout = left.toInt()
308
+ conn.readTimeout = left.toInt()
270
309
  conn.setFixedLengthStreamingMode(gzBytes)
271
310
  for ((key, value) in headers) {
272
311
  conn.setRequestProperty(key, value)
273
312
  }
274
- conn.outputStream.use { sink -> gzFile.inputStream().use { it.copyTo(sink, COPY_BUFFER) } }
313
+ conn.outputStream.use { sink ->
314
+ // disconnect() before the stream is open cuts nothing, so a deadline that passed while it opened is caught
315
+ // here; one that passes after this is a disconnect on an open stream, which fails the write.
316
+ if (cut.isDone) throw InterruptedIOException("deadline")
317
+ gzFile.inputStream().use { it.copyTo(sink, COPY_BUFFER) }
318
+ }
275
319
  out["status"] = conn.responseCode
276
320
  } catch (e: Exception) {
277
321
  out["status"] = 0
278
- out["error"] = e.toString()
322
+ out["error"] = if (cut.isDone) "timeout after ${timeoutMs.toLong()} ms: $e" else e.toString()
279
323
  } finally {
280
- connection?.disconnect()
324
+ cut.cancel(false)
325
+ connection.get()?.disconnect()
281
326
  }
282
327
  return out
283
328
  } finally {
@@ -356,8 +401,14 @@ class KeepSnappyModule : Module() {
356
401
  }
357
402
 
358
403
  OnDestroy {
404
+ destroyed = true
359
405
  if (samplerCreated) sampler.configure("off", 0.0)
360
406
  commandListen(false)
407
+ // Queued uploads are dropped; a PUT already running ends at its own deadline (the timer, shut down, still fires
408
+ // what it holds).
409
+ if (uploadsPool.isInitialized()) uploadsPool.value.shutdownNow()
410
+ if (uploadTimerPool.isInitialized()) uploadTimerPool.value.shutdown()
411
+ if (ioPool.isInitialized()) ioPool.value.shutdown()
361
412
  }
362
413
 
363
414
  Function("commandListen") { on: Boolean ->
@@ -466,9 +517,25 @@ class KeepSnappyModule : Module() {
466
517
  )
467
518
  }
468
519
 
469
- // Called at flush only, never inside a measured window; runs off the JS thread.
470
- AsyncFunction("artifactUpload") { fileName: String, url: String, headers: Map<String, String>, timeoutMs: Double ->
471
- uploadArtifact(fileName, url, headers, timeoutMs)
520
+ // Called at flush only, never inside a measured window; runs on the upload workers, off the JS thread and off
521
+ // Expo's shared AsyncFunction thread.
522
+ AsyncFunction("artifactUpload") { fileName: String, url: String, headers: Map<String, String>, timeoutMs: Double, promise: Promise ->
523
+ uploads.execute {
524
+ if (destroyed) return@execute
525
+ val result = try {
526
+ uploadArtifact(fileName, url, headers, timeoutMs)
527
+ } catch (e: Throwable) {
528
+ mapOf("status" to 0, "error" to e.toString())
529
+ }
530
+ // A reload mid-flush destroys the module while this worker runs: its JS context is gone, nothing waits for it.
531
+ // UNTESTED ON DEVICE: compiled only; a reload during a flush needs a device run.
532
+ if (destroyed) return@execute
533
+ try {
534
+ promise.resolve(result)
535
+ } catch (e: Throwable) {
536
+ Log.w(TAG, "artifactUpload: no one to answer: $e")
537
+ }
538
+ }
472
539
  }
473
540
 
474
541
  // getExternalFilesDir needs no permission and `adb pull` reads it from a
@@ -0,0 +1,91 @@
1
+ import Compression
2
+ import CryptoKit
3
+ import Foundation
4
+
5
+ // Gzip (RFC 1952) of a file streamed into another, so neither the file nor its gzip is held in memory: one chunk at a
6
+ // time, whatever the file's size. The Compression framework's zlib codec writes raw DEFLATE (RFC 1951); the header and trailer are added
7
+ // here.
8
+ enum KeepSnappyGzip {
9
+ struct Result {
10
+ let bytes: Int
11
+ let gzBytes: Int
12
+ // Of the gzip as written.
13
+ let sha256: String
14
+ }
15
+
16
+ private static let chunk = 64 * 1024
17
+
18
+ // nil: the file could not be read, compressed or written.
19
+ static func file(_ source: URL, to target: URL) -> Result? {
20
+ guard let input = InputStream(url: source) else { return nil }
21
+ guard FileManager.default.createFile(atPath: target.path, contents: nil),
22
+ let output = try? FileHandle(forWritingTo: target) else { return nil }
23
+ defer { try? output.close() }
24
+ input.open()
25
+ defer { input.close() }
26
+
27
+ var hasher = SHA256()
28
+ var gzBytes = 0
29
+ var writeFailed = false
30
+ func write(_ data: Data) {
31
+ guard !writeFailed else { return }
32
+ do {
33
+ try output.write(contentsOf: data)
34
+ hasher.update(data: data)
35
+ gzBytes += data.count
36
+ } catch {
37
+ writeFailed = true
38
+ }
39
+ }
40
+
41
+ write(Data([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff]))
42
+ var crc: UInt32 = 0xFFFF_FFFF
43
+ var size = 0
44
+ do {
45
+ let filter = try OutputFilter(.compress, using: .zlib) { (deflated: Data?) in
46
+ if let deflated { write(deflated) }
47
+ }
48
+ var buffer = [UInt8](repeating: 0, count: chunk)
49
+ while true {
50
+ let n = input.read(&buffer, maxLength: buffer.count)
51
+ if n < 0 { return nil }
52
+ if n == 0 { break }
53
+ let data = Data(buffer[0..<n])
54
+ crc = crc32Update(crc, data)
55
+ size += n
56
+ try filter.write(data)
57
+ }
58
+ try filter.finalize()
59
+ } catch {
60
+ return nil
61
+ }
62
+ var crcOut = (crc ^ 0xFFFF_FFFF).littleEndian
63
+ var sizeOut = UInt32(truncatingIfNeeded: size).littleEndian
64
+ var trailer = Data()
65
+ withUnsafeBytes(of: &crcOut) { trailer.append(contentsOf: $0) }
66
+ withUnsafeBytes(of: &sizeOut) { trailer.append(contentsOf: $0) }
67
+ write(trailer)
68
+ if writeFailed { return nil }
69
+ let sha = hasher.finalize().map { String(format: "%02x", $0) }.joined()
70
+ return Result(bytes: size, gzBytes: gzBytes, sha256: sha)
71
+ }
72
+
73
+ private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in
74
+ var c = UInt32(n)
75
+ for _ in 0..<8 {
76
+ c = (c & 1) != 0 ? 0xEDB8_8320 ^ (c >> 1) : c >> 1
77
+ }
78
+ return c
79
+ }
80
+
81
+ // CRC-32 (IEEE) running state: start at 0xFFFFFFFF, xor the end with 0xFFFFFFFF.
82
+ private static func crc32Update(_ start: UInt32, _ data: Data) -> UInt32 {
83
+ var c = start
84
+ data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in
85
+ for byte in buffer {
86
+ c = crcTable[Int((c ^ UInt32(byte)) & 0xFF)] ^ (c >> 8)
87
+ }
88
+ }
89
+ return c
90
+ }
91
+ }
@@ -1,4 +1,3 @@
1
- import CryptoKit
2
1
  import ExpoModulesCore
3
2
  import Foundation
4
3
  import UIKit
@@ -224,46 +223,12 @@ public class KeepSnappyModule: Module {
224
223
 
225
224
  // A raw profile file of a run (plan 40 §3.6): gzip, sha256 of the gzip, PUT to the
226
225
  // signed URL the API handed out. Called at flush only, never inside a measured window.
227
- // status 0: no HTTP answer (missing file, network error, timeout).
228
- // The file is memory-mapped (clean pages, not app memory) and checked against the API's cap
229
- // before it is read; only the gzip is built in memory.
230
- // NOT YET COMPILED: the size check and the mapped read (night of 2026-10-14).
226
+ // status 0: no HTTP answer (missing file, network error, timeout). Runs on the upload
227
+ // workers: Expo runs a module's AsyncFunctions on one serial queue.
231
228
  AsyncFunction("artifactUpload") { (fileName: String, url: String, headers: [String: String], timeoutMs: Double, promise: Promise) in
232
- guard let file = Self.fileIfPresent(fileName.replacingOccurrences(of: "/", with: "_")) else {
233
- promise.resolve(["status": 0, "error": "file missing"])
234
- return
229
+ Self.uploadQueue.addOperation {
230
+ promise.resolve(Self.uploadArtifact(fileName: fileName, url: url, headers: headers, timeoutMs: timeoutMs))
235
231
  }
236
- let size = ((try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber)?.int64Value ?? 0
237
- if size > Self.artifactMaxBytes {
238
- promise.resolve(["status": 413, "error": "file is \(size) bytes, over \(Self.artifactMaxBytes)"])
239
- return
240
- }
241
- guard let raw = try? Data(contentsOf: file, options: .alwaysMapped) else {
242
- promise.resolve(["status": 0, "error": "file missing"])
243
- return
244
- }
245
- guard let target = URL(string: url), let gz = Self.gzip(raw) else {
246
- promise.resolve(["status": 0, "bytes": raw.count, "error": "gzip or url failed"])
247
- return
248
- }
249
- let sha = SHA256.hash(data: gz).map { String(format: "%02x", $0) }.joined()
250
- var request = URLRequest(url: target, timeoutInterval: max(1, timeoutMs / 1000))
251
- request.httpMethod = "PUT"
252
- for (key, value) in headers {
253
- request.setValue(value, forHTTPHeaderField: key)
254
- }
255
- URLSession.shared.uploadTask(with: request, from: gz) { _, response, error in
256
- var out: [String: Any] = [
257
- "status": (response as? HTTPURLResponse)?.statusCode ?? 0,
258
- "bytes": raw.count,
259
- "gzBytes": gz.count,
260
- "sha256": sha,
261
- ]
262
- if let error {
263
- out["error"] = error.localizedDescription
264
- }
265
- promise.resolve(out)
266
- }.resume()
267
232
  }
268
233
 
269
234
  Function("cpuProfileDump") { (fileName: String) -> [String: Any]? in
@@ -281,6 +246,76 @@ public class KeepSnappyModule: Module {
281
246
  // The API parses at most 64 MiB of a raw profile file (MAX_RAW_BYTES in apps/api cpu-worker.ts).
282
247
  private static let artifactMaxBytes: Int64 = 64 * 1024 * 1024
283
248
 
249
+ // Upload workers, one per SDK lane (ARTIFACT_UPLOAD_CONCURRENCY in js/sdk/artifacts.ts; test/artifacts.test.ts keeps
250
+ // them equal). A worker holds its file until the PUT is answered.
251
+ private static let artifactUploadLanes = 8
252
+ // Static lets: initialised once, safely from any thread.
253
+ private static let uploadQueue: OperationQueue = {
254
+ let queue = OperationQueue()
255
+ queue.name = "com.gigaverse.keepsnappy.upload"
256
+ queue.maxConcurrentOperationCount = KeepSnappyModule.artifactUploadLanes
257
+ queue.qualityOfService = .utility
258
+ return queue
259
+ }()
260
+ private static let uploadSession: URLSession = {
261
+ let config = URLSessionConfiguration.default
262
+ config.httpMaximumConnectionsPerHost = KeepSnappyModule.artifactUploadLanes
263
+ return URLSession(configuration: config)
264
+ }()
265
+
266
+ // The gzip is streamed to a temp file (hashed while written) and PUT from it, so neither the file
267
+ // nor its gzip is held in memory; a file over the API's cap is not read at all. `timeoutMs`
268
+ // bounds gzip + PUT from here, whatever the call waited for a worker.
269
+ private static func uploadArtifact(fileName: String, url: String, headers: [String: String], timeoutMs: Double) -> [String: Any] {
270
+ let deadline = DispatchTime.now() + .milliseconds(Int(max(0, timeoutMs)))
271
+ guard let file = fileIfPresent(fileName.replacingOccurrences(of: "/", with: "_")) else {
272
+ return ["status": 0, "error": "file missing"]
273
+ }
274
+ let size = ((try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber)?.int64Value ?? 0
275
+ if size > Self.artifactMaxBytes {
276
+ return ["status": 413, "error": "file is \(size) bytes, over \(Self.artifactMaxBytes)"]
277
+ }
278
+ guard let target = URL(string: url) else {
279
+ return ["status": 0, "bytes": Int(size), "error": "bad url"]
280
+ }
281
+ let gzFile = FileManager.default.temporaryDirectory.appendingPathComponent("keepsnappy-upload-\(UUID().uuidString).gz")
282
+ defer { try? FileManager.default.removeItem(at: gzFile) }
283
+ guard let gz = KeepSnappyGzip.file(file, to: gzFile) else {
284
+ return ["status": 0, "bytes": Int(size), "error": "gzip failed"]
285
+ }
286
+ var out: [String: Any] = ["status": 0, "bytes": gz.bytes, "gzBytes": gz.gzBytes, "sha256": gz.sha256]
287
+ let leftNs = Int64(deadline.uptimeNanoseconds) - Int64(DispatchTime.now().uptimeNanoseconds)
288
+ if leftNs <= 0 {
289
+ out["error"] = "timeout before the PUT"
290
+ return out
291
+ }
292
+ var request = URLRequest(url: target, timeoutInterval: max(1, Double(leftNs) / 1e9))
293
+ request.httpMethod = "PUT"
294
+ for (key, value) in headers {
295
+ request.setValue(value, forHTTPHeaderField: key)
296
+ }
297
+ let answered = DispatchSemaphore(value: 0)
298
+ var status = 0
299
+ var failure: String?
300
+ let task = uploadSession.uploadTask(with: request, fromFile: gzFile) { _, response, error in
301
+ status = (response as? HTTPURLResponse)?.statusCode ?? 0
302
+ failure = error?.localizedDescription
303
+ answered.signal()
304
+ }
305
+ task.resume()
306
+ // URLRequest's timeout is between two packets; the whole PUT ends at the deadline.
307
+ if answered.wait(timeout: deadline) == .timedOut {
308
+ task.cancel()
309
+ answered.wait()
310
+ failure = "timeout after \(Int(timeoutMs)) ms: \(failure ?? "cancelled")"
311
+ }
312
+ out["status"] = status
313
+ if let failure {
314
+ out["error"] = failure
315
+ }
316
+ return out
317
+ }
318
+
284
319
  private static func outputDir() -> URL? {
285
320
  guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
286
321
  let dir = docs.appendingPathComponent("keepsnappy", isDirectory: true)
@@ -288,36 +323,6 @@ public class KeepSnappyModule: Module {
288
323
  return dir
289
324
  }
290
325
 
291
- // RFC 1952 around Foundation's zlib codec, which writes raw DEFLATE (RFC 1951).
292
- private static func gzip(_ data: Data) -> Data? {
293
- guard let deflated = try? (data as NSData).compressed(using: .zlib) as Data else { return nil }
294
- var out = Data([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff])
295
- out.append(deflated)
296
- var crc = crc32(data).littleEndian
297
- var size = UInt32(truncatingIfNeeded: data.count).littleEndian
298
- withUnsafeBytes(of: &crc) { out.append(contentsOf: $0) }
299
- withUnsafeBytes(of: &size) { out.append(contentsOf: $0) }
300
- return out
301
- }
302
-
303
- private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in
304
- var c = UInt32(n)
305
- for _ in 0..<8 {
306
- c = (c & 1) != 0 ? 0xEDB8_8320 ^ (c >> 1) : c >> 1
307
- }
308
- return c
309
- }
310
-
311
- private static func crc32(_ data: Data) -> UInt32 {
312
- var c: UInt32 = 0xFFFF_FFFF
313
- data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in
314
- for byte in buffer {
315
- c = crcTable[Int((c ^ UInt32(byte)) & 0xFF)] ^ (c >> 8)
316
- }
317
- }
318
- return c ^ 0xFFFF_FFFF
319
- }
320
-
321
326
  private static let flushSignalTag = 0x9E7F_1A5
322
327
  private static let ackSignalTag = 0x9E7F_1A6
323
328
 
@@ -51,7 +51,18 @@ export const ARTIFACT_CALLS_PER_MINUTE = 200;
51
51
  export const ARTIFACT_UPLOAD_DEADLINE_MS =
52
52
  ((ARTIFACT_MAX_FILES_PER_RUN * ARTIFACT_CALLS_PER_FILE) / ARTIFACT_CALLS_PER_MINUTE) * 60_000;
53
53
  export const ARTIFACT_REQUEST_TIMEOUT_MS = 15_000;
54
+ // Counted by native from the moment it starts on the file (gzip + PUT), not from the call: the call may wait in the
55
+ // native upload pool first.
54
56
  export const ARTIFACT_PUT_TIMEOUT_MS = 60_000;
57
+ // Native ends its own upload at the PUT timeout; the JS side gives up on the call this much later, so a native call
58
+ // that hangs (or a worker that never answers) frees its lane instead of holding it to the flush deadline.
59
+ export const ARTIFACT_NATIVE_CALL_MARGIN_MS = 10_000;
60
+ // Files in flight at once (lanes). A file costs a lane three round trips (announce, PUT, complete with the API's inline
61
+ // conclusion): 1.84 s on the farm, so four lanes took 73 s for 159 files while the API instance stayed under half a CPU
62
+ // (on Android the PUTs also ran one at a time). Each native module uploads on a pool of this many workers (ARTIFACT_UPLOAD_LANES in KeepSnappyModule.kt,
63
+ // artifactUploadLanes in KeepSnappyModule.swift; test/artifacts.test.ts keeps them equal). The API's per-run bucket
64
+ // (ARTIFACT_CALLS_BURST in apps/api artifacts/service.ts) holds a whole run, so the lanes never wait on a 429.
65
+ export const ARTIFACT_UPLOAD_CONCURRENCY = 8;
55
66
  // One deadline per flush, started at the flush link (core.ts flushOnce) and shared by the lines push, the clock
56
67
  // report and the artifact upload (this file's ARTIFACT_UPLOAD_DEADLINE_MS is that deadline). The end report gets its
57
68
  // own request timeout after it, so `keepsnappy.flushed` is raised at most ARTIFACT_FLUSH_WORST_CASE_MS after the link.
@@ -154,6 +165,7 @@ const withoutToken = (entry: ArtifactEntry): Omit<ArtifactEntry, 'runToken'> =>
154
165
 
155
166
  // Gzips the file (named as written by writeText / cpuProfileDump), hashes the
156
167
  // gzip, PUTs it. status 0: no HTTP answer (network, timeout, missing file).
168
+ // `timeoutMs` bounds gzip + PUT from the moment native starts on the file.
157
169
  export interface ArtifactUploadNative {
158
170
  artifactUpload(
159
171
  fileName: string,
@@ -189,8 +201,10 @@ export interface ArtifactUploaderDeps {
189
201
  backoffBaseMs?: number;
190
202
  requestTimeoutMs?: number;
191
203
  putTimeoutMs?: number;
192
- // Files in flight at once: the upload must finish inside the driver's wait for the
193
- // flush signal, and one file after the other (about 0.85 s each) took 115 s for 135.
204
+ // How long past the PUT timeout the JS side still waits for the native call; default ARTIFACT_NATIVE_CALL_MARGIN_MS.
205
+ nativeMarginMs?: number;
206
+ // Files in flight at once; default ARTIFACT_UPLOAD_CONCURRENCY. The upload must finish inside the driver's wait
207
+ // for the flush signal.
194
208
  concurrency?: number;
195
209
  // The whole upload stops here; the rest stays on the device as `failed` and goes up
196
210
  // at the next flush. Default: the time the API's per-run budget needs for a full run.
@@ -291,8 +305,9 @@ export const createArtifactUploader = (deps: ArtifactUploaderDeps) => {
291
305
  const backoffBase = deps.backoffBaseMs ?? 1000;
292
306
  const requestTimeoutMs = deps.requestTimeoutMs ?? ARTIFACT_REQUEST_TIMEOUT_MS;
293
307
  const putTimeoutMs = deps.putTimeoutMs ?? ARTIFACT_PUT_TIMEOUT_MS;
308
+ const nativeMarginMs = deps.nativeMarginMs ?? ARTIFACT_NATIVE_CALL_MARGIN_MS;
294
309
  const deadlineMs = deps.deadlineMs ?? ARTIFACT_UPLOAD_DEADLINE_MS;
295
- const concurrency = Math.max(1, deps.concurrency ?? 4);
310
+ const concurrency = Math.max(1, deps.concurrency ?? ARTIFACT_UPLOAD_CONCURRENCY);
296
311
 
297
312
  // Rejects after `ms`: a hard bound for work that may ignore its abort signal (a hung fetch, a native PUT).
298
313
  const timeoutAfter = (ms: number) => {
@@ -405,12 +420,15 @@ export const createArtifactUploader = (deps: ArtifactUploaderDeps) => {
405
420
  if (!native) {
406
421
  return { ok: false, retry: false, stop: 'native upload unavailable', state: 'local', error: 'native upload unavailable' };
407
422
  }
408
- const budget = Math.min(putTimeoutMs, deadline - deps.now());
423
+ const left = deadline - deps.now();
424
+ const budget = Math.min(putTimeoutMs, left);
409
425
  if (budget <= 0) {
410
426
  return { ok: false, retry: false, stop: 'deadline', state: 'failed', error: 'put deadline' };
411
427
  }
412
428
  let result: Awaited<ReturnType<ArtifactUploadNative['artifactUpload']>>;
413
- const hard = timeoutAfter(budget);
429
+ // Native ends its own upload `budget` after it starts on the file. The pool has a worker per lane, so a call
430
+ // rarely waits before that; the margin covers that wait and the gzip, and the flush deadline still bounds it.
431
+ const hard = timeoutAfter(Math.min(left, budget + nativeMarginMs));
414
432
  try {
415
433
  result = await Promise.race([
416
434
  native.artifactUpload(entry.name, signed.url as string, signed.headers, budget),
package/js/sdk/causes.ts CHANGED
@@ -927,6 +927,8 @@ export const resetCausesForTest = () => {
927
927
  taskCauses = null;
928
928
  renderStarted = false;
929
929
  latestTouch = null;
930
+ latestAction = 0;
931
+ newestActionT = -Infinity;
930
932
  viewCause.clear();
931
933
  socketCause.clear();
932
934
  };
package/js/sdk/core.ts CHANGED
@@ -970,6 +970,16 @@ const cutStream = () => {
970
970
  perfLog(`stream ${JSON.stringify({ name: segment.name, commits: segment.commits, bytes: segment.json.length, fibersDropped: segment.fibersDropped, written: path !== null })}`);
971
971
  };
972
972
 
973
+ // What a test file leaves in the process-wide run: the open stream segment and the files listed for it, the answers of
974
+ // GC commands, the GC that ran since the last flush, and the close sample the next window would take as its open one.
975
+ export const resetRunStateForTest = () => {
976
+ cutStream();
977
+ pendingStreamFiles.length = 0;
978
+ commandAnswers.clear();
979
+ gcRanSinceFlush = false;
980
+ lastClose = null;
981
+ };
982
+
973
983
  const endBackground = (reason: string) => {
974
984
  for (const record of [...unclaimed.splice(0), ...recentCommits]) {
975
985
  routeToBackground(record);
package/js/sdk/fiber.ts CHANGED
@@ -450,6 +450,10 @@ const authoredId = (value: unknown): string | null => {
450
450
  return s !== null && !GENERATED_ID.test(s) ? s : null;
451
451
  };
452
452
 
453
+ // EXPO_PUBLIC_KEEPSNAPPY_TAP_TEXT=0 keeps what a screen shows out of tap names: only testID, accessibilityLabel and
454
+ // component names are used. Read per call so the bundle-time value is inlined at the read.
455
+ const tapTextAllowed = () => (process.env.EXPO_PUBLIC_KEEPSNAPPY_TAP_TEXT) !== '0';
456
+
453
457
  const textUnder = (root: Fiber): string | null => {
454
458
  const parts: string[] = [];
455
459
  const stack: Fiber[] = [root];
@@ -503,7 +507,7 @@ export interface TargetDescription {
503
507
  place: TargetPlace;
504
508
  }
505
509
 
506
- // testID -> accessibilityLabel -> text -> nearest named component -> tag.
510
+ // testID -> accessibilityLabel -> text (unless switched off) -> nearest named component -> tag.
507
511
  // testID/label are searched from the touched view up to the first named
508
512
  // component above the actionable (onPress/responder) fiber.
509
513
  const legacyTarget = (tag: number): Omit<TargetDescription, 'legacyName' | 'legacySource' | 'place'> => {
@@ -562,7 +566,7 @@ const legacyTarget = (tag: number): Omit<TargetDescription, 'legacyName' | 'lega
562
566
  result.name = result.accessibilityLabel;
563
567
  result.source = 'accessibilityLabel';
564
568
  } else {
565
- const text = textUnder(actionable ?? host);
569
+ const text = tapTextAllowed() ? textUnder(actionable ?? host) : null;
566
570
  if (text) {
567
571
  result.name = text;
568
572
  result.source = 'text';
package/js/sdk/screens.ts CHANGED
@@ -618,6 +618,10 @@ const onWindowsEnded = (reason: string) => {
618
618
  pendingLink = null;
619
619
  };
620
620
 
621
+ export const resetCurrentScreenForTest = () => {
622
+ current = null;
623
+ };
624
+
621
625
  export const resetLaunchForTest = () => {
622
626
  launchVisitOpened = false;
623
627
  windowsEndedSinceStart = false;
package/metro.js CHANGED
@@ -172,7 +172,17 @@ const resolveEntry = projectRoot => {
172
172
  return base + ext;
173
173
  }
174
174
  }
175
- return null;
175
+ // A package path such as Expo Router's `expo-router/entry`, which Metro resolves through node_modules. require.resolve
176
+ // answers a real path; the transformer compares it with Metro's file names, which keep the project root as given.
177
+ try {
178
+ const resolved = require.resolve(main, { paths: [projectRoot] });
179
+ const realRoot = fs.realpathSync(projectRoot);
180
+ return resolved.startsWith(realRoot + path.sep)
181
+ ? path.join(projectRoot, path.relative(realRoot, resolved))
182
+ : resolved;
183
+ } catch {
184
+ return null;
185
+ }
176
186
  };
177
187
 
178
188
  function withKeepSnappy(input, { clientPackages = [] } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepsnappy/react-native",
3
- "version": "0.1.0-alpha.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "KeepSnappy in-app SDK for React Native / Expo: zero-code measurement windows, React commit hook, native signposts and log file",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {