@keepsnappy/react-native 0.1.0-alpha.13 → 0.1.0-alpha.14

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.
@@ -39,6 +39,9 @@ private const val FLUSH_ELEMENT_TAG = "keepsnappy.flushed.view"
39
39
  private const val FLUSH_ELEMENT_DP = 8
40
40
  // Alpha 1 of 255: drawn, so it counts as visible, but no pixel changes on screen.
41
41
  private const val FLUSH_ELEMENT_BACKGROUND = 0x01000000
42
+ // No foreground activity or window yet (a relaunch, a link mid-transition): tried again this often, this far apart.
43
+ private const val FLUSH_ELEMENT_RETRIES = 3
44
+ private const val FLUSH_ELEMENT_RETRY_MS = 1000L
42
45
 
43
46
 
44
47
  // logcat drops the tail of a line past ~4 KB, so long payloads go out in chunks.
@@ -79,7 +82,7 @@ class KeepSnappyModule : Module() {
79
82
  // `am broadcast --es n 3` and `--ei n 3` both reach here.
80
83
  @Suppress("DEPRECATION")
81
84
  val n = intent.extras?.get("n")?.toString()?.toIntOrNull()
82
- if (cmd != "gc" || n == null || n < 1) {
85
+ if ((cmd != "gc" && cmd != "memread") || n == null || n < 1) {
83
86
  resultCode = Activity.RESULT_CANCELED
84
87
  resultData = "bad command"
85
88
  return
@@ -90,7 +93,7 @@ class KeepSnappyModule : Module() {
90
93
  pendingCommands[n] = pending
91
94
  mainHandler.postDelayed({
92
95
  if (pendingCommands.remove(n, pending)) {
93
- pending.setResult(Activity.RESULT_CANCELED, "gc:$n timeout", null)
96
+ pending.setResult(Activity.RESULT_CANCELED, "$cmd:$n timeout", null)
94
97
  pending.finish()
95
98
  }
96
99
  }, commandDeadlineMs)
@@ -114,19 +117,33 @@ class KeepSnappyModule : Module() {
114
117
  }
115
118
  }
116
119
 
117
- private fun showFlushElement(status: String) {
120
+ private fun showFlushElement(status: String, detail: String, attempt: Int = 0) {
121
+ fun retry(reason: String) {
122
+ if (attempt < FLUSH_ELEMENT_RETRIES) {
123
+ mainHandler.postDelayed({ showFlushElement(status, detail, attempt + 1) }, FLUSH_ELEMENT_RETRY_MS)
124
+ } else {
125
+ writeLog("KEEPSNAPPY flush element not shown: $reason after ${attempt + 1} tries ($status)")
126
+ }
127
+ }
118
128
  val activity = appContext.currentActivity
119
129
  if (activity == null) {
120
- writeLog("KEEPSNAPPY flush element not shown: no foreground activity ($status)")
130
+ retry("no foreground activity")
121
131
  return
122
132
  }
123
133
  mainHandler.post {
124
- val decor = activity.window?.decorView as? ViewGroup ?: return@post
134
+ val decor = activity.window?.decorView as? ViewGroup
135
+ if (decor == null) {
136
+ retry("no window")
137
+ return@post
138
+ }
125
139
  decor.findViewWithTag<View>(FLUSH_ELEMENT_TAG)?.let { decor.removeView(it) }
126
140
  val view = TextView(activity).apply {
127
141
  tag = FLUSH_ELEMENT_TAG
128
142
  text = status
129
143
  setTextColor(Color.TRANSPARENT)
144
+ // The SDK's detail (what failed with `error`): UiAutomator2 reads it as the `hint` attribute; iOS has it as the value.
145
+ hint = detail
146
+ setHintTextColor(Color.TRANSPARENT)
130
147
  // UiAutomator2 drops nodes Android reports as not visible to the user, and a 1 px view in the corner under the system bars was one. Measured on a Galaxy S23 (Android 16): found at 8 dp on the left edge, middle of the screen.
131
148
  setBackgroundColor(FLUSH_ELEMENT_BACKGROUND)
132
149
  contentDescription = FLUSH_ELEMENT_ID
@@ -380,7 +397,7 @@ class KeepSnappyModule : Module() {
380
397
  // like iOS it stays until the next flush replaces it or the process ends. Always logged too.
381
398
  Function("flushSignal") { status: String, detail: String ->
382
399
  writeLog("KEEPSNAPPY flushed $status $detail")
383
- showFlushElement(status)
400
+ showFlushElement(status, detail)
384
401
  }
385
402
 
386
403
  Function("spoolAppend") { record: String ->
@@ -413,6 +430,30 @@ class KeepSnappyModule : Module() {
413
430
  }.get()
414
431
  }
415
432
 
433
+ // The profile files a flush has not uploaded yet, with their run tokens: internal storage, like the push journal.
434
+ Function("artifactsPendingRead") {
435
+ io.submit<String?> {
436
+ val file = stateDir(false)?.let { File(it, "artifacts-pending.json") }
437
+ if (file != null && file.exists()) {
438
+ try { file.readText() } catch (e: Exception) { null }
439
+ } else {
440
+ null
441
+ }
442
+ }.get()
443
+ }
444
+
445
+ Function("artifactsPendingWrite") { content: String ->
446
+ io.submit<Boolean> {
447
+ val dir = stateDir(true) ?: return@submit false
448
+ val file = File(dir, "artifacts-pending.json")
449
+ if (content.isEmpty()) {
450
+ !file.exists() || file.delete()
451
+ } else {
452
+ writeAtomically(file, content)
453
+ }
454
+ }.get()
455
+ }
456
+
416
457
  // System.nanoTime() is CLOCK_MONOTONIC on Android, the same clock as
417
458
  // performance.now() in RN and the MONOTONIC snapshot in Perfetto.
418
459
  Function("clockSnapshot") {
@@ -139,6 +139,31 @@ public class KeepSnappyModule: Module {
139
139
  }
140
140
  }
141
141
 
142
+ // The profile files a flush has not uploaded yet, with their run tokens; read by a later process.
143
+ Function("artifactsPendingRead") { () -> String? in
144
+ self.logQueue.sync {
145
+ guard let file = Self.fileIfPresent("artifacts-pending.json") else { return nil }
146
+ return try? String(contentsOf: file, encoding: .utf8)
147
+ }
148
+ }
149
+
150
+ Function("artifactsPendingWrite") { (content: String) -> Bool in
151
+ self.logQueue.sync {
152
+ guard let dir = Self.outputDir() else { return false }
153
+ let file = dir.appendingPathComponent("artifacts-pending.json")
154
+ if content.isEmpty {
155
+ try? FileManager.default.removeItem(at: file)
156
+ return true
157
+ }
158
+ do {
159
+ try content.write(to: file, atomically: true, encoding: .utf8)
160
+ return true
161
+ } catch {
162
+ return false
163
+ }
164
+ }
165
+ }
166
+
142
167
  Function("spoolReplace") { (content: String) -> Bool in
143
168
  self.logQueue.sync {
144
169
  try? self.spoolHandle?.close()
@@ -296,12 +321,26 @@ public class KeepSnappyModule: Module {
296
321
  private static let flushSignalTag = 0x9E7F_1A5
297
322
  private static let ackSignalTag = 0x9E7F_1A6
298
323
 
324
+ // No window yet (a relaunch, a link mid-transition): tried again this often, this far apart.
325
+ private static let signalRetries = 3
326
+ private static let signalRetryNanoseconds: UInt64 = 1_000_000_000
327
+
299
328
  @MainActor
300
- private static func showSignal(tag: Int, id: String, label status: String, value detail: String) {
329
+ private static func showSignal(tag: Int, id: String, label status: String, value detail: String, attempt: Int = 0) {
301
330
  let windows = UIApplication.shared.connectedScenes
302
331
  .compactMap { $0 as? UIWindowScene }
303
332
  .flatMap { $0.windows }
304
- guard let window = windows.first(where: { $0.isKeyWindow }) ?? windows.first else { return }
333
+ guard let window = windows.first(where: { $0.isKeyWindow }) ?? windows.first else {
334
+ if attempt < signalRetries {
335
+ Task { @MainActor in
336
+ try? await Task.sleep(nanoseconds: signalRetryNanoseconds)
337
+ Self.showSignal(tag: tag, id: id, label: status, value: detail, attempt: attempt + 1)
338
+ }
339
+ } else {
340
+ emit("KEEPSNAPPY \(id) not shown: no window after \(attempt + 1) tries (\(status))")
341
+ }
342
+ return
343
+ }
305
344
  window.viewWithTag(tag)?.removeFromSuperview()
306
345
  let label = UILabel(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
307
346
  label.tag = tag
@@ -11,8 +11,9 @@ const path = require('path');
11
11
  const upstreamPath = (process.env.KEEPSNAPPY_UPSTREAM_BABEL_TRANSFORMER);
12
12
  const entryFile = (process.env.KEEPSNAPPY_ENTRY_FILE);
13
13
  const bootFile = (process.env.KEEPSNAPPY_BOOT_FILE);
14
+ const sdkVersion = process.env.KEEPSNAPPY_SDK_VERSION;
14
15
 
15
- if (!upstreamPath || !entryFile || !bootFile) {
16
+ if (!upstreamPath || !entryFile || !bootFile || !sdkVersion) {
16
17
  throw new Error(
17
18
  'KeepSnappy Babel transformer loaded without withKeepSnappy env (KEEPSNAPPY_*)',
18
19
  );
@@ -57,7 +58,10 @@ module.exports = {
57
58
  return upstream.transform({ ...args, src: bootImport() + args.src });
58
59
  }
59
60
  if (file.startsWith(packageRoot)) {
60
- return upstream.transform({ ...args, src: inlineEnv(args.src) });
61
+ const src = file === path.join(packageRoot, 'js', 'sdk', 'push-install.ts')
62
+ ? args.src.replace("require('../../package.json')", JSON.stringify({ version: sdkVersion }))
63
+ : args.src;
64
+ return upstream.transform({ ...args, src: inlineEnv(src) });
61
65
  }
62
66
  return upstream.transform(args);
63
67
  },
@@ -66,6 +70,6 @@ module.exports = {
66
70
  typeof upstream.getCacheKey === 'function'
67
71
  ? upstream.getCacheKey(...args)
68
72
  : '';
69
- return `${base}$keepsnappy:${entryFile}:${bootFile}:${envSignature()}`;
73
+ return `${base}$keepsnappy:${entryFile}:${bootFile}:${sdkVersion}:${envSignature()}`;
70
74
  },
71
75
  };
@@ -0,0 +1,23 @@
1
+ // Build tooling only: Metro workers receive the identity in their inherited env.
2
+ const { execFileSync } = require('child_process');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function sdkBuildVersion() {
7
+ const packageRoot = path.resolve(__dirname, '..');
8
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
9
+ if (!pkg.private) return pkg.version;
10
+ const git = args => execFileSync('git', args, { cwd: packageRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
11
+ try {
12
+ const root = git(['rev-parse', '--show-toplevel']);
13
+ const release = JSON.parse(fs.readFileSync(path.join(root, 'release.json'), 'utf8')).version;
14
+ if (typeof release !== 'string' || !release) throw new Error('release.json has no version');
15
+ const sha = git(['rev-parse', '--short', 'HEAD']);
16
+ const dirty = git(['status', '--porcelain', '--untracked-files=normal']) ? '.dirty' : '';
17
+ return `${release}+local.${sha}${dirty}`;
18
+ } catch (cause) {
19
+ throw new Error('KeepSnappy: cannot identify this unpublished SDK; build from its Git checkout or use a staged release package', { cause });
20
+ }
21
+ }
22
+
23
+ module.exports = { sdkBuildVersion };
@@ -0,0 +1,167 @@
1
+ // Profile files a flush has not uploaded yet, kept in the app's internal storage with their run tokens (next to the push
2
+ // journal), so a later process uploads them: a flush that never finished, a process killed in the middle of the upload.
3
+ // A process's whole list is kept, uploaded files too: the run's flush report counts every file the run made.
4
+
5
+ import type { ArtifactEntry, ArtifactKind, ArtifactReason, ArtifactState } from './artifacts';
6
+
7
+ export interface PendingArtifactStore {
8
+ read(): string | null;
9
+ // '' removes the record.
10
+ write(content: string): boolean;
11
+ }
12
+
13
+ interface SavedEntry {
14
+ name: string;
15
+ kind: ArtifactKind;
16
+ reasons: ArtifactReason[];
17
+ windows: number[];
18
+ bytes: number | null;
19
+ runToken: string | null;
20
+ state: ArtifactState;
21
+ }
22
+
23
+ interface SavedGroup {
24
+ processId: string;
25
+ // Flushes of later processes that tried this group.
26
+ tries: number;
27
+ entries: SavedEntry[];
28
+ }
29
+
30
+ export interface EarlierArtifacts {
31
+ processId: string;
32
+ entries: ArtifactEntry[];
33
+ }
34
+
35
+ // A file whose upload fails at this many later flushes (missing on disk, refused) stops being tried.
36
+ export const PENDING_ARTIFACT_TRIES = 3;
37
+
38
+ const owed = (state: ArtifactState) => state === 'pending' || state === 'failed';
39
+
40
+ const isObject = (value: unknown): value is Record<string, unknown> =>
41
+ typeof value === 'object' && value !== null && !Array.isArray(value);
42
+
43
+ const savedEntry = (value: unknown): SavedEntry | null => {
44
+ if (!isObject(value) || typeof value.name !== 'string' || typeof value.kind !== 'string' || typeof value.state !== 'string') {
45
+ return null;
46
+ }
47
+ return {
48
+ name: value.name,
49
+ kind: value.kind as ArtifactKind,
50
+ reasons: Array.isArray(value.reasons) ? (value.reasons as ArtifactReason[]) : [],
51
+ windows: Array.isArray(value.windows) ? (value.windows as number[]) : [],
52
+ bytes: typeof value.bytes === 'number' ? value.bytes : null,
53
+ runToken: typeof value.runToken === 'string' ? value.runToken : null,
54
+ state: value.state as ArtifactState,
55
+ };
56
+ };
57
+
58
+ const parse = (raw: string | null): SavedGroup[] => {
59
+ if (!raw) {
60
+ return [];
61
+ }
62
+ try {
63
+ const value = JSON.parse(raw) as unknown;
64
+ if (!isObject(value) || value.v !== 1 || !Array.isArray(value.groups)) {
65
+ return [];
66
+ }
67
+ return value.groups.flatMap(group => {
68
+ if (!isObject(group) || typeof group.processId !== 'string' || !Array.isArray(group.entries)) {
69
+ return [];
70
+ }
71
+ const entries = group.entries.map(savedEntry).filter((e): e is SavedEntry => e !== null);
72
+ return [{ processId: group.processId, tries: typeof group.tries === 'number' ? group.tries : 0, entries }];
73
+ });
74
+ } catch {
75
+ return [];
76
+ }
77
+ };
78
+
79
+ const toSaved = (entry: ArtifactEntry, token: string | null): SavedEntry => ({
80
+ name: entry.name,
81
+ kind: entry.kind,
82
+ reasons: entry.reasons,
83
+ windows: entry.windows,
84
+ bytes: entry.bytes,
85
+ runToken: entry.runToken !== undefined ? entry.runToken : token,
86
+ state: entry.state,
87
+ });
88
+
89
+ const toEntry = (saved: SavedEntry): ArtifactEntry => ({
90
+ name: saved.name,
91
+ kind: saved.kind,
92
+ reasons: saved.reasons,
93
+ windows: saved.windows,
94
+ bytes: saved.bytes,
95
+ state: owed(saved.state) ? 'pending' : saved.state,
96
+ artifactId: null,
97
+ gzBytes: null,
98
+ sha256: null,
99
+ attempts: 0,
100
+ error: null,
101
+ runToken: saved.runToken,
102
+ });
103
+
104
+ // `store` is looked up at each call: null (an older binary) keeps nothing, as before.
105
+ export const createPendingArtifacts = (store: () => PendingArtifactStore | null, log: (message: string) => void) => {
106
+ const read = () => {
107
+ try {
108
+ return parse(store()?.read() ?? null);
109
+ } catch (error) {
110
+ log(`artifacts pending read failed ${String(error)}`);
111
+ return [];
112
+ }
113
+ };
114
+ const save = (groups: SavedGroup[]) => {
115
+ const target = store();
116
+ if (!target) {
117
+ return;
118
+ }
119
+ const kept = groups.filter(group => group.entries.some(entry => owed(entry.state)));
120
+ try {
121
+ target.write(kept.length > 0 ? JSON.stringify({ v: 1, groups: kept }) : '');
122
+ } catch (error) {
123
+ log(`artifacts pending write failed ${String(error)}`);
124
+ }
125
+ };
126
+
127
+ return {
128
+ // This process's files as they stand now; replaces its earlier record.
129
+ note(processId: string, entries: ArtifactEntry[], token: string | null) {
130
+ if (!store()) {
131
+ return;
132
+ }
133
+ const groups = read().filter(group => group.processId !== processId);
134
+ groups.push({ processId, tries: 0, entries: entries.map(entry => toSaved(entry, token)) });
135
+ save(groups);
136
+ },
137
+ // Earlier processes' lists, as entries the uploader takes: what is owed is pending again.
138
+ earlier(processId: string): EarlierArtifacts[] {
139
+ return read()
140
+ .filter(group => group.processId !== processId)
141
+ .map(group => ({ processId: group.processId, entries: group.entries.map(toEntry) }));
142
+ },
143
+ // After those were tried: what is still owed stays, for at most PENDING_ARTIFACT_TRIES flushes.
144
+ settleEarlier(tried: EarlierArtifacts[]) {
145
+ const byProcess = new Map(tried.map(group => [group.processId, group]));
146
+ const groups: SavedGroup[] = [];
147
+ for (const group of read()) {
148
+ const done = byProcess.get(group.processId);
149
+ if (!done) {
150
+ groups.push(group);
151
+ continue;
152
+ }
153
+ const entries = done.entries.map(entry => toSaved(entry, null));
154
+ const left = entries.filter(entry => owed(entry.state));
155
+ // A try is an upload that was attempted and failed; a file the deadline stopped first was not tried.
156
+ const attempted = done.entries.some(entry => owed(entry.state) && entry.attempts > 0);
157
+ const tries = attempted ? group.tries + 1 : group.tries;
158
+ if (left.length > 0 && tries >= PENDING_ARTIFACT_TRIES) {
159
+ log(`artifacts earlier dropped ${JSON.stringify({ processId: group.processId, tries, files: left.map(entry => entry.name) })}`);
160
+ continue;
161
+ }
162
+ groups.push({ processId: group.processId, tries, entries });
163
+ }
164
+ save(groups);
165
+ },
166
+ };
167
+ };
@@ -622,7 +622,8 @@ export const createArtifactUploader = (deps: ArtifactUploaderDeps) => {
622
622
  deps.log(`artifacts upload stopped: ${reported}`);
623
623
  }
624
624
  const summary = summarizeArtifacts(entries, deps.now() - t0, reported);
625
- await report(entries, token, 'end', deps.now() + requestTimeoutMs, reported);
625
+ // At most one request timeout past the flush deadline, however late the upload ran: ARTIFACT_FLUSH_WORST_CASE_MS holds.
626
+ await report(entries, token, 'end', Math.min(deps.now(), deadline) + requestTimeoutMs, reported);
626
627
  return summary;
627
628
  };
628
629
 
package/js/sdk/core.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  summarizeArtifacts,
33
33
  type WrittenFile,
34
34
  } from './artifacts';
35
+ import { createPendingArtifacts, type EarlierArtifacts } from './artifact-pending';
35
36
  import {
36
37
  backgroundSource,
37
38
  type Carrier,
@@ -143,7 +144,10 @@ interface KeepSnappyNative extends Partial<CpuProfileNative> {
143
144
  samplerRecent(sinceT: number): SamplerRecord[];
144
145
  samplerNow(): number;
145
146
  flushSignal?(status: string, detail: string): void;
146
- // GC before the action: Android finishes the pending command broadcast with this result; iOS shows it on `keepsnappy.ack`.
147
+ // The list of profile files not uploaded yet, with run tokens: internal storage, read by a later process.
148
+ artifactsPendingRead?(): string | null;
149
+ artifactsPendingWrite?(content: string): boolean;
150
+ // Command answer (`gc:<n> ok`, `memread:<n> ok`): Android finishes the pending command broadcast with this result; iOS shows it on `keepsnappy.ack`.
147
151
  commandDone?(n: number, data: string): void;
148
152
  /** Android: whether the broadcast of command n still waits for its answer (native finishes it at a deadline). */
149
153
  commandPending?(n: number): boolean;
@@ -1617,6 +1621,17 @@ export const setArtifactHandler = (handler: ArtifactHandler | null) => {
1617
1621
  // Entries a window interrupted wait for the next flush of this process.
1618
1622
  let artifactBacklog: ArtifactEntry[] = [];
1619
1623
 
1624
+ const pendingArtifacts = createPendingArtifacts(() => {
1625
+ const native = getNative();
1626
+ if (typeof native?.artifactsPendingRead !== 'function' || typeof native.artifactsPendingWrite !== 'function') {
1627
+ return null;
1628
+ }
1629
+ return {
1630
+ read: () => native.artifactsPendingRead!(),
1631
+ write: content => native.artifactsPendingWrite!(content),
1632
+ };
1633
+ }, perfLog);
1634
+
1620
1635
  const writeManifest = (entries: ArtifactEntry[], upload: ArtifactUploadSummary | null) => {
1621
1636
  try {
1622
1637
  const manifest = buildManifest({
@@ -1662,11 +1677,39 @@ const sendArtifacts = async (fresh: ArtifactEntry[], deadline: number) => {
1662
1677
  summary = summarizeArtifacts(entries, 0, 'push not configured');
1663
1678
  }
1664
1679
  artifactBacklog = entries.filter(entry => entry.state === 'pending' || entry.state === 'failed');
1680
+ pendingArtifacts.note(PROCESS_ID, entries, runToken);
1665
1681
  writeManifest(entries, summary);
1666
1682
  perfLog(`artifacts ${JSON.stringify(summary)}`);
1667
1683
  return summary;
1668
1684
  };
1669
1685
 
1686
+ // After this process's own files: what earlier processes left (a flush that never finished, a kill during the upload), each
1687
+ // under its own run token and process id, within the same deadline.
1688
+ const sendEarlierArtifacts = async (deadline: number) => {
1689
+ if (!artifactHandler) {
1690
+ return;
1691
+ }
1692
+ const groups: EarlierArtifacts[] = pendingArtifacts.earlier(PROCESS_ID);
1693
+ if (groups.length === 0) {
1694
+ return;
1695
+ }
1696
+ const tried: EarlierArtifacts[] = [];
1697
+ for (const group of groups) {
1698
+ // What does not fit in this flush waits for the next one, with no try counted.
1699
+ if (Date.now() >= deadline) {
1700
+ break;
1701
+ }
1702
+ tried.push(group);
1703
+ try {
1704
+ const summary = await artifactHandler(group.entries, null, group.processId, deadline);
1705
+ perfLog(`artifacts earlier ${JSON.stringify({ processId: group.processId, ...summary })}`);
1706
+ } catch (error) {
1707
+ perfLog(`artifacts earlier failed ${String(error)}`);
1708
+ }
1709
+ }
1710
+ pendingArtifacts.settleEarlier(tried);
1711
+ };
1712
+
1670
1713
  // Declared on every window line so the backend can tell windows of this model from older ones (REQ-VER-34).
1671
1714
  // 2: a tap or scroll window runs from its touch to the next touch, the background or the flush; no quiet rule, no `+3s` twin.
1672
1715
  // 3: model 2, and a screen window also ends at the next action, the next screen, the background or the flush; no 3 s cap.
@@ -1997,7 +2040,11 @@ export const setFlushHandler = (handler: FlushHandler | null) => {
1997
2040
  // `flush=1`: end the open windows, send every unsent line, then show the result
1998
2041
  // to the test driver as an accessibility element (iOS: id `keepsnappy.flushed`,
1999
2042
  // label `ok` / `error` / `off`), so a flow can wait for it without network access.
2000
- const flushOnce = async () => {
2043
+ // The driver's name for one flush link (`ack=<n>`), echoed at the end of the answer's detail: the answer element stays up
2044
+ // until the next one replaces it, so without it a later flush in the same process could read an earlier flush's answer.
2045
+ const ACK_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
2046
+
2047
+ const flushOnce = async (ack: string | undefined) => {
2001
2048
  // One deadline for the whole flush (lines, clock, artifacts); the end report of the upload comes after it.
2002
2049
  const deadline = Date.now() + ARTIFACT_UPLOAD_DEADLINE_MS;
2003
2050
  endAll('flush');
@@ -2008,6 +2055,8 @@ const flushOnce = async () => {
2008
2055
  } catch (error) {
2009
2056
  perfLog(`flush files failed ${String(error)}`);
2010
2057
  }
2058
+ // Before anything that waits on the network: a flush that never ends, or a process killed now, leaves the list to a later one.
2059
+ pendingArtifacts.note(PROCESS_ID, [...artifactBacklog, ...artifacts], runToken);
2011
2060
  let status = 'off';
2012
2061
  let detail = 'push not configured';
2013
2062
  if (flushHandler) {
@@ -2026,10 +2075,25 @@ const flushOnce = async () => {
2026
2075
  const sent = await sendArtifacts(artifacts, deadline);
2027
2076
  if (sent) {
2028
2077
  detail += ` artifacts=${sent.uploaded}/${sent.total}`;
2078
+ // A profile file of a run that did not reach the API is missing data, whatever kept it back: failed after its tries, left
2079
+ // unsent by the deadline, kept on the device (token refused, no endpoint, no native upload) or refused by the API.
2080
+ const missing = sent.failed + sent.pending + (runToken !== null ? sent.local + sent.rejected : 0);
2081
+ if (status === 'ok' && missing > 0) {
2082
+ status = 'error';
2083
+ detail += ` failed=${sent.failed} pending=${sent.pending} local=${sent.local} rejected=${sent.rejected}`;
2084
+ }
2029
2085
  }
2030
2086
  } catch (error) {
2031
2087
  perfLog(`artifacts failed ${String(error)}`);
2032
2088
  }
2089
+ try {
2090
+ await sendEarlierArtifacts(deadline);
2091
+ } catch (error) {
2092
+ perfLog(`artifacts earlier failed ${String(error)}`);
2093
+ }
2094
+ if (ack !== undefined && ACK_PATTERN.test(ack)) {
2095
+ detail += ` ack=${ack}`;
2096
+ }
2033
2097
  perfLog(`flush ${status} ${detail}`);
2034
2098
  try {
2035
2099
  getNative()?.flushSignal?.(status, detail);
@@ -2041,11 +2105,11 @@ const flushOnce = async () => {
2041
2105
  // A second flush link while one runs (the driver's final flush) waits for it: the flush signal means the
2042
2106
  // uploads of the first are done, so it must not be raised by a flush that has nothing left to send.
2043
2107
  let flushing: Promise<void> | null = null;
2044
- export const flushNow = (): Promise<void> => {
2108
+ export const flushNow = (ack?: string): Promise<void> => {
2045
2109
  commandAnswers.clear();
2046
2110
  gcRanSinceFlush = false;
2047
2111
  // The first flush starts at once (it ends the open windows in the same tick as the link).
2048
- const run = flushing === null ? flushOnce() : flushing.then(flushOnce);
2112
+ const run = flushing === null ? flushOnce(ack) : flushing.then(() => flushOnce(ack));
2049
2113
  const tail: Promise<void> = run
2050
2114
  .catch(() => undefined)
2051
2115
  .then(() => {
@@ -2242,10 +2306,32 @@ export const actionGc = (n: number, tNative?: number): string => {
2242
2306
  }
2243
2307
  // The gc-pre / gc-post records go out now: the backend pairs them with this command by their seq.
2244
2308
  forwardSamples();
2309
+ rememberAnswer(key, answer);
2310
+ answerCommand(n, answer);
2311
+ return answer;
2312
+ };
2313
+
2314
+ const rememberAnswer = (key: string, answer: string) => {
2245
2315
  commandAnswers.set(key, answer);
2246
2316
  if (commandAnswers.size > COMMAND_ANSWERS_KEPT) {
2247
2317
  commandAnswers.delete(commandAnswers.keys().next().value as string);
2248
2318
  }
2319
+ };
2320
+
2321
+ // The end GC's manual memory read: the driver's `memread` command, sent with the GC's own n after the GC's answer. One sample, at
2322
+ // once (no timer), reason `endgc-read:<n>`, sent with the run's lines before the answer; the answer is the driver's signal that the
2323
+ // app may go to the background. Derive reads kept from this record (derivation 23).
2324
+ export const endGcRead = (n: number): string => {
2325
+ const key = `${runToken ?? '-'}#read${n}`;
2326
+ const known = commandAnswers.get(key);
2327
+ if (known !== undefined) {
2328
+ answerCommand(n, known);
2329
+ return known;
2330
+ }
2331
+ const record = sample(`endgc-read:${n}`);
2332
+ const answer = `memread:${n} ${record ? 'ok' : 'unavailable'}`;
2333
+ forwardSamples();
2334
+ rememberAnswer(key, answer);
2249
2335
  answerCommand(n, answer);
2250
2336
  return answer;
2251
2337
  };
@@ -2264,7 +2350,7 @@ const syncCommandListener = () => {
2264
2350
  native.addListener?.('keepsnappyCommand', e => {
2265
2351
  // A command native already timed out on is not run: its GC would land in the next step's action.
2266
2352
  if (native.commandPending?.(e.n) === false) {
2267
- perfLog(`command gc:${e.n} late, skipped`);
2353
+ perfLog(`command ${e.cmd}:${e.n} late, skipped`);
2268
2354
  return;
2269
2355
  }
2270
2356
  handleKeepSnappyCommand(e.cmd, e.n, e.tNative);
@@ -2275,15 +2361,19 @@ const syncCommandListener = () => {
2275
2361
 
2276
2362
  /** A command (`cmd=<name>&n=<n>`), from a link (iOS) or the native receiver (Android); unknown commands are ignored. */
2277
2363
  export const handleKeepSnappyCommand = (cmd: string | undefined, n: number, tNative?: number): boolean => {
2278
- if (!KEEPSNAPPY_ENABLED || cmd !== 'gc' || !Number.isInteger(n) || n < 1) {
2364
+ if (!KEEPSNAPPY_ENABLED || (cmd !== 'gc' && cmd !== 'memread') || !Number.isInteger(n) || n < 1) {
2279
2365
  return false;
2280
2366
  }
2281
2367
  // Every received command is answered, so the Android receiver is never left pending.
2282
2368
  try {
2283
- actionGc(n, tNative);
2369
+ if (cmd === 'gc') {
2370
+ actionGc(n, tNative);
2371
+ } else {
2372
+ endGcRead(n);
2373
+ }
2284
2374
  } catch (error) {
2285
- perfLog(`command gc:${n} failed ${String(error)}`);
2286
- answerCommand(n, `gc:${n} error`);
2375
+ perfLog(`command ${cmd}:${n} failed ${String(error)}`);
2376
+ answerCommand(n, `${cmd}:${n} error`);
2287
2377
  }
2288
2378
  return true;
2289
2379
  };
@@ -2306,7 +2396,7 @@ export const handleKeepSnappyDeepLink = (url: string) => {
2306
2396
  }
2307
2397
  applySettings(params);
2308
2398
  if (params.flush === '1') {
2309
- void flushNow();
2399
+ void flushNow(params.ack);
2310
2400
  }
2311
2401
  };
2312
2402
 
package/js/sdk/push.ts CHANGED
@@ -95,9 +95,13 @@ export interface ClockSample {
95
95
 
96
96
  const MAX_CLOCK_SAMPLES = 16;
97
97
 
98
- const serverClock = async (res: { json?: () => Promise<unknown> }) => {
98
+ // `bound` rejects when the request's time is up: a native fetch resolves at the headers, and its body can stall with the
99
+ // abort signal ignored.
100
+ const serverClock = async (res: { json?: () => Promise<unknown> }, bound: Promise<never>) => {
99
101
  try {
100
- const body = (await res.json?.()) as { clock?: { recv_ms?: unknown; send_ms?: unknown } } | undefined;
102
+ const body = (await Promise.race([res.json?.(), bound])) as
103
+ | { clock?: { recv_ms?: unknown; send_ms?: unknown } }
104
+ | undefined;
101
105
  const t1 = body?.clock?.recv_ms;
102
106
  const t2 = body?.clock?.send_ms;
103
107
  return typeof t1 === 'number' && typeof t2 === 'number' ? { t1, t2 } : null;
@@ -240,6 +244,9 @@ export const createPushClient = (deps: PushDeps) => {
240
244
  const endpoint = `${deps.url.replace(/\/+$/, '')}/v1/windows`;
241
245
  // Set by flushAll: the time (deps.now) every request of that flush must be over by.
242
246
  let flushLimit: number | null = null;
247
+ // Batches of a run (with a token) the ingest refused for good: their lines are gone, so the run lacks data.
248
+ let refusedRunBatches = 0;
249
+ let lastRefusedStatus = 0;
243
250
  // Rejects after `ms`; a hard bound for a fetch that ignores its abort signal.
244
251
  const timeoutAfter = (ms: number) => {
245
252
  let handle: unknown;
@@ -603,7 +610,12 @@ export const createPushClient = (deps: PushDeps) => {
603
610
  sending = true;
604
611
  try {
605
612
  if (!device) {
606
- device = await deps.getDevice();
613
+ const bound = timeoutAfter(flushLimit !== null ? Math.max(1, Math.min(timeoutMs, flushLimit - deps.now())) : timeoutMs);
614
+ try {
615
+ device = await Promise.race([deps.getDevice(), bound.promise]);
616
+ } finally {
617
+ bound.clear();
618
+ }
607
619
  }
608
620
  if (!batch.id) {
609
621
  // Names exactly these lines (process, first and last sequence, count),
@@ -641,7 +653,8 @@ export const createPushClient = (deps: PushDeps) => {
641
653
  const abort = controller
642
654
  ? deps.setTimer(() => controller.abort(), budget)
643
655
  : null;
644
- const hard = flushLimit !== null ? timeoutAfter(budget) : null;
656
+ // Every request, idle or flush: a flush link waits for an idle send still in flight.
657
+ const hard = timeoutAfter(budget);
645
658
  let status = 0;
646
659
  try {
647
660
  const t0 = deps.now();
@@ -651,16 +664,16 @@ export const createPushClient = (deps: PushDeps) => {
651
664
  body,
652
665
  ...(controller ? { signal: controller.signal } : {}),
653
666
  });
654
- const res = hard ? await Promise.race([pending, hard.promise]) : await pending;
667
+ const res = await Promise.race([pending, hard.promise]);
655
668
  const t3 = deps.now();
656
669
  status = res.status;
670
+ // A fresh batch (202) stored the samples it carried, whether or not its body arrives; a duplicate's go again.
671
+ if (status === 202) {
672
+ dropClock(reported);
673
+ }
657
674
  if (runToken && (status === 200 || status === 202)) {
658
- const server = await serverClock(res);
675
+ const server = await serverClock(res, hard.promise);
659
676
  if (server) {
660
- // Stored only by a fresh batch; a duplicate's samples go again.
661
- if (status === 202) {
662
- dropClock(reported);
663
- }
664
677
  keepClock(runToken, { t0, ...server, t3 });
665
678
  }
666
679
  }
@@ -668,7 +681,7 @@ export const createPushClient = (deps: PushDeps) => {
668
681
  if (abort !== null) {
669
682
  deps.clearTimer(abort);
670
683
  }
671
- hard?.clear();
684
+ hard.clear();
672
685
  }
673
686
  stats.lastStatus = status;
674
687
  if (status === 200 || status === 202) {
@@ -685,6 +698,10 @@ export const createPushClient = (deps: PushDeps) => {
685
698
  } else if (isPermanent(status)) {
686
699
  stats.batchesRejected++;
687
700
  stats.lastError = `rejected ${status}, batch ${batch.id} dropped`;
701
+ if (batch.token) {
702
+ refusedRunBatches++;
703
+ lastRefusedStatus = status;
704
+ }
688
705
  retryBatch = null;
689
706
  retryAt = null;
690
707
  attempts = 0;
@@ -762,11 +779,23 @@ export const createPushClient = (deps: PushDeps) => {
762
779
  resume();
763
780
  const sentBefore = stats.batchesSent;
764
781
  const linesBefore = stats.linesSent;
782
+ const refusedBefore = refusedRunBatches;
765
783
  let failures = 0;
766
784
  flushLimit = deadline;
767
785
  try {
768
786
  if (inflight) {
769
- await inflight;
787
+ if (deadline === null) {
788
+ await inflight;
789
+ } else {
790
+ const bound = timeoutAfter(Math.max(0, deadline - deps.now()));
791
+ try {
792
+ await Promise.race([inflight, bound.promise]);
793
+ } catch {
794
+ // the deadline passed first; the loop below starts nothing more
795
+ } finally {
796
+ bound.clear();
797
+ }
798
+ }
770
799
  }
771
800
  while (
772
801
  (queue.length > 0 || retryBatch) &&
@@ -786,15 +815,21 @@ export const createPushClient = (deps: PushDeps) => {
786
815
  }
787
816
  }
788
817
  }
789
- const ok = queue.length === 0 && !retryBatch;
790
- if (ok) {
818
+ const sentAll = queue.length === 0 && !retryBatch;
819
+ if (sentAll) {
791
820
  await sendClock(deadline);
792
821
  }
822
+ const refused = refusedRunBatches - refusedBefore;
823
+ const ok = sentAll && refused === 0;
793
824
  return {
794
825
  ok,
795
826
  batches: stats.batchesSent - sentBefore,
796
827
  lines: stats.linesSent - linesBefore,
797
- error: ok ? null : (stats.lastError ?? 'flush deadline'),
828
+ error: ok
829
+ ? null
830
+ : !sentAll
831
+ ? (stats.lastError ?? 'flush deadline')
832
+ : `rejected ${lastRefusedStatus}: ${refused} ${refused === 1 ? 'batch' : 'batches'} of the run dropped`,
798
833
  };
799
834
  } finally {
800
835
  flushLimit = null;
package/metro.js CHANGED
@@ -195,6 +195,8 @@ function withKeepSnappy(input) {
195
195
  );
196
196
  }
197
197
 
198
+ process.env.KEEPSNAPPY_SDK_VERSION = require('./js/build-version.js').sdkBuildVersion();
199
+
198
200
  // Transform workers are forked after the config loads and inherit this env.
199
201
  process.env[ENV_UPSTREAM] = upstream;
200
202
  process.env[ENV_ENTRY] = entry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepsnappy/react-native",
3
- "version": "0.1.0-alpha.13",
3
+ "version": "0.1.0-alpha.14",
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": {