querysub 0.452.0 → 0.454.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 +8 -1
- package/package.json +1 -1
- package/src/-a-archives/archivesBackBlaze.ts +28 -24
- package/src/0-path-value-core/pathValueArchives.ts +4 -3
- package/src/4-querysub/Querysub.ts +15 -13
- package/src/deployManager/LaunchTrackingHeader.tsx +10 -14
- package/src/deployManager/machineApplyMainCode.ts +94 -0
- package/src/diagnostics/debugger/mcp-server.ts +326 -52
- package/src/diagnostics/misc-pages/AuthoritySpecPage.tsx +24 -23
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
"Bash(sleep:*)",
|
|
17
17
|
"Bash(date:*)",
|
|
18
18
|
"Bash(ls:*)",
|
|
19
|
-
"Bash(git:*)"
|
|
19
|
+
"Bash(git:*)",
|
|
20
|
+
"mcp__node-debugger__listScripts",
|
|
21
|
+
"mcp__node-debugger__setBreakpoint",
|
|
22
|
+
"mcp__node-debugger__waitForPause",
|
|
23
|
+
"mcp__node-debugger__resume",
|
|
24
|
+
"mcp__node-debugger__listBreakpoints",
|
|
25
|
+
"mcp__node-debugger__removeBreakpoint",
|
|
26
|
+
"mcp__hottest__runTest"
|
|
20
27
|
]
|
|
21
28
|
}
|
|
22
29
|
}
|
package/package.json
CHANGED
|
@@ -509,7 +509,10 @@ export class ArchivesBackblaze {
|
|
|
509
509
|
private last503Reset = 0;
|
|
510
510
|
// IMPORTANT! We must always CATCH AROUND the apiRetryLogic, NEVER inside of fnc. Otherwise we won't
|
|
511
511
|
// be able to recreate the auth token.
|
|
512
|
+
// `context` is a short label (verb + file path) included in every retry/error log so a stuck
|
|
513
|
+
// silent-retry loop is identifiable from the logs.
|
|
512
514
|
private async apiRetryLogic<T>(
|
|
515
|
+
context: string,
|
|
513
516
|
fnc: (api: B2Api) => Promise<T>,
|
|
514
517
|
retries = 3
|
|
515
518
|
): Promise<T> {
|
|
@@ -519,33 +522,33 @@ export class ArchivesBackblaze {
|
|
|
519
522
|
} catch (err: any) {
|
|
520
523
|
if (retries <= 0) throw err;
|
|
521
524
|
|
|
522
|
-
// If it's a 503 and it's been a minute since we last reset, then Wait and reset.
|
|
525
|
+
// If it's a 503 and it's been a minute since we last reset, then Wait and reset.
|
|
523
526
|
if (
|
|
524
527
|
(err.stack.includes(`"status": 503`)
|
|
525
528
|
|| err.stack.includes(`"service_unavailable"`)
|
|
526
529
|
|| err.stack.includes(`"internal_error"`)
|
|
527
530
|
|| err.stack.includes(`ENOBUFS`)
|
|
528
531
|
) && Date.now() - this.last503Reset > 60 * 1000) {
|
|
529
|
-
console.error(
|
|
530
|
-
this.log(
|
|
532
|
+
console.error(`[${context}] 503 error, waiting and resetting: ${err.message}`);
|
|
533
|
+
this.log(`[${context}] 503 error, waiting and resetting: ${err.message}`);
|
|
531
534
|
await delay(10 * 1000);
|
|
532
|
-
// We check again in case, and in the very likely case that this is being run in parallel, we only want to reset once.
|
|
535
|
+
// We check again in case, and in the very likely case that this is being run in parallel, we only want to reset once.
|
|
533
536
|
if (Date.now() - this.last503Reset > 60 * 1000) {
|
|
534
|
-
this.log(
|
|
537
|
+
this.log(`[${context}] Resetting getAPI and getBucketAPI: ${err.message}`);
|
|
535
538
|
this.last503Reset = Date.now();
|
|
536
539
|
getAPI.reset();
|
|
537
540
|
this.getBucketAPI.reset();
|
|
538
541
|
}
|
|
539
|
-
return this.apiRetryLogic(fnc, retries - 1);
|
|
542
|
+
return this.apiRetryLogic(context, fnc, retries - 1);
|
|
540
543
|
}
|
|
541
544
|
|
|
542
545
|
// If the error is that the authorization token is invalid, reset getBucketAPI and getAPI
|
|
543
546
|
// If the error is that the bucket isn't found, reset getBucketAPI
|
|
544
547
|
if (err.stack.includes(`"expired_auth_token"`)) {
|
|
545
|
-
this.log(
|
|
548
|
+
this.log(`[${context}] Authorization token expired`);
|
|
546
549
|
getAPI.reset();
|
|
547
550
|
this.getBucketAPI.reset();
|
|
548
|
-
return this.apiRetryLogic(fnc, retries - 1);
|
|
551
|
+
return this.apiRetryLogic(context, fnc, retries - 1);
|
|
549
552
|
}
|
|
550
553
|
|
|
551
554
|
if (
|
|
@@ -560,10 +563,10 @@ export class ArchivesBackblaze {
|
|
|
560
563
|
|| err.stack.includes(`ECONNREFUSED`)
|
|
561
564
|
|| err.stack.includes(`ENOBUFS`)
|
|
562
565
|
) {
|
|
563
|
-
console.error(
|
|
564
|
-
this.log(err.message
|
|
566
|
+
console.error(`[${context}] Retrying in 5s: ${err.message}`);
|
|
567
|
+
this.log(`[${context}] ${err.message} retrying in 5s`);
|
|
565
568
|
await delay(5000);
|
|
566
|
-
return this.apiRetryLogic(fnc, retries - 1);
|
|
569
|
+
return this.apiRetryLogic(context, fnc, retries - 1);
|
|
567
570
|
}
|
|
568
571
|
|
|
569
572
|
if (err.stack.includes(`getaddrinfo ENOTFOUND`)) {
|
|
@@ -579,10 +582,11 @@ export class ArchivesBackblaze {
|
|
|
579
582
|
resolve(addresses);
|
|
580
583
|
});
|
|
581
584
|
});
|
|
582
|
-
console.error(`getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
|
|
585
|
+
console.error(`[${context}] getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
|
|
583
586
|
}
|
|
584
587
|
|
|
585
588
|
// TODO: Handle if the bucket is deleted?
|
|
589
|
+
console.error(`[${context}] giving up after ${3 - retries + 1} attempts: ${err.stack ?? err}`);
|
|
586
590
|
throw err;
|
|
587
591
|
}
|
|
588
592
|
}
|
|
@@ -597,7 +601,7 @@ export class ArchivesBackblaze {
|
|
|
597
601
|
setTimeout(downloadPoll, 5000);
|
|
598
602
|
};
|
|
599
603
|
setTimeout(downloadPoll, 5000);
|
|
600
|
-
let result = await this.apiRetryLogic(async (api) => {
|
|
604
|
+
let result = await this.apiRetryLogic(`get ${fileName}`, async (api) => {
|
|
601
605
|
let range = config?.range;
|
|
602
606
|
if (range) {
|
|
603
607
|
let fileInfo = await this.getInfo(fileName);
|
|
@@ -649,7 +653,7 @@ export class ArchivesBackblaze {
|
|
|
649
653
|
public async set(fileName: string, data: Buffer): Promise<void> {
|
|
650
654
|
this.log(`backblaze upload (${formatNumber(data.length)}B) ${fileName}`);
|
|
651
655
|
let f = fileName;
|
|
652
|
-
await this.apiRetryLogic(async (api) => {
|
|
656
|
+
await this.apiRetryLogic(`uploadFile ${fileName}`, async (api) => {
|
|
653
657
|
await api.uploadFile({ bucketId: this.bucketId, fileName, data: data, });
|
|
654
658
|
});
|
|
655
659
|
let existsChecks = 30;
|
|
@@ -677,7 +681,7 @@ export class ArchivesBackblaze {
|
|
|
677
681
|
public async del(fileName: string): Promise<void> {
|
|
678
682
|
this.log(`backblaze delete ${fileName}`);
|
|
679
683
|
try {
|
|
680
|
-
await this.apiRetryLogic(async (api) => {
|
|
684
|
+
await this.apiRetryLogic(`hideFile ${fileName}`, async (api) => {
|
|
681
685
|
await api.hideFile({ bucketId: this.bucketId, fileName: fileName });
|
|
682
686
|
});
|
|
683
687
|
} catch (e: any) {
|
|
@@ -746,7 +750,7 @@ export class ArchivesBackblaze {
|
|
|
746
750
|
dataQueue.unshift(data, secondData);
|
|
747
751
|
|
|
748
752
|
|
|
749
|
-
let uploadInfo = await this.apiRetryLogic(async (api) => {
|
|
753
|
+
let uploadInfo = await this.apiRetryLogic(`startLargeFile ${fileName}`, async (api) => {
|
|
750
754
|
return await api.startLargeFile({
|
|
751
755
|
bucketId: this.bucketId,
|
|
752
756
|
fileName: fileName,
|
|
@@ -755,7 +759,7 @@ export class ArchivesBackblaze {
|
|
|
755
759
|
});
|
|
756
760
|
});
|
|
757
761
|
onError.push(async () => {
|
|
758
|
-
await this.apiRetryLogic(async (api) => {
|
|
762
|
+
await this.apiRetryLogic(`cancelLargeFile ${fileName}`, async (api) => {
|
|
759
763
|
await api.cancelLargeFile({ fileId: uploadInfo.fileId });
|
|
760
764
|
});
|
|
761
765
|
});
|
|
@@ -792,7 +796,7 @@ export class ArchivesBackblaze {
|
|
|
792
796
|
sha1.update(data);
|
|
793
797
|
let sha1Hex = sha1.digest("hex");
|
|
794
798
|
partSha1Array.push(sha1Hex);
|
|
795
|
-
await this.apiRetryLogic(async (api) => {
|
|
799
|
+
await this.apiRetryLogic(`uploadPart#${partNumber} ${fileName}`, async (api) => {
|
|
796
800
|
if (!data) throw new Error("Impossible, data is undefined");
|
|
797
801
|
|
|
798
802
|
let timeStr = formatTime(Date.now() - time);
|
|
@@ -818,7 +822,7 @@ export class ArchivesBackblaze {
|
|
|
818
822
|
}
|
|
819
823
|
this.log(`Finished uploading large file uploaded ${green(formatNumber(totalBytes))}B`);
|
|
820
824
|
|
|
821
|
-
await this.apiRetryLogic(async (api) => {
|
|
825
|
+
await this.apiRetryLogic(`finishLargeFile ${fileName}`, async (api) => {
|
|
822
826
|
await api.finishLargeFile({
|
|
823
827
|
fileId: uploadInfo.fileId,
|
|
824
828
|
partSha1Array: partSha1Array,
|
|
@@ -838,7 +842,7 @@ export class ArchivesBackblaze {
|
|
|
838
842
|
}
|
|
839
843
|
|
|
840
844
|
public async getInfo(fileName: string): Promise<{ writeTime: number; size: number; } | undefined> {
|
|
841
|
-
return await this.apiRetryLogic(async (api) => {
|
|
845
|
+
return await this.apiRetryLogic(`getInfo ${fileName}`, async (api) => {
|
|
842
846
|
try {
|
|
843
847
|
// NOTE: Apparently, there's no other way to do this, as the file name does not equal the file ID, and git file info requires the file ID.
|
|
844
848
|
let info = await api.listFileNames({ bucketId: this.bucketId, prefix: fileName, maxFileCount: 10 });
|
|
@@ -868,7 +872,7 @@ export class ArchivesBackblaze {
|
|
|
868
872
|
return result.map(x => x.path);
|
|
869
873
|
}
|
|
870
874
|
public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<{ path: string; createTime: number; size: number; }[]> {
|
|
871
|
-
return await this.apiRetryLogic(async (api) => {
|
|
875
|
+
return await this.apiRetryLogic(`findInfo ${prefix}`, async (api) => {
|
|
872
876
|
if (!config?.shallow && config?.type === "folders") {
|
|
873
877
|
let allFiles = await this.findInfo(prefix);
|
|
874
878
|
let allFolders = new Map<string, { path: string; createTime: number; size: number }>();
|
|
@@ -935,7 +939,7 @@ export class ArchivesBackblaze {
|
|
|
935
939
|
if (target instanceof ArchivesBackblaze) {
|
|
936
940
|
let targetBucketId = target.bucketId;
|
|
937
941
|
if (targetBucketId === this.bucketId && path === targetPath) return;
|
|
938
|
-
await this.apiRetryLogic(async (api) => {
|
|
942
|
+
await this.apiRetryLogic(`move ${path} -> ${targetPath}`, async (api) => {
|
|
939
943
|
// Ugh... listing the file name sucks, but... I guess it's still better than
|
|
940
944
|
// downloading and re-uploading the entire file.
|
|
941
945
|
let info = await api.listFileNames({ bucketId: this.bucketId, prefix: path, maxFileCount: 10 });
|
|
@@ -974,7 +978,7 @@ export class ArchivesBackblaze {
|
|
|
974
978
|
}
|
|
975
979
|
|
|
976
980
|
public async getURL(path: string) {
|
|
977
|
-
return await this.apiRetryLogic(async (api) => {
|
|
981
|
+
return await this.apiRetryLogic(`getURL ${path}`, async (api) => {
|
|
978
982
|
if (path.startsWith("/")) {
|
|
979
983
|
path = path.slice(1);
|
|
980
984
|
}
|
|
@@ -996,7 +1000,7 @@ export class ArchivesBackblaze {
|
|
|
996
1000
|
fileNamePrefix: string;
|
|
997
1001
|
authorizationToken: string;
|
|
998
1002
|
}> {
|
|
999
|
-
return await this.apiRetryLogic(async (api) => {
|
|
1003
|
+
return await this.apiRetryLogic(`getDownloadAuthorization ${config.fileNamePrefix ?? ""}`, async (api) => {
|
|
1000
1004
|
return await api.getDownloadAuthorization({
|
|
1001
1005
|
bucketId: this.bucketId,
|
|
1002
1006
|
fileNamePrefix: config.fileNamePrefix ?? "",
|
|
@@ -208,11 +208,12 @@ export class PathValueArchives {
|
|
|
208
208
|
let fullPath = pathIdentifier + "/" + file;
|
|
209
209
|
console.log(`Write archive file ${fullPath}, with size ${formatNumber(data.byteLength)}B, and count ${formatNumber(values.length)}`);
|
|
210
210
|
try {
|
|
211
|
+
|
|
211
212
|
await retryFunctional(() => archives().set(fullPath, data), {
|
|
212
213
|
maxRetries: 10,
|
|
213
214
|
minDelay: 1000,
|
|
214
215
|
maxDelay: 5000,
|
|
215
|
-
});
|
|
216
|
+
})();
|
|
216
217
|
} catch (e) {
|
|
217
218
|
console.error(`Error writing archive file ${fullPath}. THIS IS BAD! WE ARE SHUTTING DOWN SO THIS ERROR IS LOUDER! WHAT'S THE POINT OF A DB THAT DOESN'T SAVE DATA!: ${(e as Error).stack ?? e}`);
|
|
218
219
|
await delay(5000);
|
|
@@ -420,10 +421,10 @@ export class PathValueArchives {
|
|
|
420
421
|
skipValues: config.skipValues,
|
|
421
422
|
});
|
|
422
423
|
if (dataValues.length !== decodedObj.valueCount) {
|
|
423
|
-
console.
|
|
424
|
+
console.warn(`Bad archive data file at ${config.path}, Decoded count ${formatNumber(decodedObj.valueCount)} !== count in file name ${formatNumber(dataValues.length)} (${decodedObj.valueCount} !== ${dataValues.length})`);
|
|
424
425
|
}
|
|
425
426
|
} catch (e: any) {
|
|
426
|
-
console.
|
|
427
|
+
console.warn(`Bad archive data file at ${config.path}, error: ${e.stack}`);
|
|
427
428
|
}
|
|
428
429
|
let rawCount = dataValues.length;
|
|
429
430
|
|
|
@@ -44,7 +44,7 @@ import { waitForFirstTimeSync } from "socket-function/time/trueTimeShim";
|
|
|
44
44
|
import { logMeasureTable, measureBlock, measureFnc, measureWrap, startMeasure } from "socket-function/src/profiling/measure";
|
|
45
45
|
import { delay } from "socket-function/src/batching";
|
|
46
46
|
import { MaybePromise } from "socket-function/src/types";
|
|
47
|
-
import { devDebugbreak, getDomain, isBootstrapOnly, isDynamicallyLoading, isPublic, noSyncing } from "../config";
|
|
47
|
+
import { devDebugbreak, getDomain, isBootstrapOnly, isDynamicallyLoading, isPublic, isRecovery, noSyncing } from "../config";
|
|
48
48
|
import { Schema2, Schema2T, t } from "../2-proxy/schema2";
|
|
49
49
|
import { CALL_PERMISSIONS_KEY } from "./permissionsShared";
|
|
50
50
|
import yargs, { check } from "yargs";
|
|
@@ -1035,19 +1035,21 @@ export class Querysub {
|
|
|
1035
1035
|
return module;
|
|
1036
1036
|
}
|
|
1037
1037
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
await isAllowedToSeeSource(configObj?.signedIdentity)
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1038
|
+
if (!isRecovery()) {
|
|
1039
|
+
RequireController.addMapGetModules(async (result, args) => {
|
|
1040
|
+
let configObj = args[2] as { signedIdentity: SignedIdentity | undefined } | undefined;
|
|
1041
|
+
if (!await isAllowedToSeeSource(configObj?.signedIdentity)) {
|
|
1042
|
+
await isAllowedToSeeSource(configObj?.signedIdentity);
|
|
1043
|
+
//console.log(red(`Not allowed to see source`));
|
|
1044
|
+
for (let [key, value] of Object.entries(result.modules)) {
|
|
1045
|
+
result.modules[key] = stripSource(value);
|
|
1046
|
+
}
|
|
1047
|
+
} else {
|
|
1048
|
+
//console.log(green(`Allowed to see source`));
|
|
1045
1049
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
return result;
|
|
1050
|
-
});
|
|
1050
|
+
return result;
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1051
1053
|
}
|
|
1052
1054
|
|
|
1053
1055
|
public static async hostService(name: string, port = 0) {
|
|
@@ -5,7 +5,7 @@ import { css } from "typesafecss";
|
|
|
5
5
|
import { getBrowserUrlNode } from "../-f-node-discovery/NodeDiscovery";
|
|
6
6
|
import { MachineServiceController } from "./machineSchema";
|
|
7
7
|
|
|
8
|
-
const SINCE_DAYS =
|
|
8
|
+
const SINCE_DAYS = 2;
|
|
9
9
|
const CRASH_HUE = 0;
|
|
10
10
|
const OTHER_HUE = 210;
|
|
11
11
|
|
|
@@ -31,23 +31,19 @@ export class LaunchTrackingHeader extends qreact.Component {
|
|
|
31
31
|
else entry.other++;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
let
|
|
34
|
+
let ranked: { serviceKey: string; crashes: number; other: number }[] = [];
|
|
35
35
|
for (let [serviceKey, c] of perKey) {
|
|
36
|
-
|
|
37
|
-
if (!top) {
|
|
38
|
-
top = candidate;
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
if (candidate.crashes > top.crashes) {
|
|
42
|
-
top = candidate;
|
|
43
|
-
} else if (candidate.crashes === top.crashes && (candidate.crashes + candidate.other) > (top.crashes + top.other)) {
|
|
44
|
-
top = candidate;
|
|
45
|
-
}
|
|
36
|
+
ranked.push({ serviceKey, crashes: c.crashes, other: c.other });
|
|
46
37
|
}
|
|
38
|
+
ranked.sort((a, b) => {
|
|
39
|
+
if (b.crashes !== a.crashes) return b.crashes - a.crashes;
|
|
40
|
+
return (b.crashes + b.other) - (a.crashes + a.other);
|
|
41
|
+
});
|
|
42
|
+
let top = ranked[0];
|
|
47
43
|
|
|
48
44
|
let title = `Launches in last ${SINCE_DAYS} days: ${totalCrashes} crashed, ${totalOther} other`;
|
|
49
|
-
|
|
50
|
-
title +=
|
|
45
|
+
for (let r of ranked) {
|
|
46
|
+
title += `\n${r.serviceKey}: ${r.crashes} crashed, ${r.other} other`;
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
return <div title={title} className={css.hbox(6).colorhsl(0, 0, 20)}>
|
|
@@ -870,6 +870,92 @@ async function getPPID(pid: string) {
|
|
|
870
870
|
}
|
|
871
871
|
}
|
|
872
872
|
|
|
873
|
+
// Node prints this when the process has exited but the V8 inspector is still
|
|
874
|
+
// holding it open waiting for a debugger client to detach. If a debugger client
|
|
875
|
+
// drops uncleanly the process can sit on this forever, holding the screen and
|
|
876
|
+
// preventing the supervisor from spawning a fresh instance. The watcher below
|
|
877
|
+
// polls each screen's pipe.txt for this phrase and sends Ctrl+C to break it out.
|
|
878
|
+
const DEBUGGER_DISCONNECT_MESSAGE = "Waiting for the debugger to disconnect...";
|
|
879
|
+
|
|
880
|
+
// To avoid false positives from app code that happens to log a string
|
|
881
|
+
// containing the phrase, the wedge is only reported when:
|
|
882
|
+
// 1. the phrase is on its own line (start-of-string or after a newline), AND
|
|
883
|
+
// 2. nothing else has been logged after it (only trailing whitespace/EOF).
|
|
884
|
+
// In a real wedge, this line is the very last thing Node writes before going
|
|
885
|
+
// silent — so any later output rules out the wedge interpretation.
|
|
886
|
+
const DEBUGGER_DISCONNECT_TAIL_PATTERN = /(?:^|\r?\n)Waiting for the debugger to disconnect\.\.\.\s*$/;
|
|
887
|
+
|
|
888
|
+
// How often the wedge watcher scans every service's pipe.txt.
|
|
889
|
+
const DEBUGGER_WEDGE_POLL_INTERVAL = timeInSecond * 15;
|
|
890
|
+
// How much of the tail of pipe.txt to read on each scan. The phrase is short
|
|
891
|
+
// and appears near the very end of the log when a process is stuck, so a few
|
|
892
|
+
// KB is plenty and keeps scans cheap.
|
|
893
|
+
const DEBUGGER_WEDGE_TAIL_BYTES = 8 * 1024;
|
|
894
|
+
// Suppress repeated Ctrl+C bursts to the same screen while it tears down; if
|
|
895
|
+
// the first signal didn't break the wait, give it this long before we try again.
|
|
896
|
+
const DEBUGGER_WEDGE_RESIGNAL_DELAY = timeInMinute;
|
|
897
|
+
// After a first match, wait this long and re-check before signaling. If the
|
|
898
|
+
// process is still alive and just happened to log a message ending in the
|
|
899
|
+
// phrase, more output will appear in the meantime and the second check fails.
|
|
900
|
+
const DEBUGGER_WEDGE_RECHECK_DELAY = timeInSecond * 3;
|
|
901
|
+
|
|
902
|
+
// screenName -> last time we sent Ctrl+C because of a debugger wedge.
|
|
903
|
+
const lastDebuggerWedgeSignal = new Map<string, number>();
|
|
904
|
+
|
|
905
|
+
async function readPipeFileTail(pipeFile: string, maxBytes: number): Promise<string> {
|
|
906
|
+
let handle: fs.promises.FileHandle | undefined;
|
|
907
|
+
try {
|
|
908
|
+
handle = await fs.promises.open(pipeFile, "r");
|
|
909
|
+
let stat = await handle.stat();
|
|
910
|
+
let start = Math.max(0, stat.size - maxBytes);
|
|
911
|
+
let length = stat.size - start;
|
|
912
|
+
if (length <= 0) return "";
|
|
913
|
+
let buf = Buffer.alloc(length);
|
|
914
|
+
await handle.read(buf, 0, length, start);
|
|
915
|
+
return buf.toString("utf8");
|
|
916
|
+
} catch {
|
|
917
|
+
// File doesn't exist yet, or got truncated mid-read — treat as empty.
|
|
918
|
+
return "";
|
|
919
|
+
} finally {
|
|
920
|
+
if (handle) {
|
|
921
|
+
try {
|
|
922
|
+
await handle.close();
|
|
923
|
+
} catch {
|
|
924
|
+
// ignore
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function unwedgeStuckDebuggerScreens(): Promise<void> {
|
|
931
|
+
let prefix = getTmuxPrefix();
|
|
932
|
+
let screens = await getScreenState(false);
|
|
933
|
+
for (let { screenName } of screens) {
|
|
934
|
+
let pipeFile = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/pipe.txt";
|
|
935
|
+
let tail1 = await readPipeFileTail(pipeFile, DEBUGGER_WEDGE_TAIL_BYTES);
|
|
936
|
+
if (!DEBUGGER_DISCONNECT_TAIL_PATTERN.test(tail1)) continue;
|
|
937
|
+
|
|
938
|
+
// Confirm the process is actually wedged and not just briefly idle by
|
|
939
|
+
// re-checking after a short delay. A live process will write more
|
|
940
|
+
// output in this window, which moves the phrase away from the end
|
|
941
|
+
// and fails the second match.
|
|
942
|
+
await delay(DEBUGGER_WEDGE_RECHECK_DELAY);
|
|
943
|
+
let tail2 = await readPipeFileTail(pipeFile, DEBUGGER_WEDGE_TAIL_BYTES);
|
|
944
|
+
if (!DEBUGGER_DISCONNECT_TAIL_PATTERN.test(tail2)) continue;
|
|
945
|
+
|
|
946
|
+
let last = lastDebuggerWedgeSignal.get(screenName) ?? 0;
|
|
947
|
+
if (Date.now() - last < DEBUGGER_WEDGE_RESIGNAL_DELAY) continue;
|
|
948
|
+
lastDebuggerWedgeSignal.set(screenName, Date.now());
|
|
949
|
+
|
|
950
|
+
console.warn(red(`Detected stuck "${DEBUGGER_DISCONNECT_MESSAGE}" in ${screenName} (confirmed across two checks ${DEBUGGER_WEDGE_RECHECK_DELAY}ms apart); sending Ctrl+C to unblock it.`));
|
|
951
|
+
try {
|
|
952
|
+
await runPromise(`${prefix}tmux send-keys -t ${screenName} 'C-c' Enter`);
|
|
953
|
+
} catch (e: any) {
|
|
954
|
+
console.warn(`Failed to send Ctrl+C to ${screenName}: ${e.stack ?? e}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
873
959
|
export async function machineApplyMain() {
|
|
874
960
|
let parentPID = process.argv[2];
|
|
875
961
|
// Wait for the console to get shimmed
|
|
@@ -898,6 +984,14 @@ export async function machineApplyMain() {
|
|
|
898
984
|
await Querysub.hostService("machine-apply");
|
|
899
985
|
onServiceConfigChange(resyncServices);
|
|
900
986
|
|
|
987
|
+
runInfinitePoll(DEBUGGER_WEDGE_POLL_INTERVAL, async () => {
|
|
988
|
+
try {
|
|
989
|
+
await unwedgeStuckDebuggerScreens();
|
|
990
|
+
} catch (e: any) {
|
|
991
|
+
console.error(`Error in debugger-wedge watcher: ${e.stack ?? e}`);
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
|
|
901
995
|
runInfinitePoll(timeInMinute * 3, async () => {
|
|
902
996
|
//console.log(magenta(`Quick outdated check at ${new Date().toISOString()}`));
|
|
903
997
|
// console.log(magenta("Likely outdated, resyncing now"));
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Exposes the V8 inspector / Chrome DevTools Protocol as MCP tools:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* attachNode - connect to a Querysub cluster node by nodeId (deciding
|
|
7
|
+
* local vs remote via getExternalIP), keep the connection
|
|
8
|
+
* open, and return a short sessionId used by every other tool.
|
|
8
9
|
* detach - close a session.
|
|
9
10
|
* listScripts - list scripts loaded in the target.
|
|
10
11
|
* getSource - get a script's source (optionally a line range).
|
|
@@ -23,7 +24,6 @@
|
|
|
23
24
|
module.hotreload = false;
|
|
24
25
|
|
|
25
26
|
import * as http from "http";
|
|
26
|
-
import * as path from "path";
|
|
27
27
|
import * as dns from "dns";
|
|
28
28
|
|
|
29
29
|
// Allow callers to point this process at a different project root (so we use
|
|
@@ -49,7 +49,6 @@ import { getExternalIP } from "../../misc/networking";
|
|
|
49
49
|
import { getNodeIdDomain } from "socket-function/src/nodeCache";
|
|
50
50
|
|
|
51
51
|
const DEFAULT_MCP_PORT = 3001;
|
|
52
|
-
const DEFAULT_PORT_FILE = path.join(process.cwd(), "debugger-port.json");
|
|
53
52
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
54
53
|
// Per-node budget for the getEntryPoint / getInspectURL probes in listNodes;
|
|
55
54
|
// a node that doesn't answer in time is reported with those fields undefined.
|
|
@@ -78,10 +77,38 @@ interface ScriptParsed {
|
|
|
78
77
|
url: string;
|
|
79
78
|
}
|
|
80
79
|
|
|
80
|
+
interface PausedState {
|
|
81
|
+
/** Reason CDP reported for the pause (e.g. "other" for breakpoint, "step"). */
|
|
82
|
+
reason: string;
|
|
83
|
+
/** CDP breakpointIds that fired this pause, if any. */
|
|
84
|
+
hitBreakpoints: string[];
|
|
85
|
+
/** CDP call frames, top-most first. */
|
|
86
|
+
callFrames: any[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface BreakpointEntry {
|
|
90
|
+
/** CDP breakpointId (also used as the user-facing handle). */
|
|
91
|
+
breakpointId: string;
|
|
92
|
+
/** URL the breakpoint was placed on. */
|
|
93
|
+
url: string;
|
|
94
|
+
/** Requested line number, 1-based. */
|
|
95
|
+
requestedLine: number;
|
|
96
|
+
/** Actual resolved line number, 1-based; undefined if it didn't bind. */
|
|
97
|
+
actualLine?: number;
|
|
98
|
+
/** Optional condition expression that gates the pause. */
|
|
99
|
+
condition?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
interface Session {
|
|
82
103
|
id: string;
|
|
83
104
|
remote: DebuggerRemote;
|
|
84
105
|
scripts: ScriptParsed[];
|
|
106
|
+
/** Active pausing breakpoints, keyed by CDP breakpointId. */
|
|
107
|
+
breakpoints: Map<string, BreakpointEntry>;
|
|
108
|
+
/** Set while the target is paused; cleared on resume. */
|
|
109
|
+
paused?: PausedState;
|
|
110
|
+
/** Resolvers waiting for the next pause event. */
|
|
111
|
+
pauseWaiters: Array<() => void>;
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
const sessions = new Map<string, Session>();
|
|
@@ -129,17 +156,60 @@ function resolveScript(session: Session, query: string): ScriptParsed {
|
|
|
129
156
|
* other tools can find it by sessionId.
|
|
130
157
|
*/
|
|
131
158
|
async function openSession(remote: DebuggerRemote): Promise<Session> {
|
|
132
|
-
const session: Session = {
|
|
159
|
+
const session: Session = {
|
|
160
|
+
id: newSessionId(),
|
|
161
|
+
remote,
|
|
162
|
+
scripts: [],
|
|
163
|
+
breakpoints: new Map(),
|
|
164
|
+
pauseWaiters: [],
|
|
165
|
+
};
|
|
133
166
|
// Register before enabling so we capture the initial script dump.
|
|
134
167
|
remote.on("Debugger.scriptParsed", (p: ScriptParsed) =>
|
|
135
168
|
session.scripts.push({ scriptId: p.scriptId, url: p.url }),
|
|
136
169
|
);
|
|
170
|
+
// Track pause/resume so other tools (evaluate, waitForPause) can see
|
|
171
|
+
// whether the target is currently stopped at a breakpoint.
|
|
172
|
+
remote.on("Debugger.paused", (p: any) => {
|
|
173
|
+
session.paused = {
|
|
174
|
+
reason: p.reason ?? "other",
|
|
175
|
+
hitBreakpoints: p.hitBreakpoints ?? [],
|
|
176
|
+
callFrames: p.callFrames ?? [],
|
|
177
|
+
};
|
|
178
|
+
const waiters = session.pauseWaiters.splice(0);
|
|
179
|
+
for (const w of waiters) w();
|
|
180
|
+
});
|
|
181
|
+
remote.on("Debugger.resumed", () => {
|
|
182
|
+
session.paused = undefined;
|
|
183
|
+
});
|
|
137
184
|
await remote.send("Runtime.enable");
|
|
138
185
|
await remote.send("Debugger.enable");
|
|
139
186
|
sessions.set(session.id, session);
|
|
140
187
|
return session;
|
|
141
188
|
}
|
|
142
189
|
|
|
190
|
+
/** Short summary of a paused state, suitable for tool output. */
|
|
191
|
+
function describePaused(session: Session): string {
|
|
192
|
+
if (!session.paused) return "(not paused)";
|
|
193
|
+
const { reason, hitBreakpoints, callFrames } = session.paused;
|
|
194
|
+
const hit = hitBreakpoints.length
|
|
195
|
+
? ` (hit ${hitBreakpoints.join(", ")})`
|
|
196
|
+
: "";
|
|
197
|
+
const stack = callFrames
|
|
198
|
+
.slice(0, 8)
|
|
199
|
+
.map((f: any, i: number) => {
|
|
200
|
+
const url = f.url || "?";
|
|
201
|
+
const line = (f.location?.lineNumber ?? 0) + 1;
|
|
202
|
+
const col = (f.location?.columnNumber ?? 0) + 1;
|
|
203
|
+
return ` #${i} ${f.functionName || "<anon>"} at ${url}:${line}:${col}`;
|
|
204
|
+
})
|
|
205
|
+
.join("\n");
|
|
206
|
+
return (
|
|
207
|
+
`Paused (reason=${reason})${hit}\n` +
|
|
208
|
+
`Top call frame id: ${callFrames[0]?.callFrameId ?? "(none)"}\n` +
|
|
209
|
+
`Call stack:\n${stack || " (empty)"}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
143
213
|
/** Human-readable "Attached. sessionId=… + user scripts" summary for a session. */
|
|
144
214
|
function describeSession(session: Session): string {
|
|
145
215
|
const userScripts = session.scripts.filter((s) => isUserScript(s.url));
|
|
@@ -168,17 +238,13 @@ async function getNodeInfos(): Promise<NodeInfo[]> {
|
|
|
168
238
|
const nodes = await getAllNodeIds();
|
|
169
239
|
return Promise.all(
|
|
170
240
|
nodes.map(async (nodeId): Promise<NodeInfo> => {
|
|
171
|
-
const [metadata
|
|
241
|
+
const [metadata] = await Promise.all([
|
|
172
242
|
timeoutToUndefinedSilent(
|
|
173
243
|
NODE_INFO_TIMEOUT_MS,
|
|
174
244
|
NodeCapabilitiesController.nodes[nodeId].getMetadata(),
|
|
175
|
-
)
|
|
176
|
-
timeoutToUndefinedSilent(
|
|
177
|
-
NODE_INFO_TIMEOUT_MS,
|
|
178
|
-
NodeCapabilitiesController.nodes[nodeId].getInspectURL(),
|
|
179
|
-
),
|
|
245
|
+
)
|
|
180
246
|
]);
|
|
181
|
-
return { nodeId, entryPoint: metadata?.entryPoint
|
|
247
|
+
return { nodeId, entryPoint: metadata?.entryPoint };
|
|
182
248
|
}),
|
|
183
249
|
);
|
|
184
250
|
}
|
|
@@ -267,9 +333,8 @@ const tools: Record<string, ToolDef> = {
|
|
|
267
333
|
description:
|
|
268
334
|
"List every node in the Querysub cluster, with each node's process " +
|
|
269
335
|
"entry point and V8 inspector (devtools) URL. Returns an array of " +
|
|
270
|
-
"{ nodeId, entryPoint
|
|
271
|
-
"time has entryPoint
|
|
272
|
-
"inspectUrl to point the `attach` tool at it for debugging.",
|
|
336
|
+
"{ nodeId, entryPoint }. A node that does not answer in " +
|
|
337
|
+
"time has entryPoint left undefined",
|
|
273
338
|
inputSchema: {
|
|
274
339
|
type: "object",
|
|
275
340
|
properties: {},
|
|
@@ -286,8 +351,7 @@ const tools: Record<string, ToolDef> = {
|
|
|
286
351
|
"and return a sessionId usable by every other tool. Works out " +
|
|
287
352
|
"whether the node is on this machine or remote — a local node is " +
|
|
288
353
|
"debugged directly; a remote node is reached by asking it to open a " +
|
|
289
|
-
"one-time external debug port locked to this machine's IP.
|
|
290
|
-
"this over `attach` when working with cluster nodes.",
|
|
354
|
+
"one-time external debug port locked to this machine's IP.",
|
|
291
355
|
inputSchema: {
|
|
292
356
|
type: "object",
|
|
293
357
|
properties: {
|
|
@@ -308,39 +372,6 @@ const tools: Record<string, ToolDef> = {
|
|
|
308
372
|
},
|
|
309
373
|
},
|
|
310
374
|
|
|
311
|
-
attach: {
|
|
312
|
-
description:
|
|
313
|
-
"Attach to a running Node.js process over its V8 inspector (debugger) " +
|
|
314
|
-
"port. Keeps the connection open and returns a short sessionId for use " +
|
|
315
|
-
"by every other tool. Provide one of portFile / port / wsUrl; defaults " +
|
|
316
|
-
"to the ./debugger-port.json written by test.ts. Retries for ~10s if " +
|
|
317
|
-
"the target is not listening yet.",
|
|
318
|
-
inputSchema: {
|
|
319
|
-
type: "object",
|
|
320
|
-
properties: {
|
|
321
|
-
portFile: {
|
|
322
|
-
type: "string",
|
|
323
|
-
description: "Path to a debugger-port.json file. Default: ./debugger-port.json",
|
|
324
|
-
},
|
|
325
|
-
port: { type: "number", description: "Inspector HTTP port (alternative to portFile)." },
|
|
326
|
-
wsUrl: { type: "string", description: "Direct CDP WebSocket URL (alternative to portFile/port)." },
|
|
327
|
-
},
|
|
328
|
-
},
|
|
329
|
-
handler: async (args) => {
|
|
330
|
-
let remote: DebuggerRemote;
|
|
331
|
-
if (typeof args.wsUrl === "string") {
|
|
332
|
-
remote = new DebuggerRemote({ wsUrl: args.wsUrl });
|
|
333
|
-
} else if (typeof args.port === "number") {
|
|
334
|
-
remote = new DebuggerRemote({ port: args.port });
|
|
335
|
-
} else {
|
|
336
|
-
remote = new DebuggerRemote({ portFile: args.portFile ?? DEFAULT_PORT_FILE });
|
|
337
|
-
}
|
|
338
|
-
const session = await openSession(remote);
|
|
339
|
-
log(`attached session ${session.id} (${session.scripts.length} scripts)`);
|
|
340
|
-
return describeSession(session);
|
|
341
|
-
},
|
|
342
|
-
},
|
|
343
|
-
|
|
344
375
|
detach: {
|
|
345
376
|
description: "Close a debugger session and disconnect from the target.",
|
|
346
377
|
inputSchema: {
|
|
@@ -424,8 +455,10 @@ const tools: Record<string, ToolDef> = {
|
|
|
424
455
|
evaluate: {
|
|
425
456
|
description:
|
|
426
457
|
"Evaluate a JavaScript expression inside the target process and return " +
|
|
427
|
-
"the result.
|
|
428
|
-
"
|
|
458
|
+
"the result. When the target is paused at a breakpoint, the expression " +
|
|
459
|
+
"runs in the top call frame's scope so locals and parameters are visible; " +
|
|
460
|
+
"otherwise it runs in the global context. Awaits promises. Tip: " +
|
|
461
|
+
"`process.exit(0)` cleanly terminates the target (when not paused).",
|
|
429
462
|
inputSchema: {
|
|
430
463
|
type: "object",
|
|
431
464
|
properties: {
|
|
@@ -439,6 +472,21 @@ const tools: Record<string, ToolDef> = {
|
|
|
439
472
|
if (typeof args.expression !== "string") {
|
|
440
473
|
throw new Error("expression must be a string");
|
|
441
474
|
}
|
|
475
|
+
if (session.paused?.callFrames?.length) {
|
|
476
|
+
const topFrame = session.paused.callFrames[0];
|
|
477
|
+
const res = await session.remote.send("Debugger.evaluateOnCallFrame", {
|
|
478
|
+
callFrameId: topFrame.callFrameId,
|
|
479
|
+
expression: args.expression,
|
|
480
|
+
returnByValue: true,
|
|
481
|
+
});
|
|
482
|
+
if (res.exceptionDetails) {
|
|
483
|
+
throw new Error(`Remote threw: ${JSON.stringify(res.exceptionDetails)}`);
|
|
484
|
+
}
|
|
485
|
+
const value = res.result?.value;
|
|
486
|
+
return value === undefined
|
|
487
|
+
? "undefined (or non-serializable result)"
|
|
488
|
+
: JSON.stringify(value, null, 2);
|
|
489
|
+
}
|
|
442
490
|
const value = await session.remote.evaluate(args.expression);
|
|
443
491
|
return value === undefined
|
|
444
492
|
? "undefined (or non-serializable result)"
|
|
@@ -578,6 +626,232 @@ const tools: Record<string, ToolDef> = {
|
|
|
578
626
|
return `${header}\n${body}`;
|
|
579
627
|
},
|
|
580
628
|
},
|
|
629
|
+
|
|
630
|
+
setBreakpoint: {
|
|
631
|
+
description:
|
|
632
|
+
"Install a real breakpoint that PAUSES the target when hit. Unlike " +
|
|
633
|
+
"logpoint, this stops execution so you can inspect state (via evaluate, " +
|
|
634
|
+
"which uses the paused call frame's scope) and add more breakpoints " +
|
|
635
|
+
"before resuming. Returns a breakpointId. Use waitForPause to block " +
|
|
636
|
+
"until the breakpoint actually fires. IMPORTANT: while paused, the " +
|
|
637
|
+
"target's entire event loop is stopped (no heartbeats, no I/O) — " +
|
|
638
|
+
"external watchdogs may kill long-paused processes, so resume promptly.",
|
|
639
|
+
inputSchema: {
|
|
640
|
+
type: "object",
|
|
641
|
+
properties: {
|
|
642
|
+
sessionId: { type: "string" },
|
|
643
|
+
script: { type: "string", description: "scriptId or URL substring." },
|
|
644
|
+
lineNumber: {
|
|
645
|
+
type: "number",
|
|
646
|
+
description: "1-based line to set the breakpoint on (matches getSource output).",
|
|
647
|
+
},
|
|
648
|
+
condition: {
|
|
649
|
+
type: "string",
|
|
650
|
+
description:
|
|
651
|
+
"Optional JS boolean expression. The target only pauses when it is truthy.",
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
required: ["sessionId", "script", "lineNumber"],
|
|
655
|
+
},
|
|
656
|
+
handler: async (args) => {
|
|
657
|
+
const session = getSession(args.sessionId);
|
|
658
|
+
const script = resolveScript(session, String(args.script));
|
|
659
|
+
const lineNumber = Math.floor(args.lineNumber);
|
|
660
|
+
if (!(lineNumber >= 1)) {
|
|
661
|
+
throw new Error("lineNumber must be a positive integer (1-based).");
|
|
662
|
+
}
|
|
663
|
+
if (args.condition !== undefined && typeof args.condition !== "string") {
|
|
664
|
+
throw new Error("condition must be a string if provided.");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const params: any = {
|
|
668
|
+
url: script.url,
|
|
669
|
+
lineNumber: lineNumber - 1,
|
|
670
|
+
};
|
|
671
|
+
if (typeof args.condition === "string" && args.condition.trim()) {
|
|
672
|
+
params.condition = args.condition;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const res = await session.remote.send("Debugger.setBreakpointByUrl", params);
|
|
676
|
+
const breakpointId: string = res.breakpointId;
|
|
677
|
+
const locations: any[] = res.locations ?? [];
|
|
678
|
+
const bound = locations.length > 0;
|
|
679
|
+
const actualLine = bound ? locations[0].lineNumber + 1 : undefined;
|
|
680
|
+
|
|
681
|
+
session.breakpoints.set(breakpointId, {
|
|
682
|
+
breakpointId,
|
|
683
|
+
url: script.url,
|
|
684
|
+
requestedLine: lineNumber,
|
|
685
|
+
actualLine,
|
|
686
|
+
condition: params.condition,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
if (!bound) {
|
|
690
|
+
return (
|
|
691
|
+
`Breakpoint set but did NOT bind: line ${lineNumber} of ${script.url} ` +
|
|
692
|
+
`has no executable code right now. It will bind later if the source is ` +
|
|
693
|
+
`(re)loaded. breakpointId=${breakpointId}`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
return (
|
|
697
|
+
`Breakpoint set. breakpointId=${breakpointId}\n` +
|
|
698
|
+
`Location: ${script.url}:${actualLine}` +
|
|
699
|
+
(actualLine !== lineNumber ? ` (requested ${lineNumber})` : "") +
|
|
700
|
+
(params.condition ? `\nCondition: ${params.condition}` : "")
|
|
701
|
+
);
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
removeBreakpoint: {
|
|
706
|
+
description: "Remove a pausing breakpoint by its breakpointId.",
|
|
707
|
+
inputSchema: {
|
|
708
|
+
type: "object",
|
|
709
|
+
properties: {
|
|
710
|
+
sessionId: { type: "string" },
|
|
711
|
+
breakpointId: { type: "string" },
|
|
712
|
+
},
|
|
713
|
+
required: ["sessionId", "breakpointId"],
|
|
714
|
+
},
|
|
715
|
+
handler: async (args) => {
|
|
716
|
+
const session = getSession(args.sessionId);
|
|
717
|
+
if (typeof args.breakpointId !== "string" || !args.breakpointId) {
|
|
718
|
+
throw new Error("breakpointId must be a non-empty string.");
|
|
719
|
+
}
|
|
720
|
+
await session.remote.send("Debugger.removeBreakpoint", {
|
|
721
|
+
breakpointId: args.breakpointId,
|
|
722
|
+
});
|
|
723
|
+
const existed = session.breakpoints.delete(args.breakpointId);
|
|
724
|
+
return existed
|
|
725
|
+
? `Removed breakpoint ${args.breakpointId}.`
|
|
726
|
+
: `Removed breakpoint ${args.breakpointId} (was not in this session's table).`;
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
listBreakpoints: {
|
|
731
|
+
description: "List the pausing breakpoints currently set on this session.",
|
|
732
|
+
inputSchema: {
|
|
733
|
+
type: "object",
|
|
734
|
+
properties: { sessionId: { type: "string" } },
|
|
735
|
+
required: ["sessionId"],
|
|
736
|
+
},
|
|
737
|
+
handler: async (args) => {
|
|
738
|
+
const session = getSession(args.sessionId);
|
|
739
|
+
if (session.breakpoints.size === 0) return "(no breakpoints set)";
|
|
740
|
+
const lines: string[] = [];
|
|
741
|
+
for (const bp of session.breakpoints.values()) {
|
|
742
|
+
const loc = bp.actualLine !== undefined
|
|
743
|
+
? `${bp.url}:${bp.actualLine}`
|
|
744
|
+
: `${bp.url}:${bp.requestedLine} (NOT BOUND)`;
|
|
745
|
+
const cond = bp.condition ? ` condition=${bp.condition}` : "";
|
|
746
|
+
lines.push(`${bp.breakpointId} ${loc}${cond}`);
|
|
747
|
+
}
|
|
748
|
+
return lines.join("\n");
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
|
|
752
|
+
waitForPause: {
|
|
753
|
+
description:
|
|
754
|
+
"Block until the target is paused at a breakpoint (or already is), and " +
|
|
755
|
+
"return a summary of the paused state including call stack and the top " +
|
|
756
|
+
"callFrameId. If the timeout elapses before a pause, returns a not-paused " +
|
|
757
|
+
"message and does NOT throw.",
|
|
758
|
+
inputSchema: {
|
|
759
|
+
type: "object",
|
|
760
|
+
properties: {
|
|
761
|
+
sessionId: { type: "string" },
|
|
762
|
+
timeoutMs: {
|
|
763
|
+
type: "number",
|
|
764
|
+
description: "How long to wait for a pause, in ms. Default 30000.",
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
required: ["sessionId"],
|
|
768
|
+
},
|
|
769
|
+
handler: async (args) => {
|
|
770
|
+
const session = getSession(args.sessionId);
|
|
771
|
+
if (session.paused) {
|
|
772
|
+
return describePaused(session);
|
|
773
|
+
}
|
|
774
|
+
const timeoutMs = Math.max(1, Math.floor(args.timeoutMs ?? 30_000));
|
|
775
|
+
let timedOut = false;
|
|
776
|
+
await new Promise<void>((resolve) => {
|
|
777
|
+
const timer = setTimeout(() => {
|
|
778
|
+
timedOut = true;
|
|
779
|
+
// Drop our waiter so a later pause doesn't double-resolve.
|
|
780
|
+
const idx = session.pauseWaiters.indexOf(resolve);
|
|
781
|
+
if (idx >= 0) session.pauseWaiters.splice(idx, 1);
|
|
782
|
+
resolve();
|
|
783
|
+
}, timeoutMs);
|
|
784
|
+
session.pauseWaiters.push(() => {
|
|
785
|
+
clearTimeout(timer);
|
|
786
|
+
resolve();
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
if (timedOut && !session.paused) {
|
|
790
|
+
return `Timed out after ${timeoutMs}ms — target is not paused.`;
|
|
791
|
+
}
|
|
792
|
+
return describePaused(session);
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
|
|
796
|
+
resume: {
|
|
797
|
+
description: "Resume the paused target (Debugger.resume).",
|
|
798
|
+
inputSchema: {
|
|
799
|
+
type: "object",
|
|
800
|
+
properties: { sessionId: { type: "string" } },
|
|
801
|
+
required: ["sessionId"],
|
|
802
|
+
},
|
|
803
|
+
handler: async (args) => {
|
|
804
|
+
const session = getSession(args.sessionId);
|
|
805
|
+
if (!session.paused) return "Target is not paused — nothing to resume.";
|
|
806
|
+
await session.remote.send("Debugger.resume");
|
|
807
|
+
return "Resumed.";
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
stepOver: {
|
|
812
|
+
description: "Step over the next statement (Debugger.stepOver). Target must be paused.",
|
|
813
|
+
inputSchema: {
|
|
814
|
+
type: "object",
|
|
815
|
+
properties: { sessionId: { type: "string" } },
|
|
816
|
+
required: ["sessionId"],
|
|
817
|
+
},
|
|
818
|
+
handler: async (args) => {
|
|
819
|
+
const session = getSession(args.sessionId);
|
|
820
|
+
if (!session.paused) throw new Error("Target is not paused.");
|
|
821
|
+
await session.remote.send("Debugger.stepOver");
|
|
822
|
+
return "Stepped over.";
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
stepInto: {
|
|
827
|
+
description: "Step into the next function call (Debugger.stepInto). Target must be paused.",
|
|
828
|
+
inputSchema: {
|
|
829
|
+
type: "object",
|
|
830
|
+
properties: { sessionId: { type: "string" } },
|
|
831
|
+
required: ["sessionId"],
|
|
832
|
+
},
|
|
833
|
+
handler: async (args) => {
|
|
834
|
+
const session = getSession(args.sessionId);
|
|
835
|
+
if (!session.paused) throw new Error("Target is not paused.");
|
|
836
|
+
await session.remote.send("Debugger.stepInto");
|
|
837
|
+
return "Stepped into.";
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
stepOut: {
|
|
842
|
+
description: "Step out of the current function (Debugger.stepOut). Target must be paused.",
|
|
843
|
+
inputSchema: {
|
|
844
|
+
type: "object",
|
|
845
|
+
properties: { sessionId: { type: "string" } },
|
|
846
|
+
required: ["sessionId"],
|
|
847
|
+
},
|
|
848
|
+
handler: async (args) => {
|
|
849
|
+
const session = getSession(args.sessionId);
|
|
850
|
+
if (!session.paused) throw new Error("Target is not paused.");
|
|
851
|
+
await session.remote.send("Debugger.stepOut");
|
|
852
|
+
return "Stepped out.";
|
|
853
|
+
},
|
|
854
|
+
},
|
|
581
855
|
};
|
|
582
856
|
|
|
583
857
|
// --------------------------------------------------------------------------
|
|
@@ -61,32 +61,33 @@ class AuthorityRangeBar extends qreact.Component<{ start: number; end: number }>
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo }> {
|
|
64
|
+
state = {
|
|
65
|
+
expanded: false,
|
|
66
|
+
};
|
|
64
67
|
render() {
|
|
65
68
|
let info = this.props.info;
|
|
66
|
-
let spec = info.spec
|
|
67
|
-
|
|
69
|
+
let spec = info.spec!;
|
|
70
|
+
let expanded = this.state.expanded;
|
|
71
|
+
return <div
|
|
72
|
+
className={css.button.vbox(6).pad2(10).fillWidth.bord2(0, 0, 85).hsl(0, 0, 99)}
|
|
73
|
+
onClick={() => this.state.expanded = !expanded}
|
|
74
|
+
>
|
|
68
75
|
<div className={css.hbox(10).fillWidth}>
|
|
76
|
+
<span>{expanded ? "▼" : "▶"}</span>
|
|
69
77
|
<span className={css.boldStyle}>{info.nodeId}</span>
|
|
78
|
+
<span>{spec.routeStart.toFixed(4)} - {spec.routeEnd.toFixed(4)}</span>
|
|
79
|
+
<AuthorityRangeBar start={spec.routeStart} end={spec.routeEnd} />
|
|
80
|
+
<span className={css.colorhsl(0, 0, 50)}>width {(spec.routeEnd - spec.routeStart).toFixed(4)}</span>
|
|
81
|
+
{spec.excludeDefault && <span className={css.colorhsl(0, 70, 35)}>(excludes default)</span>}
|
|
70
82
|
<span className={css.colorhsl(0, 0, 40).ellipsis.flexFillWidth}>{info.entryPoint || "(no entry point)"}</span>
|
|
71
83
|
</div>
|
|
72
|
-
{
|
|
73
|
-
<div className={css.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<AuthorityRangeBar start={spec.routeStart} end={spec.routeEnd} />
|
|
80
|
-
<span className={css.colorhsl(0, 0, 50)}>width {(spec.routeEnd - spec.routeStart).toFixed(4)}</span>
|
|
81
|
-
{spec.excludeDefault && <span className={css.colorhsl(0, 70, 35)}>(excludes default)</span>}
|
|
82
|
-
</div>
|
|
83
|
-
<div className={css.vbox(2)}>
|
|
84
|
-
<div className={css.boldStyle}>Prefixes ({spec.prefixes.length}):</div>
|
|
85
|
-
{spec.prefixes.length === 0 && <div className={css.colorhsl(0, 0, 50)}>(none)</div>}
|
|
86
|
-
{spec.prefixes.map(p =>
|
|
87
|
-
<div key={p.originalPrefix} className={css.colorhsl(0, 0, 25)}>{p.originalPrefix}</div>
|
|
88
|
-
)}
|
|
89
|
-
</div>
|
|
84
|
+
{expanded &&
|
|
85
|
+
<div className={css.vbox(2)}>
|
|
86
|
+
<div className={css.boldStyle}>Prefixes ({spec.prefixes.length}):</div>
|
|
87
|
+
{spec.prefixes.length === 0 && <div className={css.colorhsl(0, 0, 50)}>(none)</div>}
|
|
88
|
+
{spec.prefixes.map(p =>
|
|
89
|
+
<div key={p.originalPrefix} className={css.colorhsl(0, 0, 25)}>{p.originalPrefix}</div>
|
|
90
|
+
)}
|
|
90
91
|
</div>
|
|
91
92
|
}
|
|
92
93
|
</div>;
|
|
@@ -99,13 +100,13 @@ export class AuthoritySpecPage extends qreact.Component {
|
|
|
99
100
|
if (!infos) {
|
|
100
101
|
return <div className={css.pad2(16)}>Loading authority specs...</div>;
|
|
101
102
|
}
|
|
102
|
-
|
|
103
|
-
sort(
|
|
103
|
+
infos = infos.filter(x => x.spec && x.spec.routeStart >= 0 && x.spec.routeEnd >= 0);
|
|
104
|
+
sort(infos, x => x.spec!.routeStart);
|
|
104
105
|
|
|
105
106
|
return <div className={css.vbox(12).pad2(16).fillWidth}>
|
|
106
107
|
<h2>Node Authority Specs ({infos.length})</h2>
|
|
107
108
|
<div className={css.vbox(8).fillWidth}>
|
|
108
|
-
{
|
|
109
|
+
{infos.map(info => <AuthorityNodeRow key={info.nodeId} info={info} />)}
|
|
109
110
|
</div>
|
|
110
111
|
</div>;
|
|
111
112
|
}
|