querysub 0.490.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/.claude/settings.local.json +2 -1
- package/package.json +1 -1
- package/src/-e-certs/certAuthority.ts +13 -3
- package/src/2-proxy/PathValueProxyWatcher.ts +43 -21
- package/src/3-path-functions/PathFunctionRunner.ts +21 -1
- package/src/3-path-functions/functionCapture.ts +213 -0
- package/src/3-path-functions/functionCaptureFormat.ts +103 -0
- package/src/3-path-functions/functionReplay.ts +272 -0
- package/src/4-querysub/Querysub.ts +5 -2
- package/src/diagnostics/managementPages.tsx +11 -0
- package/src/diagnostics/misc-pages/FunctionCaptureAnalyzePage.tsx +220 -0
- package/src/diagnostics/misc-pages/FunctionCapturePage.tsx +192 -0
- package/tsconfig.json +14 -1
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import acme from "acme-client";
|
|
2
|
-
import
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { parseCert } from "sliftutils/misc/https/certs";
|
|
3
4
|
import { cache, lazy } from "socket-function/src/caching";
|
|
4
5
|
import { hasDNSWritePermissions, setRecord } from "../-b-authorities/dnsAuthority";
|
|
5
6
|
import { magenta } from "socket-function/src/formatting/logColors";
|
|
@@ -13,6 +14,8 @@ import { timeInMinute } from "socket-function/src/misc";
|
|
|
13
14
|
const archives = lazy(() => getArchives(`https_certs_3/`));
|
|
14
15
|
// Expire EXPIRATION_THRESHOLD% of the way through the certificate's lifetime
|
|
15
16
|
const EXPIRATION_THRESHOLD = 0.4;
|
|
17
|
+
// Let's Encrypt only accepts RSA or ECDSA account keys (not Ed25519, which is all sliftutils/certs generates).
|
|
18
|
+
const ACCOUNT_KEY_MODULUS_BITS = 2048;
|
|
16
19
|
|
|
17
20
|
export const getHTTPSKeyCert = cache(async (domain: string): Promise<{ key: string; cert: string }> => {
|
|
18
21
|
if (!await hasDNSWritePermissions()) {
|
|
@@ -112,13 +115,20 @@ let getAccountKey: () => string = lazy(() => {
|
|
|
112
115
|
let keyCache = accountKeyCache.value;
|
|
113
116
|
if (!keyCache) {
|
|
114
117
|
console.log(`Generating new letsencrypt account key`);
|
|
115
|
-
|
|
116
|
-
keyCache = { key: privateKeyToPem(keyPair.privateKey) };
|
|
118
|
+
keyCache = { key: generateRSAPrivateKeyPem() };
|
|
117
119
|
accountKeyCache.value = keyCache;
|
|
118
120
|
}
|
|
119
121
|
return keyCache.key;
|
|
120
122
|
});
|
|
121
123
|
|
|
124
|
+
function generateRSAPrivateKeyPem(): string {
|
|
125
|
+
return crypto.generateKeyPairSync("rsa", {
|
|
126
|
+
modulusLength: ACCOUNT_KEY_MODULUS_BITS,
|
|
127
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
128
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
129
|
+
}).privateKey;
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
async function generateCert(config: {
|
|
123
133
|
accountKey: string;
|
|
124
134
|
domain: string;
|
|
@@ -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
|
-
&&
|
|
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 =
|
|
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 (!
|
|
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 (!
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
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 => !
|
|
1461
|
-
let remainingUnsyncedParent = Array.from(watcher.lastUnsyncedParentAccesses).filter(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 => !
|
|
1476
|
-
let reallyUnsyncedParentAccesses = Array.from(watcher.lastUnsyncedParentAccesses).filter(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 (!
|
|
1730
|
+
if (!currentReadSource.isSynced(path)) {
|
|
1709
1731
|
return true;
|
|
1710
1732
|
}
|
|
1711
1733
|
}
|
|
1712
1734
|
for (let path of watcher.lastUnsyncedParentAccesses) {
|
|
1713
|
-
if (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
+
}
|