querysub 0.491.0 → 0.492.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.491.0",
3
+ "version": "0.492.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -43,9 +43,31 @@ import { isClient } from "../config2";
43
43
 
44
44
  const DEFAULT_MAX_LOCKS = 1000;
45
45
 
46
- // After this time we allow proxies to be reordered, even if there's flags that tell them not to be.
46
+ // After this time we allow proxies to be reordered, even if there's flags that tell them not to be.
47
47
  const MAX_PROXY_REORDER_BLOCK_TIME = timeInSecond * 10;
48
48
 
49
+ // The proxy watcher reads all of its synced data through this indirection instead of importing
50
+ // authorityStorage directly, so the data source can be swapped out. This is what lets us replay
51
+ // captured function runs against an in-memory dataset in complete isolation (see functionReplay.ts).
52
+ export interface PathValueReadSource {
53
+ getValueAtOrBeforeTime(pathStr: string, time: Time | undefined): PathValue | undefined;
54
+ isSynced(pathStr: string): boolean;
55
+ isParentSynced(pathStr: string): boolean;
56
+ getPathsFromParent(pathStr: string): Set<string> | undefined;
57
+ temporaryOverride<T>(overrides: PathValue[] | undefined, code: () => T): T;
58
+ DEBUG_hasAnyValues(pathStr: string): boolean;
59
+ }
60
+ let currentReadSource: PathValueReadSource = authorityStorage;
61
+ export function runWithPathValueReadSource<T>(source: PathValueReadSource, code: () => T): T {
62
+ let prev = currentReadSource;
63
+ currentReadSource = source;
64
+ try {
65
+ return code();
66
+ } finally {
67
+ currentReadSource = prev;
68
+ }
69
+ }
70
+
49
71
  let nextSeqNum = 1;
50
72
  let nextOrderSeqNum = 1;
51
73
 
