@keepsnappy/react-native 0.1.0-alpha.16 → 0.1.0-alpha.18
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/babel-transformer.js +11 -4
- package/js/component-stamps.js +22 -7
- package/js/sdk/artifacts.ts +23 -5
- package/js/sdk/causes.ts +2 -0
- package/js/sdk/core.ts +47 -8
- package/js/sdk/fiber.ts +83 -9
- package/js/sdk/interactions.ts +19 -8
- package/js/sdk/push.ts +67 -27
- package/js/sdk/sampler.ts +1 -1
- package/js/sdk/screens.ts +23 -13
- package/js/sdk/stream.ts +6 -0
- 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/babel-transformer.js
CHANGED
|
@@ -68,20 +68,27 @@ module.exports = {
|
|
|
68
68
|
: args.src;
|
|
69
69
|
args = { ...args, src: inlineEnv(src) };
|
|
70
70
|
}
|
|
71
|
+
const root = args.options?.projectRoot ?? projectRoot ?? process.cwd();
|
|
72
|
+
// A client file built without its stamps keys its components to an ancestor's: never silently.
|
|
73
|
+
const skipped = error => console.warn(`KeepSnappy: component stamps skipped for ${path.relative(root, file)}: ${String(error)}`);
|
|
71
74
|
let stamps;
|
|
72
75
|
try {
|
|
73
76
|
stamps = require('./component-stamps.js').instrumentation(
|
|
74
|
-
original.src, file,
|
|
77
|
+
original.src, file, root, clientPackages, file === entryFile ? 1 : 0,
|
|
75
78
|
);
|
|
76
|
-
} catch { return upstream.transform(args); }
|
|
79
|
+
} catch (error) { skipped(error); return upstream.transform(args); }
|
|
77
80
|
if (!stamps) return upstream.transform(args);
|
|
78
81
|
const complete = result => { stamps.complete(); return result; };
|
|
79
|
-
const passthrough =
|
|
82
|
+
const passthrough = error => {
|
|
83
|
+
skipped(error);
|
|
84
|
+
stamps.complete(true);
|
|
85
|
+
return upstream.transform(args);
|
|
86
|
+
};
|
|
80
87
|
try {
|
|
81
88
|
const result = upstream.transform({ ...args, plugins: [stamps.plugin, ...(args.plugins ?? [])] });
|
|
82
89
|
return result && typeof result.then === 'function'
|
|
83
90
|
? result.then(complete, passthrough) : complete(result);
|
|
84
|
-
} catch { return passthrough(); }
|
|
91
|
+
} catch (error) { return passthrough(error); }
|
|
85
92
|
},
|
|
86
93
|
getCacheKey(...args) {
|
|
87
94
|
const base =
|
package/js/component-stamps.js
CHANGED
|
@@ -119,6 +119,15 @@ const anonymousRender = (node, t) => {
|
|
|
119
119
|
&& !node.id && !node.async && !node.generator && returnsJSX(node.body, t);
|
|
120
120
|
};
|
|
121
121
|
|
|
122
|
+
// A plugin that ran first (React Compiler listed in the app's babel.config.js) may put back regenerated nodes without
|
|
123
|
+
// `loc`; the line then comes from the declaration in the source.
|
|
124
|
+
const declarationLine = (src, id, statement, lineOffset) => {
|
|
125
|
+
const line = id.loc?.start?.line ?? statement.loc?.start?.line;
|
|
126
|
+
if (typeof line === 'number') return Math.max(1, line - lineOffset);
|
|
127
|
+
const match = new RegExp(`^[ \\t]*(?:export[ \\t]+(?:default[ \\t]+)?)?(?:async[ \\t]+)?(?:function\\*?|class|const)[ \\t]+${id.name}\\b`, 'm').exec(src);
|
|
128
|
+
return match ? src.slice(0, match.index).split('\n').length : 1;
|
|
129
|
+
};
|
|
130
|
+
|
|
122
131
|
function instrumentation(src, filename, projectRoot, clientPackages, lineOffset = 0) {
|
|
123
132
|
const client = clientFile(filename, projectRoot, clientPackages);
|
|
124
133
|
if (!client) return null;
|
|
@@ -166,13 +175,19 @@ function instrumentation(src, filename, projectRoot, clientPackages, lineOffset
|
|
|
166
175
|
const stamps = [];
|
|
167
176
|
for (const id of found) {
|
|
168
177
|
const componentName = id === defaultId ? 'default' : id.name;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
t.binaryExpression('
|
|
175
|
-
|
|
178
|
+
// One declaration that cannot be stamped must not cost the file its other stamps.
|
|
179
|
+
try {
|
|
180
|
+
const key = componentKey(componentName, client.file);
|
|
181
|
+
const line = declarationLine(src, id, statement.node, lineOffset);
|
|
182
|
+
const name = () => t.identifier(id.name);
|
|
183
|
+
const typeIs = value => t.binaryExpression('===', t.unaryExpression('typeof', name()), t.stringLiteral(value));
|
|
184
|
+
stamps.push(t.ifStatement(t.logicalExpression('||', typeIs('function'), t.logicalExpression('&&',
|
|
185
|
+
t.binaryExpression('!==', name(), t.nullLiteral()), typeIs('object'))),
|
|
186
|
+
t.expressionStatement(t.assignmentExpression('=', t.memberExpression(name(), t.identifier('__ks')), t.stringLiteral(key)))));
|
|
187
|
+
components.push({ key, name: componentName, file: client.file, line });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.warn(`KeepSnappy: could not stamp ${componentName} in ${client.file}: ${String(error)}`);
|
|
190
|
+
}
|
|
176
191
|
}
|
|
177
192
|
if (stamps.length) statement.insertAfter(stamps);
|
|
178
193
|
}
|
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
|
@@ -473,8 +473,10 @@ interface OpenSampleState {
|
|
|
473
473
|
// - so does a window that starts within OPEN_SAMPLE_TOLERANCE_MS of the close sample (restarted key);
|
|
474
474
|
// - a window detected long after its t0 (a screen window opened by a press, seen after the new screen rendered) gets
|
|
475
475
|
// none: its bound is the press's open sample;
|
|
476
|
-
// - else the sampler is asked to sample on its own thread.
|
|
477
|
-
|
|
476
|
+
// - else the sampler is asked to sample on its own thread. A press always is, however late its touch reached JS: with
|
|
477
|
+
// no window before it (after a GC command, the background or the flush) the sample before the touch can be seconds
|
|
478
|
+
// old, so its own sample, a negative lead past t0, is the closer bound.
|
|
479
|
+
const openSampleFor = (id: number, t0: number, press: boolean): OpenSampleState => {
|
|
478
480
|
const none = { openRecord: null, openPending: null };
|
|
479
481
|
if (samplerState.mode === 'off') {
|
|
480
482
|
return none;
|
|
@@ -487,7 +489,7 @@ const openSampleFor = (id: number, t0: number): OpenSampleState => {
|
|
|
487
489
|
) {
|
|
488
490
|
return { openRecord: lastClose.record, openPending: null };
|
|
489
491
|
}
|
|
490
|
-
if (now() - samplerState.offsetMs - t0Native > OPEN_SAMPLE_TOLERANCE_MS) {
|
|
492
|
+
if (!press && now() - samplerState.offsetMs - t0Native > OPEN_SAMPLE_TOLERANCE_MS) {
|
|
491
493
|
return none;
|
|
492
494
|
}
|
|
493
495
|
const reason = `window-start:${id}`;
|
|
@@ -938,7 +940,14 @@ const routeToBackground = (record: CommitRecord) => {
|
|
|
938
940
|
|
|
939
941
|
// Segments written and not yet listed for upload (listed at the next flush).
|
|
940
942
|
const pendingStreamFiles: WrittenFile[] = [];
|
|
941
|
-
//
|
|
943
|
+
// What the run lost since the last flush, which reports it. Commit-stream segments that failed to write are missing data
|
|
944
|
+
// (the backend splits work by cause from them). Kept React exports dropped at the run cap or not written, and stream fibers
|
|
945
|
+
// past a segment's cap, leave every measured value complete: counted notes only.
|
|
946
|
+
const runLosses = { stream: 0, exportsCapped: 0, exportsUnwritten: 0, streamFibersDropped: 0 };
|
|
947
|
+
export const setStreamFiberCapForTest = (fibers: number) => commitStream.setMaxFibers(fibers);
|
|
948
|
+
// A segment is cut at this many commits even between flushes, so a long stretch outside a run stays bounded. In a run
|
|
949
|
+
// the cut waits while a window is open (no file I/O inside a measured window, REQ-MEAS-23): every window ends at the
|
|
950
|
+
// flush, which cuts it, and a segment past its fiber cap keeps only the commits' facts.
|
|
942
951
|
const MAX_SEGMENT_COMMITS = 5000;
|
|
943
952
|
|
|
944
953
|
const cutStream = () => {
|
|
@@ -954,10 +963,23 @@ const cutStream = () => {
|
|
|
954
963
|
}
|
|
955
964
|
if (path) {
|
|
956
965
|
pendingStreamFiles.push({ name: segment.name, windows: [], bytes: segment.json.length });
|
|
966
|
+
} else if (inRun()) {
|
|
967
|
+
runLosses.stream++;
|
|
957
968
|
}
|
|
969
|
+
if (inRun()) runLosses.streamFibersDropped += segment.fibersDropped;
|
|
958
970
|
perfLog(`stream ${JSON.stringify({ name: segment.name, commits: segment.commits, bytes: segment.json.length, fibersDropped: segment.fibersDropped, written: path !== null })}`);
|
|
959
971
|
};
|
|
960
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
|
+
|
|
961
983
|
const endBackground = (reason: string) => {
|
|
962
984
|
for (const record of [...unclaimed.splice(0), ...recentCommits]) {
|
|
963
985
|
routeToBackground(record);
|
|
@@ -998,7 +1020,7 @@ const start = (name: string, options: StartOptions = {}) => {
|
|
|
998
1020
|
const statsStart = readHermesStats();
|
|
999
1021
|
const id = nextWindowId++;
|
|
1000
1022
|
const startT = options.at ?? now();
|
|
1001
|
-
const openSample = openSampleFor(id, startT);
|
|
1023
|
+
const openSample = openSampleFor(id, startT, options.kind === 'press');
|
|
1002
1024
|
const window: MarkerWindow = {
|
|
1003
1025
|
id,
|
|
1004
1026
|
key,
|
|
@@ -1301,7 +1323,7 @@ const onCommitFiberRoot = (rendererId: number, root: FiberRoot) => {
|
|
|
1301
1323
|
// Work no open window judges is the background of its source: pushes, and loops of earlier actions or of the launch.
|
|
1302
1324
|
record.background = record.judged ? null : backgroundSource(causes);
|
|
1303
1325
|
commitStream.noteCommit({ seq, t: t0, renderMs, causes, fibers: exported?.fibers ?? null, updaters: profile?.updaters ?? null });
|
|
1304
|
-
if (commitStream.size() >= MAX_SEGMENT_COMMITS) {
|
|
1326
|
+
if (commitStream.size() >= MAX_SEGMENT_COMMITS && !(inRun() && openWindows.size > 0)) {
|
|
1305
1327
|
cutStream();
|
|
1306
1328
|
}
|
|
1307
1329
|
recordCommit(record);
|
|
@@ -1525,7 +1547,12 @@ interface PendingExport {
|
|
|
1525
1547
|
}
|
|
1526
1548
|
|
|
1527
1549
|
// React exports held until flush (inside a run), capped like a window's.
|
|
1528
|
-
|
|
1550
|
+
let maxPendingExportFibers = 400000;
|
|
1551
|
+
export const setPendingExportCapForTest = (fibers: number) => {
|
|
1552
|
+
const previous = maxPendingExportFibers;
|
|
1553
|
+
maxPendingExportFibers = fibers;
|
|
1554
|
+
return previous;
|
|
1555
|
+
};
|
|
1529
1556
|
const pendingExports: PendingExport[] = [];
|
|
1530
1557
|
let pendingExportFibers = 0;
|
|
1531
1558
|
|
|
@@ -1546,7 +1573,8 @@ const writeReactExport = (window: MarkerWindow) => {
|
|
|
1546
1573
|
truncated: window.exportTruncated,
|
|
1547
1574
|
};
|
|
1548
1575
|
if (inRun()) {
|
|
1549
|
-
if (pendingExportFibers + window.exportFibers >
|
|
1576
|
+
if (pendingExportFibers + window.exportFibers > maxPendingExportFibers) {
|
|
1577
|
+
if (settings.diag || budgetBreaks.has(window.id)) runLosses.exportsCapped++;
|
|
1550
1578
|
return { ...base, deferred: true, dropped: true };
|
|
1551
1579
|
}
|
|
1552
1580
|
pendingExports.push({
|
|
@@ -1623,6 +1651,7 @@ const writeDeferredFiles = (): ArtifactEntry[] => {
|
|
|
1623
1651
|
} catch (error) {
|
|
1624
1652
|
result = { error: String(error) };
|
|
1625
1653
|
}
|
|
1654
|
+
if (!result.path && (settings.diag || budgetBreaks.has(pending.window))) runLosses.exportsUnwritten++;
|
|
1626
1655
|
perfLog(
|
|
1627
1656
|
`rexport ${JSON.stringify({ name: pending.name, commits: pending.commits.length, fibers: pending.fibers, ...result })}`,
|
|
1628
1657
|
);
|
|
@@ -2127,6 +2156,16 @@ const flushOnce = async (ack: string | undefined) => {
|
|
|
2127
2156
|
} catch (error) {
|
|
2128
2157
|
perfLog(`artifacts failed ${String(error)}`);
|
|
2129
2158
|
}
|
|
2159
|
+
if (runToken !== null) {
|
|
2160
|
+
if (runLosses.stream > 0) {
|
|
2161
|
+
if (status === 'ok') status = 'error';
|
|
2162
|
+
detail += ` lost stream=${runLosses.stream}`;
|
|
2163
|
+
}
|
|
2164
|
+
if (runLosses.exportsCapped > 0) detail += ` exports capped=${runLosses.exportsCapped}`;
|
|
2165
|
+
if (runLosses.exportsUnwritten > 0) detail += ` exports unwritten=${runLosses.exportsUnwritten}`;
|
|
2166
|
+
if (runLosses.streamFibersDropped > 0) detail += ` stream fibersDropped=${runLosses.streamFibersDropped}`;
|
|
2167
|
+
}
|
|
2168
|
+
Object.assign(runLosses, { stream: 0, exportsCapped: 0, exportsUnwritten: 0, streamFibersDropped: 0 });
|
|
2130
2169
|
try {
|
|
2131
2170
|
await sendEarlierArtifacts(deadline);
|
|
2132
2171
|
} catch (error) {
|
package/js/sdk/fiber.ts
CHANGED
|
@@ -230,9 +230,11 @@ export const noteMounted = (fiber: Fiber) => {
|
|
|
230
230
|
const props = propsOf(fiber);
|
|
231
231
|
if (props && props.route !== undefined) {
|
|
232
232
|
noteRoute(fiber, props);
|
|
233
|
+
// React Navigation core's SceneView: the route's `screen` config and `clearOptions`. Expo Router 58's fork no
|
|
234
|
+
// longer passes `getState`, so that prop does not mark it. Headers and layouts get `route` without these.
|
|
233
235
|
const route = props.route as { key?: unknown } | null;
|
|
234
236
|
if (typeof route?.key === 'string' &&
|
|
235
|
-
typeof props.
|
|
237
|
+
props.screen !== null && typeof props.screen === 'object' &&
|
|
236
238
|
typeof props.clearOptions === 'function') {
|
|
237
239
|
scenes.set(route.key, fiber);
|
|
238
240
|
}
|
|
@@ -448,6 +450,10 @@ const authoredId = (value: unknown): string | null => {
|
|
|
448
450
|
return s !== null && !GENERATED_ID.test(s) ? s : null;
|
|
449
451
|
};
|
|
450
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
|
+
|
|
451
457
|
const textUnder = (root: Fiber): string | null => {
|
|
452
458
|
const parts: string[] = [];
|
|
453
459
|
const stack: Fiber[] = [root];
|
|
@@ -501,7 +507,7 @@ export interface TargetDescription {
|
|
|
501
507
|
place: TargetPlace;
|
|
502
508
|
}
|
|
503
509
|
|
|
504
|
-
// testID -> accessibilityLabel -> text -> nearest named component -> tag.
|
|
510
|
+
// testID -> accessibilityLabel -> text (unless switched off) -> nearest named component -> tag.
|
|
505
511
|
// testID/label are searched from the touched view up to the first named
|
|
506
512
|
// component above the actionable (onPress/responder) fiber.
|
|
507
513
|
const legacyTarget = (tag: number): Omit<TargetDescription, 'legacyName' | 'legacySource' | 'place'> => {
|
|
@@ -560,7 +566,7 @@ const legacyTarget = (tag: number): Omit<TargetDescription, 'legacyName' | 'lega
|
|
|
560
566
|
result.name = result.accessibilityLabel;
|
|
561
567
|
result.source = 'accessibilityLabel';
|
|
562
568
|
} else {
|
|
563
|
-
const text = textUnder(actionable ?? host);
|
|
569
|
+
const text = tapTextAllowed() ? textUnder(actionable ?? host) : null;
|
|
564
570
|
if (text) {
|
|
565
571
|
result.name = text;
|
|
566
572
|
result.source = 'text';
|
|
@@ -582,9 +588,11 @@ export interface TargetPlace {
|
|
|
582
588
|
manySiblings?: number;
|
|
583
589
|
}
|
|
584
590
|
|
|
591
|
+
// A scroll view's own responder handlers (RN sets them on RCTScrollView) serve the scroll gesture, so a scroll view is
|
|
592
|
+
// never the tap's region: the pressables inside it are.
|
|
585
593
|
const actionableFiber = (f: Fiber) => {
|
|
586
594
|
const props = propsOf(f);
|
|
587
|
-
return props !== null && isActionable(props);
|
|
595
|
+
return props !== null && isActionable(props) && !isScrollHost(f);
|
|
588
596
|
};
|
|
589
597
|
const isListCell = (f: Fiber) => {
|
|
590
598
|
if (!isComponentTag(f.tag) || clientKeyOf(f) !== null) return false;
|
|
@@ -594,6 +602,69 @@ const isListCell = (f: Fiber) => {
|
|
|
594
602
|
(typeof p.getRenderedItem === 'function' && 'itemKey' in p));
|
|
595
603
|
};
|
|
596
604
|
|
|
605
|
+
// React elements a component received in its props (children or any other prop), arrays included, by their props
|
|
606
|
+
// object: the fiber React creates from an element keeps that object as its props.
|
|
607
|
+
// Props objects do not change after their render, so the result is kept per object.
|
|
608
|
+
const MAX_PASSED_ELEMENTS = 64;
|
|
609
|
+
const NO_ELEMENTS: ReadonlySet<unknown> = new Set();
|
|
610
|
+
const passedByProps = new WeakMap<object, ReadonlySet<unknown>>();
|
|
611
|
+
const passedElementProps = (props: Props): ReadonlySet<unknown> => {
|
|
612
|
+
const known = passedByProps.get(props);
|
|
613
|
+
if (known) return known;
|
|
614
|
+
const found = new Set<unknown>();
|
|
615
|
+
try {
|
|
616
|
+
const stack: unknown[] = Object.values(props);
|
|
617
|
+
let seen = 0;
|
|
618
|
+
while (stack.length > 0 && seen++ < MAX_PASSED_ELEMENTS) {
|
|
619
|
+
const value = stack.pop();
|
|
620
|
+
if (Array.isArray(value)) {
|
|
621
|
+
stack.push(...value.slice(0, MAX_PASSED_ELEMENTS));
|
|
622
|
+
} else if (value !== null && typeof value === 'object' && '$$typeof' in value && 'props' in value) {
|
|
623
|
+
found.add((value as { props: unknown }).props);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
} catch {
|
|
627
|
+
// A prop that throws on read (a revoked Proxy) carries no element we can match.
|
|
628
|
+
}
|
|
629
|
+
const result = found.size === 0 ? NO_ELEMENTS : found;
|
|
630
|
+
passedByProps.set(props, result);
|
|
631
|
+
return result;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// A client component between the target and the component that placed it passes the press through: it received a
|
|
635
|
+
// press handler from its owner (a button wrapper forwarding `onPress`), or it renders an element it received in its
|
|
636
|
+
// props around the target (a layout or animation wrapper given `children`). Neither tells two buttons apart.
|
|
637
|
+
const passesThrough = (f: Fiber, path: readonly Fiber[]) => {
|
|
638
|
+
const props = propsOf(f);
|
|
639
|
+
if (!props) return false;
|
|
640
|
+
if (isActionable(props)) return true;
|
|
641
|
+
const passed = passedElementProps(props);
|
|
642
|
+
if (passed.size === 0) return false;
|
|
643
|
+
return path.some(p => passed.has(p.memoizedProps) || (p.alternate !== null && passed.has(p.alternate.memoizedProps)));
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// The parent of a place: the first client component above the target that does not pass the press through, else the
|
|
647
|
+
// nearest client component above it.
|
|
648
|
+
const placeParentOf = (target: Fiber): Fiber | null => {
|
|
649
|
+
let nearest: Fiber | null = null;
|
|
650
|
+
const path: Fiber[] = [target];
|
|
651
|
+
for (let f = target.return, n = 0; f && n < MAX_UP * 2; f = f.return, n++) {
|
|
652
|
+
if (clientFiber(f)) {
|
|
653
|
+
nearest ??= f;
|
|
654
|
+
if (!passesThrough(f, path)) return f;
|
|
655
|
+
}
|
|
656
|
+
path.push(f);
|
|
657
|
+
}
|
|
658
|
+
return nearest;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// A client component that can pass a press through for something below it; any other client component is the
|
|
662
|
+
// parent of every target under it.
|
|
663
|
+
const mayPassThrough = (f: Fiber) => {
|
|
664
|
+
const props = propsOf(f);
|
|
665
|
+
return props !== null && (isActionable(props) || passedElementProps(props).size > 0);
|
|
666
|
+
};
|
|
667
|
+
|
|
597
668
|
const occurrenceOf = (
|
|
598
669
|
parent: Fiber,
|
|
599
670
|
target: Fiber,
|
|
@@ -611,13 +682,19 @@ const occurrenceOf = (
|
|
|
611
682
|
const stack: { f: Fiber; inside: boolean }[] = root.child ? [{ f: root.child, inside: false }] : [];
|
|
612
683
|
let count = 0;
|
|
613
684
|
let index = 0;
|
|
685
|
+
const isParent = (f: Fiber | null) => f !== null && (f === root || f === root.alternate);
|
|
614
686
|
while (stack.length && visited < 1000) {
|
|
615
687
|
const { f, inside } = stack.pop()!;
|
|
616
688
|
visited++;
|
|
617
689
|
if (f.sibling) stack.push({ f: f.sibling, inside });
|
|
618
690
|
if (isListCell(f)) continue;
|
|
619
691
|
const client = clientFiber(f);
|
|
620
|
-
if (client &&
|
|
692
|
+
if (client && inside) continue;
|
|
693
|
+
if (client && clientKeyOf(f) !== key) {
|
|
694
|
+
if (mayPassThrough(f) && f.child) stack.push({ f: f.child, inside: false });
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (client && !isParent(placeParentOf(f))) continue;
|
|
621
698
|
const inTarget = inside || client;
|
|
622
699
|
if (inTarget && ((scroll && client) || (!scroll && actionableFiber(f)))) {
|
|
623
700
|
count++;
|
|
@@ -649,10 +726,7 @@ const placeOf = (host: Fiber | null, scroll: boolean, screen: ScreenKeyResolutio
|
|
|
649
726
|
if (!scroll && actionableFiber(f)) region = f;
|
|
650
727
|
if (clientFiber(f)) { target = f; break; }
|
|
651
728
|
}
|
|
652
|
-
|
|
653
|
-
for (let f = target?.return ?? null, n = 0; f && n < MAX_UP * 2; f = f.return, n++) {
|
|
654
|
-
if (clientFiber(f)) { parent = f; break; }
|
|
655
|
-
}
|
|
729
|
+
const parent = target ? placeParentOf(target) : null;
|
|
656
730
|
return {
|
|
657
731
|
screen: screen.screen,
|
|
658
732
|
parent: parent ? clientKeyOf(parent) : null,
|
package/js/sdk/interactions.ts
CHANGED
|
@@ -270,6 +270,19 @@ const finishPress = (press: ActivePress, reason: string, at?: number) => {
|
|
|
270
270
|
}
|
|
271
271
|
};
|
|
272
272
|
|
|
273
|
+
// A drag is named after its scroll view (REQ-MEAS-10). Returns whether the gesture is a drag.
|
|
274
|
+
const classify = (press: ActivePress) => {
|
|
275
|
+
const drag = press.maxMovePt >= DRAG_SLOP_PT || press.scrolled || press.takenByScroll;
|
|
276
|
+
press.kind = drag ? 'drag' : 'press';
|
|
277
|
+
if (drag) {
|
|
278
|
+
const axis = dragAxis(press);
|
|
279
|
+
const candidate = selectScrollHost(press.scrollHosts, axis);
|
|
280
|
+
press.container = candidate ? nameScrollContainer(candidate, press.screen) : null;
|
|
281
|
+
markers.rename(press.key, dragName(press), `drag:${press.container?.legacyName ?? press.target.legacyName}`);
|
|
282
|
+
}
|
|
283
|
+
return drag;
|
|
284
|
+
};
|
|
285
|
+
|
|
273
286
|
const onTouchStart = (ne: Record<string, unknown>) => {
|
|
274
287
|
const touches = ne.touches;
|
|
275
288
|
if (Array.isArray(touches) && touches.length > 1) {
|
|
@@ -326,6 +339,11 @@ const onTouchStart = (ne: Record<string, unknown>) => {
|
|
|
326
339
|
legacyName: `press:${target.legacyName}`,
|
|
327
340
|
at: touchStartT,
|
|
328
341
|
details: details(press),
|
|
342
|
+
// A touch whose touchEnd never reached JS (its view unmounted, the app left the foreground) is classified by how
|
|
343
|
+
// far it moved, so a swipe stays a drag on its list.
|
|
344
|
+
beforeEnd: () => {
|
|
345
|
+
if (press.kind === null) classify(press);
|
|
346
|
+
},
|
|
329
347
|
});
|
|
330
348
|
};
|
|
331
349
|
|
|
@@ -420,14 +438,7 @@ const onTouchEnd = (ne: Record<string, unknown>, cancelled: boolean) => {
|
|
|
420
438
|
// A scroll view cancels the touch when it takes the gesture. On a list still moving from a fling it does so at once,
|
|
421
439
|
// before the finger passes the slop, so the move alone would call a fling a press on the row under the finger.
|
|
422
440
|
press.takenByScroll = cancelled && press.scrollHosts.length > 0;
|
|
423
|
-
|
|
424
|
-
press.kind = drag ? 'drag' : 'press';
|
|
425
|
-
if (drag) {
|
|
426
|
-
const axis = dragAxis(press);
|
|
427
|
-
const candidate = selectScrollHost(press.scrollHosts, axis);
|
|
428
|
-
press.container = candidate ? nameScrollContainer(candidate, press.screen) : null;
|
|
429
|
-
const name = dragName(press);
|
|
430
|
-
markers.rename(press.key, name, `drag:${press.container?.legacyName ?? press.target.legacyName}`);
|
|
441
|
+
if (classify(press)) {
|
|
431
442
|
return;
|
|
432
443
|
}
|
|
433
444
|
if (cancelled) {
|
package/js/sdk/push.ts
CHANGED
|
@@ -115,6 +115,8 @@ const serverClock = async (res: { json?: () => Promise<unknown> }, bound: Promis
|
|
|
115
115
|
interface Item {
|
|
116
116
|
line: string;
|
|
117
117
|
bytes: number;
|
|
118
|
+
// The line's size inside the request body: a JSON string (escaped) plus its comma.
|
|
119
|
+
wire: number;
|
|
118
120
|
token: string | null;
|
|
119
121
|
// The process that recorded the line (its bootId) and its sequence there.
|
|
120
122
|
origin: string;
|
|
@@ -127,7 +129,7 @@ interface Batch {
|
|
|
127
129
|
id: string;
|
|
128
130
|
items: Item[];
|
|
129
131
|
token: string | null;
|
|
130
|
-
|
|
132
|
+
wire: number;
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
interface SpoolAdd {
|
|
@@ -211,6 +213,8 @@ export const pendingFromSpool = (content: string | null): PendingLine[] =>
|
|
|
211
213
|
readSpool(content).pending;
|
|
212
214
|
|
|
213
215
|
// Retrying cannot fix these; the batch is dropped instead of blocking the queue.
|
|
216
|
+
const wireBytes = (line: string) => utf8Length(JSON.stringify(line)) + 1;
|
|
217
|
+
|
|
214
218
|
const isPermanent = (status: number) =>
|
|
215
219
|
status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
216
220
|
|
|
@@ -261,6 +265,11 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
261
265
|
let journalRecords = 0;
|
|
262
266
|
// Lines dropped before delivery, by this process and (from the note) earlier ones.
|
|
263
267
|
let undelivered = 0;
|
|
268
|
+
// Lines of a run dropped before delivery (queue cap, line over the batch limit), by run token, until its next flush.
|
|
269
|
+
const droppedOfRun = new Map<string, number>();
|
|
270
|
+
const noteDropped = (token: string | null) => {
|
|
271
|
+
if (token !== null) droppedOfRun.set(token, (droppedOfRun.get(token) ?? 0) + 1);
|
|
272
|
+
};
|
|
264
273
|
// Round trips not reported yet, each with the run token it was measured under.
|
|
265
274
|
let clockPending: { token: string; sample: ClockSample }[] = [];
|
|
266
275
|
const clockOf = (token: string | null) =>
|
|
@@ -424,6 +433,7 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
424
433
|
}
|
|
425
434
|
}
|
|
426
435
|
undelivered += dropped.length;
|
|
436
|
+
for (const item of dropped) noteDropped(item.token);
|
|
427
437
|
return dropped;
|
|
428
438
|
};
|
|
429
439
|
|
|
@@ -432,12 +442,14 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
432
442
|
if (bytes > maxBatchBytes) {
|
|
433
443
|
stats.dropped++;
|
|
434
444
|
undelivered++;
|
|
445
|
+
noteDropped(deps.getRunToken());
|
|
435
446
|
stats.lastError = `line of ${bytes} bytes exceeds batch limit`;
|
|
436
447
|
return;
|
|
437
448
|
}
|
|
438
449
|
const item: Item = {
|
|
439
450
|
line,
|
|
440
451
|
bytes,
|
|
452
|
+
wire: wireBytes(line),
|
|
441
453
|
token: deps.getRunToken(),
|
|
442
454
|
origin: bootId,
|
|
443
455
|
seq: nextSeq++,
|
|
@@ -486,11 +498,13 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
486
498
|
if (bytes > maxBatchBytes) {
|
|
487
499
|
stats.resumeDropped++;
|
|
488
500
|
undelivered++;
|
|
501
|
+
noteDropped(record.t);
|
|
489
502
|
continue;
|
|
490
503
|
}
|
|
491
504
|
earlier.push({
|
|
492
505
|
line: record.l,
|
|
493
506
|
bytes,
|
|
507
|
+
wire: wireBytes(record.l),
|
|
494
508
|
token: record.t,
|
|
495
509
|
origin: record.o,
|
|
496
510
|
seq: record.s,
|
|
@@ -528,16 +542,16 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
528
542
|
}
|
|
529
543
|
const first = queue[0] as Item;
|
|
530
544
|
const items: Item[] = [];
|
|
531
|
-
let
|
|
545
|
+
let wire = 0;
|
|
532
546
|
if (first.batch !== undefined) {
|
|
533
547
|
// Already sent once (by a killed process): resend exactly that batch.
|
|
534
548
|
while (queue.length > 0 && queue[0]?.batch === first.batch) {
|
|
535
549
|
const next = queue.shift() as Item;
|
|
536
550
|
queuedBytes -= next.bytes;
|
|
537
551
|
items.push(next);
|
|
538
|
-
|
|
552
|
+
wire += next.wire;
|
|
539
553
|
}
|
|
540
|
-
return { id: first.batch, items, token: first.token,
|
|
554
|
+
return { id: first.batch, items, token: first.token, wire };
|
|
541
555
|
}
|
|
542
556
|
while (queue.length > 0 && items.length < maxBatchItems) {
|
|
543
557
|
const next = queue[0];
|
|
@@ -546,16 +560,16 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
546
560
|
next.batch !== undefined ||
|
|
547
561
|
next.token !== first.token ||
|
|
548
562
|
next.origin !== first.origin ||
|
|
549
|
-
(items.length > 0 &&
|
|
563
|
+
(items.length > 0 && wire + next.wire > maxBatchBytes)
|
|
550
564
|
) {
|
|
551
565
|
break;
|
|
552
566
|
}
|
|
553
567
|
queue.shift();
|
|
554
568
|
queuedBytes -= next.bytes;
|
|
555
569
|
items.push(next);
|
|
556
|
-
|
|
570
|
+
wire += next.wire;
|
|
557
571
|
}
|
|
558
|
-
return { id: '', items, token: first.token,
|
|
572
|
+
return { id: '', items, token: first.token, wire };
|
|
559
573
|
};
|
|
560
574
|
|
|
561
575
|
let inflight: Promise<void> | null = null;
|
|
@@ -601,29 +615,48 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
601
615
|
bound.clear();
|
|
602
616
|
}
|
|
603
617
|
}
|
|
618
|
+
const runToken = batch.token;
|
|
619
|
+
const reported = clockOf(runToken);
|
|
620
|
+
const sentDevice = device;
|
|
621
|
+
const bodyOf = (id: string) => JSON.stringify({
|
|
622
|
+
batch_id: id,
|
|
623
|
+
device: sentDevice,
|
|
624
|
+
items: batch.items.map(item => item.line),
|
|
625
|
+
...(runToken ? { run_token: runToken } : {}),
|
|
626
|
+
...(reported.length ? { clock: reported } : {}),
|
|
627
|
+
});
|
|
628
|
+
// Names exactly these lines (process, first and last sequence, count),
|
|
629
|
+
// so a different set of lines is always a different batch.
|
|
630
|
+
const idOf = () => {
|
|
631
|
+
const first = batch.items[0] as Item;
|
|
632
|
+
const last = batch.items[batch.items.length - 1] as Item;
|
|
633
|
+
return `${sentDevice.install_id}-${first.origin}-${first.seq}-${last.seq}-${batch.items.length}`;
|
|
634
|
+
};
|
|
635
|
+
let body = bodyOf(batch.id || idOf());
|
|
604
636
|
if (!batch.id) {
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
637
|
+
// The cap is on the body as sent (escaped lines, device, clock samples): lines over it go back to the queue
|
|
638
|
+
// front for the next batch. A lone line still goes; a line alone over the cap was dropped at enqueue.
|
|
639
|
+
for (let over = utf8Length(body) - maxBatchBytes; over > 0 && batch.items.length > 1; over = utf8Length(body) - maxBatchBytes) {
|
|
640
|
+
const back: Item[] = [];
|
|
641
|
+
for (let freed = 0; freed < over && batch.items.length > 1;) {
|
|
642
|
+
const item = batch.items.pop() as Item;
|
|
643
|
+
freed += item.wire;
|
|
644
|
+
back.unshift(item);
|
|
645
|
+
}
|
|
646
|
+
queue.unshift(...back);
|
|
647
|
+
for (const item of back) queuedBytes += item.bytes;
|
|
648
|
+
body = bodyOf(idOf());
|
|
649
|
+
}
|
|
650
|
+
// Journalled before the first send: a later process that resends after
|
|
608
651
|
// a kill sends the same lines under the same id, and the server's
|
|
609
652
|
// replay check can only ever drop lines it already has.
|
|
653
|
+
batch.id = idOf();
|
|
610
654
|
const first = batch.items[0] as Item;
|
|
611
|
-
const last = batch.items[batch.items.length - 1] as Item;
|
|
612
|
-
batch.id = `${device.install_id}-${first.origin}-${first.seq}-${last.seq}-${batch.items.length}`;
|
|
613
655
|
for (const item of batch.items) {
|
|
614
656
|
item.batch = batch.id;
|
|
615
657
|
}
|
|
616
658
|
journal({ b: batch.id, o: first.origin, s: batch.items.map(item => item.seq) });
|
|
617
659
|
}
|
|
618
|
-
const runToken = batch.token;
|
|
619
|
-
const reported = clockOf(runToken);
|
|
620
|
-
const body = JSON.stringify({
|
|
621
|
-
batch_id: batch.id,
|
|
622
|
-
device,
|
|
623
|
-
items: batch.items.map(item => item.line),
|
|
624
|
-
...(runToken ? { run_token: runToken } : {}),
|
|
625
|
-
...(reported.length ? { clock: reported } : {}),
|
|
626
|
-
});
|
|
627
660
|
const headers: Record<string, string> = {
|
|
628
661
|
'content-type': 'application/json',
|
|
629
662
|
'x-keepsnappy-key': deps.key,
|
|
@@ -804,16 +837,23 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
804
837
|
await sendClock(deadline);
|
|
805
838
|
}
|
|
806
839
|
const refused = refusedRunBatches - refusedBefore;
|
|
807
|
-
|
|
840
|
+
// A run that lost lines before they were sent is missing windows, whatever went up after.
|
|
841
|
+
const token = deps.getRunToken();
|
|
842
|
+
const lost = token === null ? 0 : (droppedOfRun.get(token) ?? 0);
|
|
843
|
+
if (token !== null) droppedOfRun.delete(token);
|
|
844
|
+
const ok = sentAll && refused === 0 && lost === 0;
|
|
845
|
+
const error = ok
|
|
846
|
+
? null
|
|
847
|
+
: !sentAll
|
|
848
|
+
? (stats.lastError ?? 'flush deadline')
|
|
849
|
+
: refused > 0
|
|
850
|
+
? `rejected ${lastRefusedStatus}: ${refused} ${refused === 1 ? 'batch' : 'batches'} of the run dropped`
|
|
851
|
+
: 'lines of the run dropped before sending';
|
|
808
852
|
return {
|
|
809
853
|
ok,
|
|
810
854
|
batches: stats.batchesSent - sentBefore,
|
|
811
855
|
lines: stats.linesSent - linesBefore,
|
|
812
|
-
error:
|
|
813
|
-
? null
|
|
814
|
-
: !sentAll
|
|
815
|
-
? (stats.lastError ?? 'flush deadline')
|
|
816
|
-
: `rejected ${lastRefusedStatus}: ${refused} ${refused === 1 ? 'batch' : 'batches'} of the run dropped`,
|
|
856
|
+
error: error !== null && lost > 0 ? `${error} dropped=${lost}` : error,
|
|
817
857
|
};
|
|
818
858
|
} finally {
|
|
819
859
|
flushLimit = null;
|
package/js/sdk/sampler.ts
CHANGED
|
@@ -132,7 +132,7 @@ export const estimateOffset = (before: number, native: number, after: number) =>
|
|
|
132
132
|
// late after its t0 a window can start and still take one. It gates only screen windows (a screen window opened by a
|
|
133
133
|
// press is detected after the new screen rendered and takes the press's open sample, taken the touch delivery delay
|
|
134
134
|
// past the press's t0) and the reuse of a close sample for a restarted key. Press windows do not depend on it: they
|
|
135
|
-
// take the previous window's close sample by contiguity. Evidence for 50: idle lab event timing of 4.9-19.1 ms
|
|
135
|
+
// take the previous window's close sample by contiguity, else a sample of their own however late it is. Evidence for 50: idle lab event timing of 4.9-19.1 ms
|
|
136
136
|
// (packages/derive/test/fixtures/p37-*.log); no loaded-device data. Measure the touchstart delivery delay p99 per
|
|
137
137
|
// device from farm data (docs/log/followups.md).
|
|
138
138
|
export const OPEN_SAMPLE_TOLERANCE_MS = 50;
|
package/js/sdk/screens.ts
CHANGED
|
@@ -66,7 +66,8 @@ interface Visit {
|
|
|
66
66
|
visit: number;
|
|
67
67
|
screenKey: string | null;
|
|
68
68
|
legacyName: string;
|
|
69
|
-
|
|
69
|
+
// The screen component is not known yet: its scene has not mounted, or it is suspended.
|
|
70
|
+
pending: boolean;
|
|
70
71
|
resolution: ScreenRouteResolution;
|
|
71
72
|
lazyBoundary: Fiber | null;
|
|
72
73
|
t0: number;
|
|
@@ -254,7 +255,7 @@ const endVisit = (reason: string, at?: number) => {
|
|
|
254
255
|
if (!visit || visit.endedAt !== null) {
|
|
255
256
|
return;
|
|
256
257
|
}
|
|
257
|
-
if (visit.
|
|
258
|
+
if (visit.pending) resolveVisit(visit, true);
|
|
258
259
|
const t = Math.max(visit.t0, at ?? now());
|
|
259
260
|
visit.endedAt = t;
|
|
260
261
|
stats.ends[reason] = (stats.ends[reason] ?? 0) + 1;
|
|
@@ -269,7 +270,9 @@ const endVisit = (reason: string, at?: number) => {
|
|
|
269
270
|
// state event.
|
|
270
271
|
const chooseStart = (snapshot: NavSnapshot, floor: number) => {
|
|
271
272
|
const inSpan = (t: number) => t >= floor && t <= snapshot.stateT;
|
|
272
|
-
|
|
273
|
+
// Touches delivered since the previous visit opened, whatever their finger time: a finger that landed while the
|
|
274
|
+
// previous screen was mounting reaches JS after its state was read.
|
|
275
|
+
const presses = pressesSinceVisit.filter(p => p.t0 >= windowsEndedAt && p.t0 <= snapshot.stateT);
|
|
273
276
|
const link = pendingLink && inSpan(pendingLink.t) ? pendingLink : null;
|
|
274
277
|
const action = snapshot.action && inSpan(snapshot.action.t) ? snapshot.action : null;
|
|
275
278
|
const touchStart = (p: Press) => ({
|
|
@@ -357,7 +360,7 @@ const resolveVisit = (visit: Visit, final = false) => {
|
|
|
357
360
|
if (resolved.screenPending && !final) return;
|
|
358
361
|
moveScreenCounter(visit, resolved.screen);
|
|
359
362
|
visit.name = buildPlaceId({ kind: 'screen', screen: resolved.screen, visit: visit.visit });
|
|
360
|
-
visit.
|
|
363
|
+
visit.pending = false;
|
|
361
364
|
markers.rename(visit.key, visit.name, visit.legacyName);
|
|
362
365
|
};
|
|
363
366
|
|
|
@@ -380,7 +383,7 @@ const extend = (visit: Visit, chain: RoutePart[], chainKey: string) => {
|
|
|
380
383
|
visit.screenKey = null;
|
|
381
384
|
}
|
|
382
385
|
visit.name = visit.legacyName;
|
|
383
|
-
visit.
|
|
386
|
+
visit.pending = true;
|
|
384
387
|
markers.rename(visit.key, visit.name, visit.legacyName);
|
|
385
388
|
} else resolveVisit(visit);
|
|
386
389
|
stats.extended++;
|
|
@@ -447,7 +450,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
|
|
|
447
450
|
visit: n,
|
|
448
451
|
legacyName: nameOf(chainKey, n),
|
|
449
452
|
screenKey: null,
|
|
450
|
-
|
|
453
|
+
pending: true,
|
|
451
454
|
resolution: resolved,
|
|
452
455
|
lazyBoundary: resolved.suspended ?? null,
|
|
453
456
|
t0: start.t0,
|
|
@@ -465,7 +468,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
|
|
|
465
468
|
if (!resolved.screenPending) {
|
|
466
469
|
moveScreenCounter(visit, resolved.screen);
|
|
467
470
|
visit.name = buildPlaceId({ kind: 'screen', screen: resolved.screen, visit: visit.visit });
|
|
468
|
-
visit.
|
|
471
|
+
visit.pending = false;
|
|
469
472
|
}
|
|
470
473
|
current = visit;
|
|
471
474
|
pressesSinceVisit = [];
|
|
@@ -481,7 +484,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
|
|
|
481
484
|
kind: 'screen',
|
|
482
485
|
legacyName: visit.legacyName,
|
|
483
486
|
beforeEnd: () => {
|
|
484
|
-
if (visit.
|
|
487
|
+
if (visit.pending) resolveVisit(visit, true);
|
|
485
488
|
visit.lazyBoundary = null;
|
|
486
489
|
visit.resolution = { screen: visit.screenKey ?? visit.resolution.screen };
|
|
487
490
|
},
|
|
@@ -526,12 +529,14 @@ export const noteAppLink = (url: string, via: string) => {
|
|
|
526
529
|
}
|
|
527
530
|
};
|
|
528
531
|
|
|
532
|
+
// Every touch delivered while a visit is open is an action after the one that opened it, even when its finger landed
|
|
533
|
+
// before the SDK read the navigation state (the JS thread was busy with the mount): order decides, not the clocks.
|
|
529
534
|
const onPress = (t0: number) => {
|
|
530
535
|
const press = lastPressStart();
|
|
531
536
|
if (press) {
|
|
532
537
|
pressesSinceVisit.push(press);
|
|
533
538
|
}
|
|
534
|
-
if (current && current.endedAt === null
|
|
539
|
+
if (current && current.endedAt === null) {
|
|
535
540
|
endVisit('next-action', t0);
|
|
536
541
|
}
|
|
537
542
|
};
|
|
@@ -613,6 +618,10 @@ const onWindowsEnded = (reason: string) => {
|
|
|
613
618
|
pendingLink = null;
|
|
614
619
|
};
|
|
615
620
|
|
|
621
|
+
export const resetCurrentScreenForTest = () => {
|
|
622
|
+
current = null;
|
|
623
|
+
};
|
|
624
|
+
|
|
616
625
|
export const resetLaunchForTest = () => {
|
|
617
626
|
launchVisitOpened = false;
|
|
618
627
|
windowsEndedSinceStart = false;
|
|
@@ -628,7 +637,7 @@ export const installScreens = () => {
|
|
|
628
637
|
installed = true;
|
|
629
638
|
setScreenKeyProvider(() => current ? {
|
|
630
639
|
screen: current.screenKey ?? current.resolution.screen,
|
|
631
|
-
...(current.
|
|
640
|
+
...(current.pending || current.lazyBoundary ? { screenPending: true } : {}),
|
|
632
641
|
} : { screen: '(no screen)' });
|
|
633
642
|
setRetainFloor(screenRetainFloor);
|
|
634
643
|
onWindowsEnd(onWindowsEnded);
|
|
@@ -641,11 +650,12 @@ export const installScreens = () => {
|
|
|
641
650
|
onAfterCommit(() => {
|
|
642
651
|
const visit = current;
|
|
643
652
|
if (visit && isWindowOpen(visit.key)) {
|
|
644
|
-
|
|
653
|
+
// A pending visit is resolved when its scene mounts, however many commits come first; it falls back to the route
|
|
654
|
+
// name only when it ends.
|
|
655
|
+
if (visit.pending && !visit.lazyBoundary) {
|
|
645
656
|
visit.resolution = screenResolution(visit.chain);
|
|
646
657
|
visit.lazyBoundary = visit.resolution.suspended ?? null;
|
|
647
|
-
visit
|
|
648
|
-
resolveVisit(visit, visit.pendingCommits === 0);
|
|
658
|
+
resolveVisit(visit);
|
|
649
659
|
} else if (visit.lazyBoundary) {
|
|
650
660
|
const boundary = currentFiber(visit.lazyBoundary);
|
|
651
661
|
if (!boundary || boundary.memoizedState == null) {
|
package/js/sdk/stream.ts
CHANGED
|
@@ -39,6 +39,12 @@ export const createStream = (processId: string, maxFibers = MAX_SEGMENT_FIBERS)
|
|
|
39
39
|
/** The segment the next commit goes into: windows name it so the backend can tell a stream is complete. */
|
|
40
40
|
seq: () => seq,
|
|
41
41
|
size: () => commits.length,
|
|
42
|
+
/** Sets the fiber cap of the segments from now on; returns the previous cap. */
|
|
43
|
+
setMaxFibers(n: number) {
|
|
44
|
+
const previous = maxFibers;
|
|
45
|
+
maxFibers = n;
|
|
46
|
+
return previous;
|
|
47
|
+
},
|
|
42
48
|
noteRoot(root: CauseRoot) {
|
|
43
49
|
roots.push(root);
|
|
44
50
|
},
|
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.18",
|
|
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": {
|