@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 +1 -0
- package/android/src/main/java/expo/modules/keepsnappy/KeepSnappyModule.kt +80 -13
- package/ios/KeepSnappyGzip.swift +91 -0
- package/ios/KeepSnappyModule.swift +74 -69
- package/js/sdk/artifacts.ts +23 -5
- package/js/sdk/causes.ts +2 -0
- package/js/sdk/core.ts +10 -0
- package/js/sdk/fiber.ts +6 -2
- package/js/sdk/screens.ts +4 -0
- package/metro.js +11 -1
- package/package.json +1 -1
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
304
|
+
connection.set(conn)
|
|
266
305
|
conn.requestMethod = "PUT"
|
|
267
306
|
conn.doOutput = true
|
|
268
|
-
conn.connectTimeout =
|
|
269
|
-
conn.readTimeout =
|
|
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 ->
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
233
|
-
promise.resolve(
|
|
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
|
|
package/js/sdk/artifacts.ts
CHANGED
|
@@ -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
|
-
//
|
|
193
|
-
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
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
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
|
-
|
|
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.
|
|
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": {
|