@@ -566,7 +588,7 @@ export class PathValueProxyWatcher {
566
588
  if (!watcher.permissionsChecker.checkPermissions(pathStr).allowed) {
567
589
  if (
568
590
  !watcher.hasAnyUnsyncedAccesses()
569
- && authorityStorage.DEBUG_hasAnyValues(pathStr)
591
+ && currentReadSource.DEBUG_hasAnyValues(pathStr)
570
592
  // HACK: Don't show warnings for some framework paths, because they are filling up the console logs
571
593
  // and don't really matter. We could just not request them, but at depth 3 is valid,
572
594
  // so we kind of have to request that. And at depth 2, it would require special case code,
@@ -589,14 +611,14 @@ export class PathValueProxyWatcher {
589
611
 
590
612
  const currentReadTime = watcher.options.forceReadLatest ? undefined : watcher.currentReadTime;
591
613
 
592
- let pathValue = authorityStorage.getValueAtOrBeforeTime(pathStr, currentReadTime);
614
+ let pathValue = currentReadSource.getValueAtOrBeforeTime(pathStr, currentReadTime);
593
615
  if (!watcher.options.noSyncing) {
594
616
  // NOTE: If we have any value, we are always synced (that's what synced means, as if we aren't syncing,
595
617
  // we delete any values, to prevent stale values from being used).
596
618
  if (!pathValue) {
597
619
  // NOTE: We might not have a value simply due to reading too far back in time,
598
620
  // so we have to call isSynced just to be sure it is actually unsynced
599
- if (!authorityStorage.isSynced(pathStr)) {
621
+ if (!currentReadSource.isSynced(pathStr)) {
600
622
  for (let checker of this.isUnsyncCheckers) {
601
623
  checker.value++;
602
624
  }
@@ -604,7 +626,7 @@ export class PathValueProxyWatcher {
604
626
  }
605
627
  }
606
628
  if (syncParentKeys) {
607
- if (!authorityStorage.isParentSynced(pathStr)) {
629
+ if (!currentReadSource.isParentSynced(pathStr)) {
608
630
  watcher.pendingUnsyncedParentAccesses.add(pathStr);
609
631
  }
610
632
  }
@@ -940,7 +962,7 @@ export class PathValueProxyWatcher {
940
962
  return getKeys(pathValue?.value) as string[];
941
963
  }
942
964
 
943
- let childPaths = authorityStorage.getPathsFromParent(pathStr);
965
+ let childPaths = currentReadSource.getPathsFromParent(pathStr);
944
966
 
945
967
  // We need to also get keys from pendingWrites
946
968
  {
@@ -996,7 +1018,7 @@ export class PathValueProxyWatcher {
996
1018
  }
997
1019
 
998
1020
  if (symbol === syncedSymbol) {
999
- return { value: authorityStorage.isSynced(pathStr) };
1021
+ return { value: currentReadSource.isSynced(pathStr) };
1000
1022
  }
1001
1023
 
1002
1024
  // Proxies should be considered atomic, at least for the purpose of other proxies!
@@ -1066,7 +1088,7 @@ export class PathValueProxyWatcher {
1066
1088
  let { watchFunction, ...mostOptions } = options;
1067
1089
  doProxyOptions(mostOptions, () => {
1068
1090
  try {
1069
- let result = authorityStorage.temporaryOverride(options.overrides, () =>
1091
+ let result = currentReadSource.temporaryOverride(options.overrides, () =>
1070
1092
  options.watchFunction()
1071
1093
  );
1072
1094
  // Clone, otherwise proxies get out of the watcher, which can result in accesses outside
@@ -1353,7 +1375,7 @@ export class PathValueProxyWatcher {
1353
1375
  }
1354
1376
  },
1355
1377
  code() {
1356
- return authorityStorage.temporaryOverride(options.overrides, () =>
1378
+ return currentReadSource.temporaryOverride(options.overrides, () =>
1357
1379
  runCodeWithDatabase(proxy, baseFunction)
1358
1380
  );
1359
1381
  },
@@ -1457,8 +1479,8 @@ export class PathValueProxyWatcher {
1457
1479
  setTimeout(() => {
1458
1480
  if (watcher.syncRunCount !== syncRunCount) return;
1459
1481
  if (watcher.disposed) return;
1460
- let remainingUnsynced = Array.from(watcher.lastUnsyncedAccesses).filter(x => !authorityStorage.isSynced(x));
1461
- let remainingUnsyncedParent = Array.from(watcher.lastUnsyncedParentAccesses).filter(x => !authorityStorage.isParentSynced(x));
1482
+ let remainingUnsynced = Array.from(watcher.lastUnsyncedAccesses).filter(x => !currentReadSource.isSynced(x));
1483
+ let remainingUnsyncedParent = Array.from(watcher.lastUnsyncedParentAccesses).filter(x => !currentReadSource.isParentSynced(x));
1462
1484
  console.warn(red(`Did not sync ${watcher.debugName} after 30 seconds. Either we had a lot synchronous block, or we will never received the values we are waiting for.`), { remainingUnsynced, remainingUnsyncedParent }, watcher.options.watchFunction);
1463
1485
  }, 30 * 1000);
1464
1486
  setTimeout(() => {
@@ -1472,8 +1494,8 @@ export class PathValueProxyWatcher {
1472
1494
  // we have had since this watcher last synced. If it is > 50% of the time...
1473
1495
  // then synchronous lag is the issue.
1474
1496
 
1475
- let reallyUnsyncedAccesses = Array.from(watcher.lastUnsyncedAccesses).filter(x => !authorityStorage.isSynced(x));
1476
- let reallyUnsyncedParentAccesses = Array.from(watcher.lastUnsyncedParentAccesses).filter(x => !authorityStorage.isParentSynced(x));
1497
+ let reallyUnsyncedAccesses = Array.from(watcher.lastUnsyncedAccesses).filter(x => !currentReadSource.isSynced(x));
1498
+ let reallyUnsyncedParentAccesses = Array.from(watcher.lastUnsyncedParentAccesses).filter(x => !currentReadSource.isParentSynced(x));
1477
1499
 
1478
1500
  if (reallyUnsyncedAccesses.length !== 0 || reallyUnsyncedParentAccesses.length !== 0) {
1479
1501
  let notWatchingUnsynced = reallyUnsyncedAccesses.filter(x => !remoteWatcher.debugIsWatchingPath(x));
@@ -1705,12 +1727,12 @@ export class PathValueProxyWatcher {
1705
1727
  if (watcher.options.commitAllRuns) return false;
1706
1728
  // NOTE: We COULD remove any synced values from lastUnsyncedAccesses, however... we will generally sync all values at once, so we don't really need to optimize the cascading case here. Also... deleting values requires cloning while we iterate, as well as mutating the set, which probably makes the non-cascading case slower.
1707
1729
  for (let path of watcher.lastUnsyncedAccesses) {
1708
- if (!authorityStorage.isSynced(path)) {
1730
+ if (!currentReadSource.isSynced(path)) {
1709
1731
  return true;
1710
1732
  }
1711
1733
  }
1712
1734
  for (let path of watcher.lastUnsyncedParentAccesses) {
1713
- if (!authorityStorage.isParentSynced(path)) {
1735
+ if (!currentReadSource.isParentSynced(path)) {
1714
1736
  return true;
1715
1737
  }
1716
1738
  }
@@ -1723,13 +1745,13 @@ export class PathValueProxyWatcher {
1723
1745
  function logUnsynced() {
1724
1746
  let anyLogged = false;
1725
1747
  for (let path of watcher.lastUnsyncedAccesses) {
1726
- if (!authorityStorage.isSynced(path)) {
1748
+ if (!currentReadSource.isSynced(path)) {
1727
1749
  console.log(yellow(` Waiting for ${path}`));
1728
1750
  anyLogged = true;
1729
1751
  }
1730
1752
  }
1731
1753
  for (let path of watcher.lastUnsyncedParentAccesses) {
1732
- if (!authorityStorage.isParentSynced(path)) {
1754
+ if (!currentReadSource.isParentSynced(path)) {
1733
1755
  console.log(yellow(` Waiting for parent ${path}`));
1734
1756
  anyLogged = true;
1735
1757
  }
@@ -1997,7 +2019,7 @@ export class PathValueProxyWatcher {
1997
2019
  public async commitFunction<Result = void>(
1998
2020
  options: Omit<WatcherOptions<Result>, "onResultUpdated" | "onWriteCommitted">,
1999
2021
  config?: {
2000
- onWritesCommitted?: (writes: PathValue[]) => void;
2022
+ onWritesCommitted?: (writes: PathValue[], watcher: SyncWatcher) => void;
2001
2023
  }
2002
2024
  ): Promise<Result> {
2003
2025
  options = {
@@ -2029,7 +2051,7 @@ export class PathValueProxyWatcher {
2029
2051
  onResult(result.result);
2030
2052
  }
2031
2053
  if (writes && config?.onWritesCommitted) {
2032
- config.onWritesCommitted(writes);
2054
+ config.onWritesCommitted(writes, watcher);
2033
2055
  }
2034
2056
  }
2035
2057
  });
@@ -2131,12 +2153,12 @@ export class PathValueProxyWatcher {
2131
2153
  public reuseLastWatches() {
2132
2154
  let watcher = this.getTriggeredWatcher();
2133
2155
  for (let path of watcher.lastUnsyncedAccesses) {
2134
- if (!authorityStorage.isSynced(path)) {
2156
+ if (!currentReadSource.isSynced(path)) {
2135
2157
  watcher.pendingUnsyncedAccesses.add(path);
2136
2158
  }
2137
2159
  }
2138
2160
  for (let path of watcher.lastUnsyncedParentAccesses) {
2139
- if (!authorityStorage.isParentSynced(path)) {
2161
+ if (!currentReadSource.isParentSynced(path)) {
2140
2162
  watcher.pendingUnsyncedParentAccesses.add(path);
2141
2163
  }
2142
2164
  }
@@ -24,6 +24,7 @@ import { getGitRefSync, getGitURLSync } from "../4-deploy/git";
24
24
  import type { DeployProgress } from "../4-deploy/deployFunctions";
25
25
  import { getRoutingOverride, getRoutingOverridePart } from "../0-path-value-core/PathRouterRouteOverride";
26
26
  import { PathRouter } from "../0-path-value-core/PathRouter";
27
+ import { FunctionCaptureController, isCapturing, recordCapturedCall } from "./functionCapture";
27
28
  setImmediate(() => import("../4-querysub/Querysub"));
28
29
 
29
30
  let functionCallOuterCount = 0;
@@ -229,6 +230,7 @@ export class PathFunctionRunner {
229
230
  filterSelector?: FilterSelector;
230
231
  }) {
231
232
  SocketFunction.expose(FunctionPreloadController);
233
+ SocketFunction.expose(FunctionCaptureController);
232
234
  debugFunctionRunnerShards.push({
233
235
  domainName: config.domainName,
234
236
  shardRange: config.shardRange,
@@ -777,8 +779,26 @@ export class PathFunctionRunner {
777
779
  }
778
780
  },
779
781
  }, {
780
- onWritesCommitted(writes) {
782
+ onWritesCommitted(writes, watcher) {
781
783
  finalWrites = writes;
784
+ // Capture only runs that actually authored the Result (not nooped/partial runs),
785
+ // so a replayed call maps to exactly one recorded output state.
786
+ if (isCapturing()) {
787
+ let resultsPathStr = getProxyPath(() => functionSchema()[callSpec.DomainName].PathFunctionRunner[callSpec.ModuleId].Results[callSpec.CallId]);
788
+ let resultWrite = writes.find(w => w.path === resultsPathStr);
789
+ if (resultWrite) {
790
+ recordCapturedCall({
791
+ callSpec,
792
+ functionSpec,
793
+ writes,
794
+ watcher,
795
+ resultsPathStr,
796
+ resultWriteTime: resultWrite.time,
797
+ timeTaken: Date.now() - startTime,
798
+ evalTime,
799
+ });
800
+ }
801
+ }
782
802
  },
783
803
  });
784
804
  } catch (e: any) {
@@ -0,0 +1,213 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { runInfinitePoll } from "socket-function/src/batching";
3
+ import { timeInSecond } from "socket-function/src/misc";
4
+ import { authorityStorage, compareTime, PathValue, Time } from "../0-path-value-core/pathValueCore";
5
+ import { pathValueSerializer } from "../-h-path-value-serialize/PathValueSerializer";
6
+ import { requiresNetworkTrustHook } from "../-d-trust/NetworkTrust2";
7
+ import { getDomain } from "../config";
8
+ import { CaptureFileHeader, CaptureRecord, CAPTURE_FORMAT_VERSION, encodeCaptureFile, encodeCaptureRecord } from "./functionCaptureFormat";
9
+ import type { SyncWatcher } from "../2-proxy/PathValueProxyWatcher";
10
+ import type { CallSpec, FunctionSpec } from "./PathFunctionRunner";
11
+
12
+ // How long to wait after a run commits its Result before treating that run as final and flushing
13
+ // it into the capture buffer. Long enough to catch a fast rejection (which re-runs the call and
14
+ // supersedes this Result), but well under the Result path's event expiry, so the re-check below
15
+ // can still read the value to confirm it's still ours.
16
+ export const CAPTURE_FLUSH_DELAY = timeInSecond * 5;
17
+ const CAPTURE_FLUSH_POLL_INTERVAL = timeInSecond;
18
+
19
+ export interface CaptureStatus {
20
+ capturing: boolean;
21
+ // Records confirmed final + still awaiting the flush delay.
22
+ callCount: number;
23
+ finalizedCount: number;
24
+ pendingCount: number;
25
+ // Bytes of the finalized record frames (excludes header + pending).
26
+ byteCount: number;
27
+ startedAt: number;
28
+ }
29
+
30
+ interface PendingCapture {
31
+ record: CaptureRecord;
32
+ resultsPathStr: string;
33
+ resultWriteTime: Time;
34
+ }
35
+
36
+ let capturing = false;
37
+ let captureStartedAt = 0;
38
+ let finalizedFrames: Buffer[] = [];
39
+ let finalizedCount = 0;
40
+ let finalizedBytes = 0;
41
+ let pendingCaptures: PendingCapture[] = [];
42
+
43
+ export function isCapturing() {
44
+ return capturing;
45
+ }
46
+
47
+ function resetCaptureBuffer() {
48
+ finalizedFrames = [];
49
+ finalizedCount = 0;
50
+ finalizedBytes = 0;
51
+ pendingCaptures = [];
52
+ }
53
+
54
+ function readValue(pathValue: PathValue): unknown {
55
+ return pathValueSerializer.getPathValue(pathValue, "noMutate");
56
+ }
57
+
58
+ export function recordCapturedCall(config: {
59
+ callSpec: CallSpec;
60
+ functionSpec: FunctionSpec;
61
+ writes: PathValue[];
62
+ watcher: SyncWatcher;
63
+ resultsPathStr: string;
64
+ resultWriteTime: Time;
65
+ timeTaken: number;
66
+ evalTime: number;
67
+ }): void {
68
+ if (!capturing) return;
69
+
70
+ let { callSpec, functionSpec, writes, watcher } = config;
71
+
72
+ let reads: CaptureRecord["reads"] = [];
73
+ for (let values of watcher.pendingAccesses.values()) {
74
+ for (let { pathValue } of values.values()) {
75
+ reads.push({ path: pathValue.path, value: readValue(pathValue), time: pathValue.time });
76
+ }
77
+ }
78
+
79
+ let undefinedReads: CaptureRecord["undefinedReads"] = [];
80
+ for (let [readTime, paths] of watcher.pendingEpochAccesses) {
81
+ for (let path of paths) {
82
+ undefinedReads.push({ path, readTime });
83
+ }
84
+ }
85
+
86
+ let parentKeyReads = Array.from(watcher.lastWatches.parentPaths);
87
+
88
+ let writeRecords: CaptureRecord["writes"] = writes.map(w => ({
89
+ path: w.path,
90
+ value: readValue(w),
91
+ time: w.time,
92
+ isTransparent: w.isTransparent,
93
+ event: w.event,
94
+ }));
95
+
96
+ let record: CaptureRecord = {
97
+ callId: callSpec.CallId,
98
+ functionSpec,
99
+ argsEncoded: callSpec.argsEncoded,
100
+ runAtTime: callSpec.runAtTime,
101
+ callerMachineId: callSpec.callerMachineId,
102
+ callerIP: callSpec.callerIP,
103
+ reads,
104
+ undefinedReads,
105
+ parentKeyReads,
106
+ writes: writeRecords,
107
+ timeTaken: config.timeTaken,
108
+ evalTime: config.evalTime,
109
+ capturedAt: Date.now(),
110
+ };
111
+
112
+ pendingCaptures.push({
113
+ record,
114
+ resultsPathStr: config.resultsPathStr,
115
+ resultWriteTime: config.resultWriteTime,
116
+ });
117
+ }
118
+
119
+ // A record is only kept if the Result path still holds the exact write from this run. If it was
120
+ // rejected or clobbered by another runner, the current value's time won't match, and we drop it.
121
+ function isStillOurResult(pending: PendingCapture): boolean {
122
+ let current = authorityStorage.getValueAtOrBeforeTime(pending.resultsPathStr, undefined);
123
+ if (!current) return false;
124
+ return compareTime(current.time, pending.resultWriteTime) === 0;
125
+ }
126
+
127
+ function finalizePending(force: boolean) {
128
+ if (pendingCaptures.length === 0) return;
129
+ let now = Date.now();
130
+ let stillPending: PendingCapture[] = [];
131
+ for (let pending of pendingCaptures) {
132
+ if (!force && now - pending.record.capturedAt < CAPTURE_FLUSH_DELAY) {
133
+ stillPending.push(pending);
134
+ continue;
135
+ }
136
+ if (!isStillOurResult(pending)) continue;
137
+ let frame = encodeCaptureRecord(pending.record);
138
+ finalizedFrames.push(frame);
139
+ finalizedCount++;
140
+ finalizedBytes += frame.length;
141
+ }
142
+ pendingCaptures = stillPending;
143
+ }
144
+
145
+ let flushPollStarted = false;
146
+ function ensureFlushPoll() {
147
+ if (flushPollStarted) return;
148
+ flushPollStarted = true;
149
+ runInfinitePoll(CAPTURE_FLUSH_POLL_INTERVAL, () => finalizePending(false));
150
+ }
151
+
152
+ function getStatus(): CaptureStatus {
153
+ return {
154
+ capturing,
155
+ callCount: finalizedCount + pendingCaptures.length,
156
+ finalizedCount,
157
+ pendingCount: pendingCaptures.length,
158
+ byteCount: finalizedBytes,
159
+ startedAt: captureStartedAt,
160
+ };
161
+ }
162
+
163
+ class FunctionCaptureControllerBase {
164
+ public async startCapture(): Promise<CaptureStatus> {
165
+ resetCaptureBuffer();
166
+ captureStartedAt = Date.now();
167
+ capturing = true;
168
+ ensureFlushPoll();
169
+ return getStatus();
170
+ }
171
+
172
+ public async getStatus(): Promise<CaptureStatus> {
173
+ return getStatus();
174
+ }
175
+
176
+ // Stops capturing, force-finalizes anything still within the flush delay, and returns the
177
+ // encoded capture file. The buffer is retained until clearCapture so it can be re-downloaded.
178
+ public async stopCapture(): Promise<Buffer> {
179
+ capturing = false;
180
+ finalizePending(true);
181
+ let header: CaptureFileHeader = {
182
+ version: CAPTURE_FORMAT_VERSION,
183
+ createdAt: Date.now(),
184
+ domainName: getDomain(),
185
+ recordCount: finalizedCount,
186
+ };
187
+ return encodeCaptureFile(header, finalizedFrames);
188
+ }
189
+
190
+ public async clearCapture(): Promise<CaptureStatus> {
191
+ capturing = false;
192
+ resetCaptureBuffer();
193
+ captureStartedAt = 0;
194
+ return getStatus();
195
+ }
196
+ }
197
+
198
+ export const FunctionCaptureController = SocketFunction.register(
199
+ "FunctionCaptureController-2f1b8c4a-6e9d-4a71-9b2e-8c5f0d3a1e77",
200
+ new FunctionCaptureControllerBase(),
201
+ () => ({
202
+ startCapture: {},
203
+ getStatus: {},
204
+ stopCapture: {},
205
+ clearCapture: {},
206
+ }),
207
+ () => ({
208
+ hooks: [requiresNetworkTrustHook],
209
+ }),
210
+ {
211
+ noAutoExpose: true,
212
+ }
213
+ );
@@ -0,0 +1,103 @@
1
+ import { encodeCborx, decodeCborx } from "../misc/cloneHelpers";
2
+ import type { Time } from "../0-path-value-core/pathValueCore";
3
+ import type { FunctionSpec } from "./PathFunctionRunner";
4
+
5
+ // Custom binary container for captured function runs. Layout:
6
+ // [MAGIC (8 bytes)]
7
+ // [uint32 LE headerLength][header JSON (utf8)]
8
+ // repeated: [uint32 LE recordLength][record (CBOR)]
9
+ // The header is JSON (not CBOR) so future readers can pull version + metadata without
10
+ // understanding the record encoding, which lets us evolve the record format while
11
+ // staying backwards compatible.
12
+ export const CAPTURE_MAGIC = Buffer.from("QSFNCAP\0", "latin1");
13
+ export const CAPTURE_FORMAT_VERSION = 1;
14
+
15
+ export interface CaptureFileHeader {
16
+ version: number;
17
+ createdAt: number;
18
+ domainName?: string;
19
+ nodeId?: string;
20
+ entryPoint?: string;
21
+ recordCount: number;
22
+ // Free-form metadata, so we can add fields without a version bump.
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ // One captured, fully-resolved function run. Enough to replay it in isolation and compare
27
+ // the output state.
28
+ export interface CaptureRecord {
29
+ callId: string;
30
+ functionSpec: FunctionSpec;
31
+ argsEncoded: string;
32
+ // The explicit timestamp the run executed at. Drives all determinism on replay
33
+ // (Querysub.time()/Querysub.nextId() are derived from it).
34
+ runAtTime: Time;
35
+ callerMachineId: string;
36
+ callerIP: string;
37
+
38
+ // Values the proxy resolved for each read, keyed by the value's own write time so the
39
+ // read can be reconstructed via getValueAtOrBeforeTime on replay.
40
+ reads: { path: string; value: unknown; time: Time }[];
41
+ // Reads that resolved to no value (undefined) at the given read time.
42
+ undefinedReads: { path: string; readTime?: Time }[];
43
+ // Paths whose child keys were enumerated (getKeys / Object.keys), needed so lookup
44
+ // enumeration replays identically.
45
+ parentKeyReads: string[];
46
+
47
+ // Committed writes (the output state to compare against on replay).
48
+ writes: { path: string; value: unknown; time: Time; isTransparent?: boolean; event?: boolean }[];
49
+
50
+ timeTaken: number;
51
+ evalTime: number;
52
+ capturedAt: number;
53
+ }
54
+
55
+ function writeUInt32(value: number): Buffer {
56
+ let buffer = Buffer.alloc(4);
57
+ buffer.writeUInt32LE(value, 0);
58
+ return buffer;
59
+ }
60
+
61
+ export function encodeCaptureRecord(record: CaptureRecord): Buffer {
62
+ let body = encodeCborx(record);
63
+ return Buffer.concat([writeUInt32(body.length), body]);
64
+ }
65
+
66
+ export function encodeCaptureFile(header: CaptureFileHeader, recordFrames: Buffer[]): Buffer {
67
+ let headerBuffer = Buffer.from(JSON.stringify(header), "utf8");
68
+ return Buffer.concat([CAPTURE_MAGIC, writeUInt32(headerBuffer.length), headerBuffer, ...recordFrames]);
69
+ }
70
+
71
+ export function decodeCaptureFile(buffer: Buffer): { header: CaptureFileHeader; records: CaptureRecord[] } {
72
+ if (buffer.length < CAPTURE_MAGIC.length + 4) {
73
+ throw new Error(`Capture file too small to be valid (${buffer.length} bytes)`);
74
+ }
75
+ let magic = buffer.subarray(0, CAPTURE_MAGIC.length);
76
+ if (!magic.equals(CAPTURE_MAGIC)) {
77
+ throw new Error(`Capture file has invalid magic. Expected ${JSON.stringify(CAPTURE_MAGIC.toString("latin1"))}, was ${JSON.stringify(magic.toString("latin1"))}`);
78
+ }
79
+ let offset = CAPTURE_MAGIC.length;
80
+ let headerLength = buffer.readUInt32LE(offset);
81
+ offset += 4;
82
+ if (offset + headerLength > buffer.length) {
83
+ throw new Error(`Capture file header length (${headerLength}) exceeds file size (${buffer.length})`);
84
+ }
85
+ let header = JSON.parse(buffer.subarray(offset, offset + headerLength).toString("utf8")) as CaptureFileHeader;
86
+ offset += headerLength;
87
+
88
+ let records: CaptureRecord[] = [];
89
+ while (offset < buffer.length) {
90
+ if (offset + 4 > buffer.length) {
91
+ throw new Error(`Capture file truncated reading record length at offset ${offset}`);
92
+ }
93
+ let recordLength = buffer.readUInt32LE(offset);
94
+ offset += 4;
95
+ if (offset + recordLength > buffer.length) {
96
+ throw new Error(`Capture file truncated reading record body at offset ${offset} (length ${recordLength}, file size ${buffer.length})`);
97
+ }
98
+ let record = decodeCborx<CaptureRecord>(buffer.subarray(offset, offset + recordLength));
99
+ records.push(record);
100
+ offset += recordLength;
101
+ }
102
+ return { header, records };
103
+ }
@@ -0,0 +1,272 @@
1
+ import { epochTime, PathValue } from "../0-path-value-core/pathValueCore";
2
+ import { pathValueSerializer } from "../-h-path-value-serialize/PathValueSerializer";
3
+ import { isTransparentValue, PathValueReadSource, proxyWatcher, runWithPathValueReadSource } from "../2-proxy/PathValueProxyWatcher";
4
+ import { getPathFromStr, getParentPathStr } from "../path";
5
+ import { CaptureRecord } from "./functionCaptureFormat";
6
+ import { CallSpec, FunctionSpec, overrideCurrentCall } from "./PathFunctionRunner";
7
+ import { getModuleFromSpec } from "./pathFunctionLoader";
8
+ import { parseArgs } from "./PathFunctionHelpers";
9
+
10
+ export function makeReplayCallSpec(record: CaptureRecord): CallSpec {
11
+ return {
12
+ DomainName: record.functionSpec.DomainName,
13
+ ModuleId: record.functionSpec.ModuleId,
14
+ CallId: record.callId,
15
+ FunctionId: record.functionSpec.FunctionId,
16
+ argsEncoded: record.argsEncoded,
17
+ callerMachineId: record.callerMachineId,
18
+ callerIP: record.callerIP,
19
+ runAtTime: record.runAtTime,
20
+ };
21
+ }
22
+
23
+ function makePathValue(path: string, value: unknown, time: PathValue["time"]): PathValue {
24
+ return {
25
+ path,
26
+ value,
27
+ time,
28
+ valid: true,
29
+ locks: [],
30
+ lockCount: 0,
31
+ isValueLazy: false,
32
+ isTransparent: isTransparentValue(value),
33
+ canGCValue: value === undefined,
34
+ };
35
+ }
36
+
37
+ // A PathValueReadSource backed entirely by a single captured run's reads. Because the capture
38
+ // recorded every value the run actually read, everything is treated as synced, so a replay
39
+ // finishes in a single synchronous pass with no real database.
40
+ export function buildReplayReadSource(record: CaptureRecord): PathValueReadSource {
41
+ let values = new Map<string, PathValue>();
42
+ for (let read of record.reads) {
43
+ values.set(read.path, makePathValue(read.path, read.value, read.time));
44
+ }
45
+
46
+ // Direct-parent -> children index, so getKeys()/Object.keys() enumeration replays identically.
47
+ let parentIndex = new Map<string, Set<string>>();
48
+ let addToParentIndex = (path: string) => {
49
+ let parent = getParentPathStr(path);
50
+ let set = parentIndex.get(parent);
51
+ if (!set) {
52
+ set = new Set();
53
+ parentIndex.set(parent, set);
54
+ }
55
+ set.add(path);
56
+ };
57
+ for (let read of record.reads) addToParentIndex(read.path);
58
+ for (let read of record.undefinedReads) addToParentIndex(read.path);
59
+
60
+ let overrideStack: Map<string, PathValue>[] = [];
61
+ let readWithOverrides = (path: string): PathValue | undefined => {
62
+ for (let i = overrideStack.length - 1; i >= 0; i--) {
63
+ let value = overrideStack[i].get(path);
64
+ if (value) return value;
65
+ }
66
+ return values.get(path);
67
+ };
68
+
69
+ return {
70
+ getValueAtOrBeforeTime(pathStr) {
71
+ let stored = readWithOverrides(pathStr);
72
+ if (stored) return stored;
73
+ // Mirror real storage: a synced-but-empty path resolves to an epoch transparent value
74
+ // (equivalent to "no value", but synced), so uncaptured reads don't stall the run.
75
+ return { path: pathStr, valid: true, time: epochTime, locks: [], lockCount: 0, value: undefined, canGCValue: true, isTransparent: true };
76
+ },
77
+ isSynced() {
78
+ return true;
79
+ },
80
+ isParentSynced() {
81
+ return true;
82
+ },
83
+ getPathsFromParent(pathStr) {
84
+ return parentIndex.get(pathStr);
85
+ },
86
+ temporaryOverride(overrides, code) {
87
+ if (!overrides || overrides.length === 0) return code();
88
+ let map = new Map<string, PathValue>();
89
+ for (let override of overrides) map.set(override.path, override);
90
+ overrideStack.push(map);
91
+ try {
92
+ return code();
93
+ } finally {
94
+ overrideStack.pop();
95
+ }
96
+ },
97
+ DEBUG_hasAnyValues(pathStr) {
98
+ return values.has(pathStr);
99
+ },
100
+ };
101
+ }
102
+
103
+ export interface ReplayResult {
104
+ writes: PathValue[];
105
+ result: unknown;
106
+ }
107
+
108
+ // Runs the function against a captured run's data in complete isolation, using a dry-run watcher so
109
+ // nothing is committed. Fully synchronous (the captured data is all "synced").
110
+ export function replayCapturedCall(config: {
111
+ record: CaptureRecord;
112
+ baseFunction: Function;
113
+ args: unknown[];
114
+ }): ReplayResult {
115
+ let { record, baseFunction, args } = config;
116
+ let readSource = buildReplayReadSource(record);
117
+ let callSpec = makeReplayCallSpec(record);
118
+
119
+ let captured: ReplayResult | { error: string } | undefined;
120
+ runWithPathValueReadSource(readSource, () => {
121
+ proxyWatcher.createWatcher({
122
+ dryRun: true,
123
+ canWrite: true,
124
+ runImmediately: true,
125
+ allowUnsyncedReads: true,
126
+ temporary: true,
127
+ skipPermissionsCheck: true,
128
+ nestedCalls: "ignore",
129
+ runAtTime: record.runAtTime,
130
+ maxLocksOverride: Number.MAX_SAFE_INTEGER,
131
+ debugName: `replay ${record.functionSpec.ModuleId}.${record.functionSpec.FunctionId} ${record.callId}`,
132
+ watchFunction: () => {
133
+ overrideCurrentCall({ spec: callSpec, fnc: record.functionSpec }, () => {
134
+ baseFunction(...args);
135
+ });
136
+ },
137
+ onResultUpdated: (result, writes) => {
138
+ if ("error" in result) {
139
+ captured = { error: result.error };
140
+ } else {
141
+ captured = { writes: writes ?? [], result: result.result };
142
+ }
143
+ },
144
+ });
145
+ });
146
+
147
+ if (!captured) {
148
+ throw new Error(`Replay produced no result for ${record.callId}`);
149
+ }
150
+ if ("error" in captured) {
151
+ throw new Error(`Replay threw for ${record.functionSpec.ModuleId}.${record.functionSpec.FunctionId} (${record.callId}): ${captured.error}`);
152
+ }
153
+ return captured;
154
+ }
155
+
156
+ export interface ReplayComparison {
157
+ identical: boolean;
158
+ matched: number;
159
+ mismatched: { path: string; captured: unknown; replayed: unknown }[];
160
+ // In the capture, but the replay didn't write it.
161
+ missing: { path: string; captured: unknown }[];
162
+ // The replay wrote it, but the capture didn't have it.
163
+ extra: { path: string; replayed: unknown }[];
164
+ }
165
+
166
+ function stableStringify(value: unknown): string {
167
+ return JSON.stringify(value, (_key, val) => {
168
+ if (val && typeof val === "object" && !Array.isArray(val)) {
169
+ let sorted: { [key: string]: unknown } = {};
170
+ for (let key of Object.keys(val).sort()) {
171
+ sorted[key] = (val as { [key: string]: unknown })[key];
172
+ }
173
+ return sorted;
174
+ }
175
+ return val;
176
+ });
177
+ }
178
+
179
+ // Compares the writes a replay produced against the writes originally captured. Times/locks are
180
+ // ignored (they're assigned fresh on every run); only path -> value output state is compared.
181
+ export function compareCapturedWrites(record: CaptureRecord, replayWrites: PathValue[]): ReplayComparison {
182
+ let capturedMap = new Map<string, unknown>();
183
+ for (let write of record.writes) capturedMap.set(write.path, write.value);
184
+
185
+ let replayMap = new Map<string, unknown>();
186
+ for (let write of replayWrites) replayMap.set(write.path, pathValueSerializer.getPathValue(write));
187
+
188
+ let matched = 0;
189
+ let mismatched: ReplayComparison["mismatched"] = [];
190
+ let missing: ReplayComparison["missing"] = [];
191
+ let extra: ReplayComparison["extra"] = [];
192
+
193
+ for (let [path, capturedValue] of capturedMap) {
194
+ if (!replayMap.has(path)) {
195
+ missing.push({ path, captured: capturedValue });
196
+ continue;
197
+ }
198
+ let replayedValue = replayMap.get(path);
199
+ if (stableStringify(capturedValue) === stableStringify(replayedValue)) {
200
+ matched++;
201
+ } else {
202
+ mismatched.push({ path, captured: capturedValue, replayed: replayedValue });
203
+ }
204
+ }
205
+ for (let [path, replayedValue] of replayMap) {
206
+ if (!capturedMap.has(path)) {
207
+ extra.push({ path, replayed: replayedValue });
208
+ }
209
+ }
210
+
211
+ return {
212
+ identical: mismatched.length === 0 && missing.length === 0 && extra.length === 0,
213
+ matched,
214
+ mismatched,
215
+ missing,
216
+ extra,
217
+ };
218
+ }
219
+
220
+ async function loadBaseFunction(spec: FunctionSpec): Promise<Function> {
221
+ let module = await getModuleFromSpec(spec);
222
+ let exportObj: any = module.exports;
223
+ for (let part of getPathFromStr(spec.exportPathStr)) {
224
+ exportObj = exportObj?.[part];
225
+ }
226
+ if (typeof exportObj !== "function") {
227
+ throw new Error(`Export at ${JSON.stringify(spec.exportPathStr)} in ${spec.FilePath} was not a function (it was ${typeof exportObj}). The captured code may not be loadable in this environment.`);
228
+ }
229
+ return exportObj as Function;
230
+ }
231
+
232
+ export interface ReplayRecordReport {
233
+ callId: string;
234
+ functionName: string;
235
+ ok: boolean;
236
+ error?: string;
237
+ comparison?: ReplayComparison;
238
+ }
239
+
240
+ export interface ReplaySummary {
241
+ total: number;
242
+ identical: number;
243
+ divergent: number;
244
+ errored: number;
245
+ reports: ReplayRecordReport[];
246
+ }
247
+
248
+ // Loads each captured function and replays it in isolation, comparing the replayed writes against
249
+ // the originally captured writes. Requires the app's modules to be loadable (import its deploy.ts
250
+ // before calling this, so development modules are registered).
251
+ export async function replayCaptureRecords(records: CaptureRecord[]): Promise<ReplaySummary> {
252
+ let reports: ReplayRecordReport[] = [];
253
+ for (let record of records) {
254
+ let functionName = `${record.functionSpec.ModuleId}.${record.functionSpec.FunctionId}`;
255
+ try {
256
+ let baseFunction = await loadBaseFunction(record.functionSpec);
257
+ let args = parseArgs(makeReplayCallSpec(record));
258
+ let replay = replayCapturedCall({ record, baseFunction, args });
259
+ let comparison = compareCapturedWrites(record, replay.writes);
260
+ reports.push({ callId: record.callId, functionName, ok: comparison.identical, comparison });
261
+ } catch (e: any) {
262
+ reports.push({ callId: record.callId, functionName, ok: false, error: String(e?.stack || e) });
263
+ }
264
+ }
265
+ return {
266
+ total: reports.length,
267
+ identical: reports.filter(r => r.ok).length,
268
+ divergent: reports.filter(r => !r.ok && !r.error).length,
269
+ errored: reports.filter(r => r.error).length,
270
+ reports,
271
+ };
272
+ }
@@ -18,7 +18,7 @@ import { RequireController, setRequireBootRequire } from "socket-function/requir
18
18
  import { cache, cacheLimited, lazy } from "socket-function/src/caching";
19
19
  import { getIdentityCAPromise, getOwnMachineId, getOwnThreadId, getThreadKeyCert, verifyMachineIdForPublicKey } from "sliftutils/misc/https/certs";
20
20
  import { getHostedIP, getSNICerts, publishMachineARecords } from "../-e-certs/EdgeCertController";
21
- import { debugCoreMode, registerGetCompressNetwork, authorityStorage, enableDebugRejections } from "../0-path-value-core/pathValueCore";
21
+ import { debugCoreMode, registerGetCompressNetwork, authorityStorage, enableDebugRejections, getNextTime } from "../0-path-value-core/pathValueCore";
22
22
  import { clientWatcher, ClientWatcher } from "../1-path-client/pathValueClientWatcher";
23
23
  import { SyncWatcher, proxyWatcher, specialObjectWriteValue, isSynced, PathValueProxyWatcher, atomic, doAtomicWrites, noAtomicSchema, undeleteFromLookup, registerSchemaPrefix, WatcherOptions, doProxyOptions } from "../2-proxy/PathValueProxyWatcher";
24
24
  import { isInProxyDatabase, rawSchema } from "../2-proxy/pathDatabaseProxyBase";
@@ -1204,6 +1204,9 @@ function getSyncedTimeUnique() {
1204
1204
  }
1205
1205
 
1206
1206
  function getSyncedTime() {
1207
+ if (!Querysub.isInSyncedCall()) {
1208
+ return Date.now();
1209
+ }
1207
1210
  let setTime = getSetTime();
1208
1211
  if (setTime !== undefined) {
1209
1212
  return setTime;
@@ -123,6 +123,17 @@ export async function registerManagementPages2(config: {
123
123
  controllerName: "DNSPageController",
124
124
  getModule: () => import("./misc-pages/DNSPage"),
125
125
  });
126
+ inputPages.push({
127
+ title: "Fnc Capture",
128
+ componentName: "FunctionCapturePage",
129
+ controllerName: "FunctionCaptureProxyController",
130
+ getModule: () => import("./misc-pages/FunctionCapturePage"),
131
+ });
132
+ inputPages.push({
133
+ title: "Capture Analyzer",
134
+ componentName: "FunctionCaptureAnalyzePage",
135
+ getModule: () => import("./misc-pages/FunctionCaptureAnalyzePage"),
136
+ });
126
137
  inputPages.push({
127
138
  title: "LOG VIEWER",
128
139
  componentName: "LogViewer3",
@@ -0,0 +1,220 @@
1
+ module.allowclient = true;
2
+
3
+ import { qreact } from "../../4-dom/qreact";
4
+ import { css } from "typesafecss";
5
+ import { Querysub, t } from "../../4-querysub/Querysub";
6
+ import { Button } from "../../library-components/Button";
7
+ import { formatNumber, formatTime, formatDateTime } from "socket-function/src/formatting/format";
8
+ import { green, red } from "socket-function/src/formatting/logColors";
9
+ import { getPathStr2 } from "../../path";
10
+ import { sort } from "socket-function/src/misc";
11
+ import { CaptureRecord, CaptureFileHeader, decodeCaptureFile } from "../../3-path-functions/functionCaptureFormat";
12
+
13
+ // How many rows of a read/write list we show before requiring a "Show more" click.
14
+ const ROW_LIMIT = 50;
15
+
16
+ interface ParsedCapture {
17
+ header: CaptureFileHeader;
18
+ records: CaptureRecord[];
19
+ fileName: string;
20
+ }
21
+
22
+ export class FunctionCaptureAnalyzePage extends qreact.Component {
23
+ state = t.state({
24
+ error: t.string(""),
25
+ data: t.atomic<ParsedCapture>(),
26
+ expandedGroups: t.lookup(t.boolean),
27
+ expandedCalls: t.lookup(t.boolean),
28
+ // Section id => how many rows to show.
29
+ rowLimits: t.lookup(t.number),
30
+ });
31
+
32
+ private async loadFile(file: File | undefined) {
33
+ if (!file) return;
34
+ try {
35
+ let buffer = Buffer.from(await file.arrayBuffer());
36
+ let { header, records } = decodeCaptureFile(buffer);
37
+ Querysub.localCommit(() => {
38
+ this.state.data = { header, records, fileName: file.name };
39
+ this.state.error = "";
40
+ for (let key of Object.keys(this.state.expandedGroups)) delete this.state.expandedGroups[key];
41
+ for (let key of Object.keys(this.state.expandedCalls)) delete this.state.expandedCalls[key];
42
+ });
43
+ console.log(green(`Loaded capture ${file.name}: ${records.length} records`));
44
+ } catch (e: any) {
45
+ Querysub.localCommit(() => {
46
+ this.state.error = String(e?.stack || e);
47
+ });
48
+ console.error(red(`Failed to parse capture: ${e.stack}`));
49
+ }
50
+ }
51
+
52
+ render() {
53
+ let data = this.state.data;
54
+ return <div className={css.pad2(10).vbox(14).fillWidth}>
55
+ <h1>Function Capture Analyzer</h1>
56
+ {this.renderDropZone()}
57
+ {this.state.error && <div className={css.colorhsl(0, 70, 60).whiteSpace("pre-wrap")}>{this.state.error}</div>}
58
+ {data && this.renderData(data)}
59
+ </div>;
60
+ }
61
+
62
+ private renderDropZone() {
63
+ return <div
64
+ className={css.fillWidth.pad2(24).bord2(210, 30, 45, 2, "dashed").hsl(210, 40, 88).button.textAlign("center")}
65
+ onDragOver={e => e.preventDefault()}
66
+ onDrop={e => {
67
+ e.preventDefault();
68
+ void this.loadFile(e.dataTransfer?.files?.[0]);
69
+ }}
70
+ onClick={() => {
71
+ let input = document.createElement("input");
72
+ input.type = "file";
73
+ input.accept = ".qsfncap";
74
+ input.onchange = () => void this.loadFile(input.files?.[0]);
75
+ input.click();
76
+ }}
77
+ >
78
+ Drop a .qsfncap capture file here, or click to choose one.
79
+ </div>;
80
+ }
81
+
82
+ private renderData(data: ParsedCapture) {
83
+ let groups = groupRecords(data.records);
84
+ return <div className={css.vbox(12).fillWidth}>
85
+ <div className={css.vbox(2)}>
86
+ <div className={css.fontWeight("bold")}>{data.fileName}</div>
87
+ <div className={css.fontSize(12).colorhsl(0, 0, 60)}>
88
+ version {data.header.version} · {formatNumber(data.records.length)} calls · {data.header.domainName || "?"} · created {formatDateTime(data.header.createdAt)}
89
+ </div>
90
+ </div>
91
+ <div className={css.vbox(1).fillWidth}>
92
+ {groups.map(group => this.renderGroup(group))}
93
+ </div>
94
+ </div>;
95
+ }
96
+
97
+ private renderGroup(group: FunctionGroup) {
98
+ let expanded = !!this.state.expandedGroups[group.key];
99
+ return <div className={css.vbox(1).fillWidth.borderBottom("1px solid hsla(0,0%,0%,0.15)")}>
100
+ <div
101
+ className={css.hbox(16).fillWidth.pad2(8, 6).button}
102
+ onClick={() => Querysub.localCommit(() => { this.state.expandedGroups[group.key] = !expanded; })}
103
+ >
104
+ <div className={css.width(16)}>{expanded ? "▾" : "▸"}</div>
105
+ <div className={css.width(420).vbox(2)}>
106
+ <div className={css.fontWeight("bold")}>{group.functionName}</div>
107
+ <div className={css.fontSize(11).colorhsl(0, 0, 50)}>{group.filePath}</div>
108
+ </div>
109
+ <div className={css.width(90)}>{formatNumber(group.records.length)} calls</div>
110
+ <div className={css.width(140)}>avg {formatTime(group.avgEvalTime)}</div>
111
+ </div>
112
+ {expanded &&
113
+ <div className={css.vbox(1).fillWidth.pad2(24, 0)}>
114
+ {group.records.map((record, i) => this.renderCall(group.key, record, i))}
115
+ </div>
116
+ }
117
+ </div>;
118
+ }
119
+
120
+ private renderCall(groupKey: string, record: CaptureRecord, index: number) {
121
+ let callKey = getPathStr2(groupKey, record.callId);
122
+ let expanded = !!this.state.expandedCalls[callKey];
123
+ return <div className={css.vbox(2).fillWidth.borderBottom("1px solid hsla(0,0%,0%,0.1)")}>
124
+ <div
125
+ className={css.hbox(16).fillWidth.pad2(6, 4).button}
126
+ onClick={() => Querysub.localCommit(() => { this.state.expandedCalls[callKey] = !expanded; })}
127
+ >
128
+ <div className={css.width(16)}>{expanded ? "▾" : "▸"}</div>
129
+ <div className={css.width(220).ellipsis}>call #{index + 1} · {record.callId}</div>
130
+ <div className={css.width(120)}>{formatNumber(record.reads.length)} reads</div>
131
+ <div className={css.width(120)}>{formatNumber(record.writes.length)} writes</div>
132
+ <div className={css.width(120)}>eval {formatTime(record.evalTime)}</div>
133
+ <div className={css.width(180)}>runAt {formatDateTime(record.runAtTime.time)}</div>
134
+ </div>
135
+ {expanded && this.renderCallDetail(callKey, record)}
136
+ </div>;
137
+ }
138
+
139
+ private renderCallDetail(callKey: string, record: CaptureRecord) {
140
+ return <div className={css.vbox(10).fillWidth.pad2(24, 6)}>
141
+ <div className={css.fontSize(12).colorhsl(0, 0, 60)}>args: {shortValue(record.argsEncoded)}</div>
142
+ {this.renderSection(getPathStr2(callKey, "reads"), "Reads", record.reads.map(r => `${r.path} = ${shortValue(r.value)}`))}
143
+ {record.undefinedReads.length > 0 &&
144
+ this.renderSection(getPathStr2(callKey, "undef"), "Undefined reads", record.undefinedReads.map(r => r.path))}
145
+ {record.parentKeyReads.length > 0 &&
146
+ this.renderSection(getPathStr2(callKey, "keys"), "Enumerated (getKeys)", record.parentKeyReads)}
147
+ {this.renderSection(getPathStr2(callKey, "writes"), "Writes", record.writes.map(w => `${w.path} = ${shortValue(w.value)}${w.isTransparent ? " (transparent)" : ""}`))}
148
+ </div>;
149
+ }
150
+
151
+ private renderSection(sectionId: string, title: string, rows: string[]) {
152
+ let limit = ROW_LIMIT;
153
+ if (sectionId in this.state.rowLimits) {
154
+ limit = Number(this.state.rowLimits[sectionId]);
155
+ }
156
+ let shown = rows.slice(0, limit);
157
+ let remaining = rows.length - shown.length;
158
+ return <div className={css.vbox(2).fillWidth}>
159
+ <div className={css.fontWeight("bold")}>{title} ({formatNumber(rows.length)})</div>
160
+ <div className={css.vbox(1).fillWidth.fontFamily("monospace").fontSize(12)}>
161
+ {shown.map(row => <div className={css.whiteSpace("pre-wrap").wordBreak("break-all")}>{row}</div>)}
162
+ </div>
163
+ {remaining > 0 &&
164
+ <Button onClick={() => Querysub.localCommit(() => { this.state.rowLimits[sectionId] = limit + ROW_LIMIT; })}>
165
+ Show {Math.min(ROW_LIMIT, remaining)} more ({formatNumber(remaining)} remaining)
166
+ </Button>
167
+ }
168
+ </div>;
169
+ }
170
+ }
171
+
172
+ interface FunctionGroup {
173
+ key: string;
174
+ functionName: string;
175
+ filePath: string;
176
+ avgEvalTime: number;
177
+ records: CaptureRecord[];
178
+ }
179
+
180
+ function groupRecords(records: CaptureRecord[]): FunctionGroup[] {
181
+ let map = new Map<string, CaptureRecord[]>();
182
+ for (let record of records) {
183
+ let key = getPathStr2(record.functionSpec.ModuleId, record.functionSpec.FunctionId);
184
+ let list = map.get(key);
185
+ if (!list) {
186
+ list = [];
187
+ map.set(key, list);
188
+ }
189
+ list.push(record);
190
+ }
191
+ let groups: FunctionGroup[] = [];
192
+ for (let [key, list] of map) {
193
+ let spec = list[0].functionSpec;
194
+ let totalEval = list.reduce((sum, r) => sum + r.evalTime, 0);
195
+ let exportName = spec.exportPathStr ? spec.exportPathStr.split("/").slice(1).join(".") : spec.FunctionId;
196
+ groups.push({
197
+ key,
198
+ functionName: `${spec.ModuleId} · ${exportName}`,
199
+ filePath: spec.FilePath,
200
+ avgEvalTime: totalEval / list.length,
201
+ records: list,
202
+ });
203
+ }
204
+ sort(groups, group => -group.records.length);
205
+ return groups;
206
+ }
207
+
208
+ function shortValue(value: unknown): string {
209
+ let text: string;
210
+ try {
211
+ text = typeof value === "string" ? value : JSON.stringify(value);
212
+ } catch {
213
+ text = String(value);
214
+ }
215
+ if (text === undefined) text = String(value);
216
+ if (text.length > 300) {
217
+ text = text.slice(0, 300) + `… (${formatNumber(text.length)} chars)`;
218
+ }
219
+ return text;
220
+ }
@@ -0,0 +1,192 @@
1
+ module.allowclient = true;
2
+
3
+ import { qreact } from "../../4-dom/qreact";
4
+ import { SocketFunction } from "socket-function/SocketFunction";
5
+ import { getBrowserUrlNode } from "../../-f-node-discovery/NodeDiscovery";
6
+ import { css } from "typesafecss";
7
+ import { Querysub, t } from "../../4-querysub/Querysub";
8
+ import { Button } from "../../library-components/Button";
9
+ import { ATag } from "../../library-components/ATag";
10
+ import { formatDateTime, formatNumber, formatTime } from "socket-function/src/formatting/format";
11
+ import { green, red } from "socket-function/src/formatting/logColors";
12
+ import { assertIsManagementUser, managementPageURL } from "../managementPages";
13
+ import { getControllerNodeIdList } from "../../-g-core-values/NodeCapabilities";
14
+ import { errorToUndefined } from "../../errors";
15
+ import { CaptureStatus, FunctionCaptureController } from "../../3-path-functions/functionCapture";
16
+
17
+ interface RunnerStatus {
18
+ nodeId: string;
19
+ entryPoint: string;
20
+ status?: CaptureStatus;
21
+ }
22
+
23
+ const REFRESH_INTERVAL = 1000;
24
+
25
+ export class FunctionCapturePage extends qreact.Component {
26
+ state = t.state({
27
+ statuses: t.atomic<RunnerStatus[]>(),
28
+ busy: t.lookup(t.boolean),
29
+ error: t.string(""),
30
+ });
31
+
32
+ private intervalId: ReturnType<typeof setInterval> | undefined;
33
+
34
+ componentDidMount() {
35
+ void this.refresh();
36
+ this.intervalId = setInterval(() => void this.refresh(), REFRESH_INTERVAL);
37
+ }
38
+ componentWillUnmount() {
39
+ if (this.intervalId) clearInterval(this.intervalId);
40
+ }
41
+
42
+ private async refresh() {
43
+ try {
44
+ let statuses = await FunctionCaptureProxyController.nodes[getBrowserUrlNode()].getStatuses();
45
+ Querysub.localCommit(() => {
46
+ this.state.statuses = statuses;
47
+ this.state.error = "";
48
+ });
49
+ } catch (e: any) {
50
+ Querysub.localCommit(() => {
51
+ this.state.error = String(e?.stack || e);
52
+ });
53
+ }
54
+ }
55
+
56
+ render() {
57
+ let statuses = this.state.statuses;
58
+ return <div className={css.pad2(10).vbox(14).fillWidth}>
59
+ <h1>Function Capture</h1>
60
+ <div>Turn on capturing to record fully-resolved function runs (reads, writes, timings) on a runner. Stop to download a capture file, then analyze it in the <ATag values={[{ param: managementPageURL, value: "FunctionCaptureAnalyzePage" }]}>Capture Analyzer</ATag>.</div>
61
+ {this.state.error && <div className={css.colorhsl(0, 70, 60)}>{this.state.error}</div>}
62
+ {!statuses && <div>Loading runners…</div>}
63
+ {statuses && statuses.length === 0 && <div>No function runners found.</div>}
64
+ {statuses && statuses.length > 0 &&
65
+ <div className={css.vbox(1).fillWidth}>
66
+ <div className={css.hbox(20).fillWidth.borderBottom("1px solid hsla(0,0%,0%,0.3)").pad2(6, 4).fontWeight("bold")}>
67
+ <div className={css.width(280)}>Runner</div>
68
+ <div className={css.width(90)}>Capturing</div>
69
+ <div className={css.width(90)}>Calls</div>
70
+ <div className={css.width(120)}>Size</div>
71
+ <div className={css.width(140)}>Started</div>
72
+ <div>Actions</div>
73
+ </div>
74
+ {statuses.map(runner => this.renderRunner(runner))}
75
+ </div>
76
+ }
77
+ </div>;
78
+ }
79
+
80
+ private renderRunner(runner: RunnerStatus) {
81
+ let status = runner.status;
82
+ let busy = runner.nodeId in this.state.busy;
83
+ return <div className={css.hbox(20).fillWidth.borderBottom("1px solid hsla(0,0%,0%,0.15)").pad2(6, 4)}>
84
+ <div className={css.width(280).vbox(2)}>
85
+ <div>{runner.entryPoint}</div>
86
+ <div className={css.fontSize(11).colorhsl(0, 0, 45).ellipsis.maxWidth(280)}>{runner.nodeId}</div>
87
+ </div>
88
+ <div className={css.width(90)}>{!status ? "?" : status.capturing ? "● live" : "off"}</div>
89
+ <div className={css.width(90)}>{status ? formatNumber(status.callCount) : "-"}{status && status.pendingCount > 0 && ` (+${status.pendingCount})`}</div>
90
+ <div className={css.width(120)}>{status ? `${formatNumber(status.byteCount)} B` : "-"}</div>
91
+ <div className={css.width(140)}>{status && status.startedAt ? formatTime(Date.now() - status.startedAt) + " ago" : "-"}</div>
92
+ <div className={css.hbox(8)}>
93
+ {status && !status.capturing &&
94
+ <Button disabled={busy} onClick={() => this.runAction(runner, "start")}>Start</Button>
95
+ }
96
+ {status && status.capturing &&
97
+ <Button disabled={busy} onClick={() => this.runAction(runner, "stop")}>Stop &amp; Download</Button>
98
+ }
99
+ {status && !status.capturing && (status.callCount > 0) &&
100
+ <Button disabled={busy} onClick={() => this.downloadCapture(runner)}>Download</Button>
101
+ }
102
+ {status && !status.capturing && (status.callCount > 0) &&
103
+ <Button disabled={busy} onClick={() => this.runAction(runner, "clear")}>Clear</Button>
104
+ }
105
+ </div>
106
+ </div>;
107
+ }
108
+
109
+ private async runAction(runner: RunnerStatus, action: "start" | "stop" | "clear") {
110
+ if (Querysub.fastRead(() => runner.nodeId in this.state.busy)) return;
111
+ Querysub.localCommit(() => { this.state.busy[runner.nodeId] = true; });
112
+ try {
113
+ let proxy = FunctionCaptureProxyController.nodes[getBrowserUrlNode()];
114
+ if (action === "start") {
115
+ await proxy.startCapture({ nodeId: runner.nodeId });
116
+ } else if (action === "clear") {
117
+ await proxy.clearCapture({ nodeId: runner.nodeId });
118
+ } else if (action === "stop") {
119
+ let buffer = await proxy.stopCapture({ nodeId: runner.nodeId });
120
+ downloadBuffer(buffer, runner);
121
+ }
122
+ console.log(green(`${action} capture on ${runner.entryPoint}`));
123
+ } catch (e: any) {
124
+ console.error(red(`Failed to ${action} capture on ${runner.nodeId}: ${e.stack}`));
125
+ } finally {
126
+ Querysub.localCommit(() => { delete this.state.busy[runner.nodeId]; });
127
+ void this.refresh();
128
+ }
129
+ }
130
+
131
+ private async downloadCapture(runner: RunnerStatus) {
132
+ if (Querysub.fastRead(() => runner.nodeId in this.state.busy)) return;
133
+ Querysub.localCommit(() => { this.state.busy[runner.nodeId] = true; });
134
+ try {
135
+ let buffer = await FunctionCaptureProxyController.nodes[getBrowserUrlNode()].stopCapture({ nodeId: runner.nodeId });
136
+ downloadBuffer(buffer, runner);
137
+ } catch (e: any) {
138
+ console.error(red(`Failed to download capture on ${runner.nodeId}: ${e.stack}`));
139
+ } finally {
140
+ Querysub.localCommit(() => { delete this.state.busy[runner.nodeId]; });
141
+ void this.refresh();
142
+ }
143
+ }
144
+ }
145
+
146
+ function downloadBuffer(buffer: Buffer, runner: RunnerStatus) {
147
+ let blob = new Blob([buffer]);
148
+ let url = URL.createObjectURL(blob);
149
+ let anchor = document.createElement("a");
150
+ anchor.href = url;
151
+ let label = runner.entryPoint.replace(/[^A-Za-z0-9_.-]+/g, "_");
152
+ anchor.download = `function-capture-${label}-${formatDateTime(Date.now())}.qsfncap`;
153
+ document.body.appendChild(anchor);
154
+ anchor.click();
155
+ document.body.removeChild(anchor);
156
+ URL.revokeObjectURL(url);
157
+ }
158
+
159
+ // Management-side proxy: pages can't talk to runner processes directly, so this endpoint (running
160
+ // where management pages are hosted) fans out to the runner nodes over trusted server-to-server calls.
161
+ class FunctionCaptureProxyControllerBase {
162
+ public async getStatuses(): Promise<RunnerStatus[]> {
163
+ let runners = await getControllerNodeIdList(FunctionCaptureController);
164
+ return await Promise.all(runners.map(async runner => {
165
+ let status = await errorToUndefined(FunctionCaptureController.nodes[runner.nodeId].getStatus());
166
+ return { nodeId: runner.nodeId, entryPoint: runner.entryPoint, status };
167
+ }));
168
+ }
169
+ public async startCapture(config: { nodeId: string }): Promise<CaptureStatus> {
170
+ return await FunctionCaptureController.nodes[config.nodeId].startCapture();
171
+ }
172
+ public async clearCapture(config: { nodeId: string }): Promise<CaptureStatus> {
173
+ return await FunctionCaptureController.nodes[config.nodeId].clearCapture();
174
+ }
175
+ public async stopCapture(config: { nodeId: string }): Promise<Buffer> {
176
+ return await FunctionCaptureController.nodes[config.nodeId].stopCapture();
177
+ }
178
+ }
179
+
180
+ export const FunctionCaptureProxyController = SocketFunction.register(
181
+ "FunctionCaptureProxyController-9c4e2b71-5a83-4d19-8e0f-3b6a7c2d1f42",
182
+ new FunctionCaptureProxyControllerBase(),
183
+ () => ({
184
+ getStatuses: {},
185
+ startCapture: {},
186
+ clearCapture: {},
187
+ stopCapture: {},
188
+ }),
189
+ () => ({
190
+ hooks: [assertIsManagementUser],
191
+ }),
192
+ );