@serverkgg/bridge 0.1.3 → 0.1.5
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 +1 -1
- package/rules/package.md +5 -0
- package/src/bot/botCauses.test.ts +284 -0
- package/src/bot/botCauses.ts +58 -0
- package/src/bot/botDeploy.test.ts +260 -1
- package/src/bot/botDeploy.ts +125 -6
- package/src/bot/botState.ts +6 -0
- package/src/bot/detect/botInspectWarning.ts +3 -1
- package/src/bot/detect/botPackageManager.ts +3 -0
- package/src/bot/detect/corpus/alexmubarakshin-ts-telegraf-boilerplate.json +37 -0
- package/src/bot/detect/corpus/alsarmad-discord-quran-bot.json +40 -0
- package/src/bot/detect/corpus/anasty17-mirror-leech-telegram-bot.json +50 -0
- package/src/bot/detect/corpus/anidiotsguide-guidebot.json +36 -0
- package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +45 -0
- package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +35 -0
- package/src/bot/detect/corpus/bracketed-templates-discordjs.json +40 -0
- package/src/bot/detect/corpus/cog-creators-red-discordbot.json +49 -0
- package/src/bot/detect/corpus/cranberriez-discord.ts-bot-template.json +35 -0
- package/src/bot/detect/corpus/crazycat256-nextcord-bot-template.json +38 -0
- package/src/bot/detect/corpus/discord-tickets-bot.json +45 -0
- package/src/bot/detect/corpus/edjopato-telegram-typescript-bot-template.json +37 -0
- package/src/bot/detect/corpus/haniali3161-bot-adhkar-telegram.json +33 -0
- package/src/bot/detect/corpus/hellosverre-discord-ts-template.json +34 -0
- package/src/bot/detect/corpus/kevinnovak-discord-bot-typescript-template.json +43 -0
- package/src/bot/detect/corpus/kkrypt0nn-python-discord-bot-template.json +41 -0
- package/src/bot/detect/corpus/kolezka-telegram-grammy-template.json +37 -0
- package/src/bot/detect/corpus/latand-tgbot_template_v3.json +37 -0
- package/src/bot/detect/corpus/mahmoudbm77-zekr-telegram-bot.json +36 -0
- package/src/bot/detect/corpus/mericcan41-discordjs-v14-template-ts.json +33 -0
- package/src/bot/detect/corpus/modmail-dev-modmail.json +52 -0
- package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +39 -0
- package/src/bot/detect/corpus/notchum-discord-bot-template.json +39 -0
- package/src/bot/detect/corpus/python-discord-bot.json +44 -0
- package/src/bot/detect/corpus/python-telegram-bot-rules-bot.json +39 -0
- package/src/bot/detect/corpus/rajdave69-pycord-bot-template.json +31 -0
- package/src/bot/detect/corpus/rapptz-robodanny.json +36 -0
- package/src/bot/detect/corpus/ry2x-typescript-discord.js-v.14-template.json +35 -0
- package/src/bot/detect/corpus/saiteja-madha-discord-js-bot.json +45 -0
- package/src/bot/detect/corpus/seegreatness-discord.js-for-heroku.json +37 -0
- package/src/bot/detect/corpus/serverk-discord-node.json +36 -0
- package/src/bot/detect/corpus/serverk-discord-python.json +35 -0
- package/src/bot/detect/corpus/serverk-telegram-node.json +36 -0
- package/src/bot/detect/corpus/serverk-telegram-python.json +35 -0
- package/src/bot/detect/corpus/shuruhatik-quran-radio-bot-discord.json +33 -0
- package/src/bot/detect/corpus/skyra-project-skyra.json +46 -0
- package/src/bot/detect/corpus/tgbot-collection-ytdlbot.json +41 -0
- package/src/bot/detect/corpus/zeriodev-music-bot.json +38 -0
- package/src/bot/detect/detect.corpus.test.ts +77 -0
- package/src/bot/detect/detect.test.ts +414 -17
- package/src/bot/detect/detect.ts +347 -36
- package/src/bot/detect/detectResult.ts +4 -0
- package/src/bot/detect/detectTable.test.ts +22 -6
- package/src/bot/detect/detectTable.ts +187 -19
- package/src/bot/detect/detectedVersion.ts +5 -0
- package/src/bot/detect/envExample.test.ts +55 -1
- package/src/bot/detect/envExample.ts +14 -0
- package/src/bot/detect/index.ts +1 -0
- package/src/bot/detect/keys.test.ts +32 -1
- package/src/bot/detect/keys.ts +15 -0
- package/src/bot/detect/serverkJson.test.ts +2 -0
- package/src/bot/detect/serverkJson.ts +4 -1
- package/src/bot/index.ts +1 -0
- package/src/cli/validate.test.ts +127 -1
- package/src/cli/validate.ts +42 -0
- package/src/manifest/manifest.ts +2 -1
- package/src/modules/bridge.ts +2 -0
- package/src/modules/bridgeModule.ts +5 -0
- package/src/protocol/bridgeCrash.ts +49 -0
- package/src/protocol/bridgeEvents.ts +9 -0
- package/src/protocol/bridgePreview.ts +1 -0
- package/src/rcon/rconAccess.ts +1 -1
- package/src/runtime/bridgeDriver.test.ts +45 -0
- package/src/runtime/bridgeDriver.ts +25 -0
- package/src/runtime/bridgeEvents.ts +2 -0
- package/src/runtime/bridgeRegistry.test.ts +32 -0
- package/src/runtime/bridgeSupervisor.test.ts +163 -3
- package/src/runtime/bridgeSupervisor.ts +42 -1
- package/src/runtime/serve.ts +26 -5
package/src/cli/validate.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../manifest";
|
|
22
22
|
import type { BridgeDriver, BridgeSetupModule } from "../modules";
|
|
23
23
|
import {
|
|
24
|
+
BridgeConfirm,
|
|
24
25
|
BridgeLayout,
|
|
25
26
|
BridgePlace,
|
|
26
27
|
type BridgeSection,
|
|
@@ -313,6 +314,39 @@ const reportAvailability = (driver: BridgeDriver) => {
|
|
|
313
314
|
}
|
|
314
315
|
};
|
|
315
316
|
|
|
317
|
+
const RESTORE_POINT_LAYOUTS: ReadonlySet<BridgeLayout> = new Set([
|
|
318
|
+
BridgeLayout.Form,
|
|
319
|
+
BridgeLayout.Catalog,
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
export const confirmReportLines = (driver: BridgeDriver): string[] => {
|
|
323
|
+
const lines: string[] = [];
|
|
324
|
+
|
|
325
|
+
for (const tab of driver.panel?.tabs ?? []) {
|
|
326
|
+
for (const section of tab.sections) {
|
|
327
|
+
if (!("confirm" in section) || section.confirm !== BridgeConfirm.Strong) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (RESTORE_POINT_LAYOUTS.has(section.layout)) {
|
|
332
|
+
lines.push(
|
|
333
|
+
` error: tab "${tab.id}" section "${section.id}" is a ${section.layout} with \`confirm: strong\` — the platform takes a restore point before applying it, so declare \`normal\`; \`strong\` is only for what nothing brings back`,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if ("reinstall" in section && section.reinstall === true) {
|
|
340
|
+
lines.push(
|
|
341
|
+
` error: tab "${tab.id}" section "${section.id}" reinstalls with \`confirm: strong\` — the platform takes a restore point before every reinstall, so declare \`normal\`; \`strong\` is only for what nothing brings back`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return lines;
|
|
348
|
+
};
|
|
349
|
+
|
|
316
350
|
const openTargetOf = (target: GuideOpenTarget) => {
|
|
317
351
|
if (target.tab === GuideOpenTab.Panel) {
|
|
318
352
|
return `panel tab "${target.tabId}"`;
|
|
@@ -434,6 +468,14 @@ const validateRunnablePackage = async (
|
|
|
434
468
|
validateDriver(driver);
|
|
435
469
|
validatePortActivation(manifest.container.ports, driver);
|
|
436
470
|
|
|
471
|
+
const confirmLines = confirmReportLines(driver);
|
|
472
|
+
|
|
473
|
+
if (confirmLines.length > 0) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`the panel asks for a strong confirmation where a restore point is taken:\n${confirmLines.join("\n")}`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
437
479
|
const conditionalPorts = manifest.container.ports.filter((port) => port.activeWhen.length > 0).length;
|
|
438
480
|
const pairedPorts = manifest.container.ports.filter((port) => port.protocol === PortProtocol.TcpUdp).length;
|
|
439
481
|
const blockPorts = manifest.container.ports.filter((port) => port.count > 1).length;
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -224,7 +224,8 @@ export type ManifestFileNote = z.infer<typeof manifestFileNoteSchema>;
|
|
|
224
224
|
|
|
225
225
|
const manifestFilesSchema = z.object({
|
|
226
226
|
protected: z.array(zGlob).max(64).default([]).meta({
|
|
227
|
-
description:
|
|
227
|
+
description:
|
|
228
|
+
"Platform state inside the server directory: hidden from the file manager and left out of archives and copies. Only what the bridge cannot manage without and cannot detect losing.",
|
|
228
229
|
}),
|
|
229
230
|
notable: z.array(manifestFileNoteSchema).max(32).default([]).meta({
|
|
230
231
|
description: "Paths the file manager annotates with a label, note and icon.",
|
package/src/modules/bridge.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type {
|
|
|
39
39
|
BridgeCatalogProvider,
|
|
40
40
|
BridgeCatalogQuery,
|
|
41
41
|
} from "../protocol/bridgeCatalog";
|
|
42
|
+
import type { BridgeCrashReport } from "../protocol/bridgeCrash";
|
|
42
43
|
import type { BridgePendingFile } from "../protocol/bridgeFrame";
|
|
43
44
|
import type { BridgePreview } from "../protocol/bridgePreview";
|
|
44
45
|
import type {
|
|
@@ -96,6 +97,7 @@ export namespace Bridge {
|
|
|
96
97
|
export type Collection<Row extends BridgeRow = BridgeRow> = BridgeCollectionModule<Row>;
|
|
97
98
|
export type Context = BridgeContext;
|
|
98
99
|
export type Copy = BridgeCopy;
|
|
100
|
+
export type CrashReport = BridgeCrashReport;
|
|
99
101
|
export type Detail = BridgeDetailModule;
|
|
100
102
|
export type DetailAction = BridgeDetailAction;
|
|
101
103
|
export type DetailBadge = BridgeDetailBadge;
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
BridgeCatalogQuery,
|
|
16
16
|
BridgeCatalogRelease,
|
|
17
17
|
} from "../protocol/bridgeCatalog";
|
|
18
|
+
import type { BridgeCrashReport } from "../protocol/bridgeCrash";
|
|
18
19
|
import type { BridgeBackupSnapshot, BridgeDescribeResult, BridgePendingFile } from "../protocol/bridgeFrame";
|
|
19
20
|
import type { BridgePreview } from "../protocol/bridgePreview";
|
|
20
21
|
import type { BridgeSetupStep } from "../protocol/bridgeSetup";
|
|
@@ -34,6 +35,8 @@ export interface BridgeLifecycleModule {
|
|
|
34
35
|
kind: BridgeKind.Lifecycle;
|
|
35
36
|
ready?: RegExp;
|
|
36
37
|
failed?: RegExp;
|
|
38
|
+
graceSeconds?: number;
|
|
39
|
+
stableLine?: RegExp;
|
|
37
40
|
stopTimeoutSeconds?: number;
|
|
38
41
|
readyTimeoutSeconds?: number;
|
|
39
42
|
stopSignal?: BridgeStopSignal;
|
|
@@ -42,6 +45,8 @@ export interface BridgeLifecycleModule {
|
|
|
42
45
|
environment?(context: BridgeContext): Promise<Record<string, string>>;
|
|
43
46
|
stop?(context: BridgeContext): Promise<void>;
|
|
44
47
|
onReady?(context: BridgeContext): Promise<void>;
|
|
48
|
+
onStable?(context: BridgeContext): Promise<void>;
|
|
49
|
+
onExit?(context: BridgeContext, report: BridgeCrashReport): Promise<void>;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export interface BridgeAnnounceModule {
|
|
@@ -8,6 +8,15 @@ export enum BridgeCrashCause {
|
|
|
8
8
|
WorldCorrupt = "world-corrupt",
|
|
9
9
|
MissingDependency = "missing-dependency",
|
|
10
10
|
ModCrashed = "mod-crashed",
|
|
11
|
+
TokenInvalid = "token-invalid",
|
|
12
|
+
IntentsDisallowed = "intents-disallowed",
|
|
13
|
+
TokenInUse = "token-in-use",
|
|
14
|
+
WrongTelegramPackage = "wrong-telegram-package",
|
|
15
|
+
WrongDiscordPackage = "wrong-discord-package",
|
|
16
|
+
BuildTools = "build-tools",
|
|
17
|
+
FfmpegMissing = "ffmpeg-missing",
|
|
18
|
+
AudioopRemoved = "audioop-removed",
|
|
19
|
+
RateLimited = "rate-limited",
|
|
11
20
|
Unknown = "unknown",
|
|
12
21
|
}
|
|
13
22
|
|
|
@@ -38,6 +47,22 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
|
|
|
38
47
|
name: BridgeEventName.WorldCorrupt,
|
|
39
48
|
cause: BridgeCrashCause.WorldCorrupt,
|
|
40
49
|
},
|
|
50
|
+
{
|
|
51
|
+
name: BridgeEventName.AudioopRemoved,
|
|
52
|
+
cause: BridgeCrashCause.AudioopRemoved,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: BridgeEventName.WrongTelegramPackage,
|
|
56
|
+
cause: BridgeCrashCause.WrongTelegramPackage,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: BridgeEventName.WrongDiscordPackage,
|
|
60
|
+
cause: BridgeCrashCause.WrongDiscordPackage,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: BridgeEventName.BuildToolsMissing,
|
|
64
|
+
cause: BridgeCrashCause.BuildTools,
|
|
65
|
+
},
|
|
41
66
|
{
|
|
42
67
|
name: BridgeEventName.MissingDependency,
|
|
43
68
|
cause: BridgeCrashCause.MissingDependency,
|
|
@@ -46,6 +71,26 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
|
|
|
46
71
|
name: BridgeEventName.ModCrashed,
|
|
47
72
|
cause: BridgeCrashCause.ModCrashed,
|
|
48
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: BridgeEventName.FfmpegMissing,
|
|
76
|
+
cause: BridgeCrashCause.FfmpegMissing,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: BridgeEventName.IntentsDisallowed,
|
|
80
|
+
cause: BridgeCrashCause.IntentsDisallowed,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: BridgeEventName.TokenInvalid,
|
|
84
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: BridgeEventName.TokenInUse,
|
|
88
|
+
cause: BridgeCrashCause.TokenInUse,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: BridgeEventName.RateLimited,
|
|
92
|
+
cause: BridgeCrashCause.RateLimited,
|
|
93
|
+
},
|
|
49
94
|
];
|
|
50
95
|
|
|
51
96
|
const BRIDGE_CRASH_EVIDENCE_NAMES: ReadonlySet<string> = new Set(BRIDGE_CRASH_EVIDENCE.map((entry) => entry.name));
|
|
@@ -81,3 +126,7 @@ export interface BridgeCrashRecord {
|
|
|
81
126
|
restarts: number;
|
|
82
127
|
lastLine: string | null;
|
|
83
128
|
}
|
|
129
|
+
|
|
130
|
+
export interface BridgeCrashReport extends BridgeCrashRecord {
|
|
131
|
+
cause: BridgeCrashCause;
|
|
132
|
+
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
export enum BridgeEventName {
|
|
2
2
|
AccessGranted = "AccessGranted",
|
|
3
3
|
AccessRevoked = "AccessRevoked",
|
|
4
|
+
AudioopRemoved = "AudioopRemoved",
|
|
4
5
|
BroadcastSent = "BroadcastSent",
|
|
6
|
+
BuildToolsMissing = "BuildToolsMissing",
|
|
7
|
+
FfmpegMissing = "FfmpegMissing",
|
|
5
8
|
GameInstalled = "GameInstalled",
|
|
6
9
|
GameModeChanged = "GameModeChanged",
|
|
7
10
|
GameplayChanged = "GameplayChanged",
|
|
8
11
|
InstallFailed = "InstallFailed",
|
|
12
|
+
IntentsDisallowed = "IntentsDisallowed",
|
|
9
13
|
MemoryUndersized = "MemoryUndersized",
|
|
10
14
|
MissingDependency = "MissingDependency",
|
|
11
15
|
ModCrashed = "ModCrashed",
|
|
@@ -28,6 +32,7 @@ export enum BridgeEventName {
|
|
|
28
32
|
PlayerLeft = "PlayerLeft",
|
|
29
33
|
PlayerUnbanned = "PlayerUnbanned",
|
|
30
34
|
PortBindFailed = "PortBindFailed",
|
|
35
|
+
RateLimited = "RateLimited",
|
|
31
36
|
SecretRotated = "SecretRotated",
|
|
32
37
|
ServerCrashed = "ServerCrashed",
|
|
33
38
|
ServerStarted = "ServerStarted",
|
|
@@ -40,6 +45,8 @@ export enum BridgeEventName {
|
|
|
40
45
|
SetupRejected = "SetupRejected",
|
|
41
46
|
SignInExpired = "SignInExpired",
|
|
42
47
|
TickLagging = "TickLagging",
|
|
48
|
+
TokenInUse = "TokenInUse",
|
|
49
|
+
TokenInvalid = "TokenInvalid",
|
|
43
50
|
WorldActivated = "WorldActivated",
|
|
44
51
|
WorldCloned = "WorldCloned",
|
|
45
52
|
WorldCorrupt = "WorldCorrupt",
|
|
@@ -48,7 +55,9 @@ export enum BridgeEventName {
|
|
|
48
55
|
WorldImported = "WorldImported",
|
|
49
56
|
WorldSaved = "WorldSaved",
|
|
50
57
|
WorldWiped = "WorldWiped",
|
|
58
|
+
WrongDiscordPackage = "WrongDiscordPackage",
|
|
51
59
|
WrongJavaVersion = "WrongJavaVersion",
|
|
60
|
+
WrongTelegramPackage = "WrongTelegramPackage",
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export const BRIDGE_EVENT_NAMES: readonly string[] = Object.values(BridgeEventName);
|
package/src/rcon/rconAccess.ts
CHANGED
|
@@ -249,7 +249,7 @@ export const rconAccessSections = (options: RconAccessSectionsOptions = {}): Bri
|
|
|
249
249
|
ar: "غيّر كلمة المرور",
|
|
250
250
|
en: "Rotate the password",
|
|
251
251
|
},
|
|
252
|
-
confirm: BridgeConfirm.
|
|
252
|
+
confirm: BridgeConfirm.Normal,
|
|
253
253
|
confirmText: {
|
|
254
254
|
ar: "ستتوقف كل أداة خارجية تستخدمها حتى تضيف إليها كلمة المرور الجديدة.",
|
|
255
255
|
en: "Every external tool you use stops working until you paste the new password into it.",
|
|
@@ -1899,6 +1899,51 @@ describe("the lifecycle module", () => {
|
|
|
1899
1899
|
}
|
|
1900
1900
|
});
|
|
1901
1901
|
|
|
1902
|
+
it("accepts a grace from one second to ten minutes and a stable line", () => {
|
|
1903
|
+
for (const graceSeconds of [
|
|
1904
|
+
1,
|
|
1905
|
+
20,
|
|
1906
|
+
600,
|
|
1907
|
+
]) {
|
|
1908
|
+
expect(() =>
|
|
1909
|
+
validateDriver(
|
|
1910
|
+
lifecycleDriver({
|
|
1911
|
+
graceSeconds,
|
|
1912
|
+
stableLine: /logged in as/i,
|
|
1913
|
+
}),
|
|
1914
|
+
),
|
|
1915
|
+
).not.toThrow();
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
it("refuses a grace outside one second to ten minutes", () => {
|
|
1920
|
+
for (const graceSeconds of [
|
|
1921
|
+
0,
|
|
1922
|
+
601,
|
|
1923
|
+
2.5,
|
|
1924
|
+
]) {
|
|
1925
|
+
expect(() =>
|
|
1926
|
+
validateDriver(
|
|
1927
|
+
lifecycleDriver({
|
|
1928
|
+
graceSeconds,
|
|
1929
|
+
}),
|
|
1930
|
+
),
|
|
1931
|
+
).toThrow(
|
|
1932
|
+
`the lifecycle module declares a grace of ${graceSeconds} seconds — it must be a whole number from 1 to 600`,
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
it("refuses a stable line that is not a regular expression", () => {
|
|
1938
|
+
expect(() =>
|
|
1939
|
+
validateDriver(
|
|
1940
|
+
lifecycleDriver({
|
|
1941
|
+
stableLine: "logged in as",
|
|
1942
|
+
}),
|
|
1943
|
+
),
|
|
1944
|
+
).toThrow("the lifecycle module declares a stable line that is not a regular expression");
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1902
1947
|
it("accepts each lifecycle subject the bridge names", () => {
|
|
1903
1948
|
for (const subject of Object.values(BridgeLifecycleSubject)) {
|
|
1904
1949
|
expect(() =>
|
|
@@ -62,6 +62,10 @@ const READY_TIMEOUT_MIN_SECONDS = 1;
|
|
|
62
62
|
|
|
63
63
|
const READY_TIMEOUT_MAX_SECONDS = 3600;
|
|
64
64
|
|
|
65
|
+
const GRACE_MIN_SECONDS = 1;
|
|
66
|
+
|
|
67
|
+
const GRACE_MAX_SECONDS = 600;
|
|
68
|
+
|
|
65
69
|
const RESERVED_MODULE_IDS = new Set([
|
|
66
70
|
"announce",
|
|
67
71
|
"backup",
|
|
@@ -635,6 +639,27 @@ const validateLifecycle = (driver: BridgeDriver) => {
|
|
|
635
639
|
);
|
|
636
640
|
}
|
|
637
641
|
|
|
642
|
+
const graceSeconds: unknown = driver.lifecycle.graceSeconds;
|
|
643
|
+
|
|
644
|
+
if (
|
|
645
|
+
graceSeconds !== undefined
|
|
646
|
+
&& !(
|
|
647
|
+
Number.isInteger(graceSeconds)
|
|
648
|
+
&& (graceSeconds as number) >= GRACE_MIN_SECONDS
|
|
649
|
+
&& (graceSeconds as number) <= GRACE_MAX_SECONDS
|
|
650
|
+
)
|
|
651
|
+
) {
|
|
652
|
+
fail(
|
|
653
|
+
`the lifecycle module declares a grace of ${String(graceSeconds)} seconds — it must be a whole number from ${GRACE_MIN_SECONDS} to ${GRACE_MAX_SECONDS}`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const stableLine: unknown = driver.lifecycle.stableLine;
|
|
658
|
+
|
|
659
|
+
if (stableLine !== undefined && !(stableLine instanceof RegExp)) {
|
|
660
|
+
fail("the lifecycle module declares a stable line that is not a regular expression");
|
|
661
|
+
}
|
|
662
|
+
|
|
638
663
|
const subject: unknown = driver.lifecycle.subject;
|
|
639
664
|
|
|
640
665
|
if (subject !== undefined && !isBridgeLifecycleSubject(subject)) {
|
|
@@ -550,6 +550,38 @@ describe("preview modules", () => {
|
|
|
550
550
|
});
|
|
551
551
|
});
|
|
552
552
|
|
|
553
|
+
it("passes a blocked preview through untouched", async () => {
|
|
554
|
+
const registry = registryFor({
|
|
555
|
+
...baseDriver(),
|
|
556
|
+
modules: {
|
|
557
|
+
transition: {
|
|
558
|
+
kind: BridgeKind.Preview,
|
|
559
|
+
preview: async () => ({
|
|
560
|
+
confirm: BridgeConfirm.Strong,
|
|
561
|
+
blocked: true,
|
|
562
|
+
lines: [
|
|
563
|
+
{
|
|
564
|
+
ar: "لا يوجد بيلد مستقر لهذا الإصدار بعد",
|
|
565
|
+
en: "There is no stable build for this version yet",
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
}),
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
expect((await registry.invoke("transition", BridgeAction.Preview)).result).toEqual({
|
|
574
|
+
confirm: BridgeConfirm.Strong,
|
|
575
|
+
blocked: true,
|
|
576
|
+
lines: [
|
|
577
|
+
{
|
|
578
|
+
ar: "لا يوجد بيلد مستقر لهذا الإصدار بعد",
|
|
579
|
+
en: "There is no stable build for this version yet",
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
553
585
|
it("refuses an action a preview module does not answer", async () => {
|
|
554
586
|
const registry = registryFor({
|
|
555
587
|
...baseDriver(),
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { BOT_CRASH_EVIDENCE, LOGIN_LINE } from "../bot";
|
|
4
|
+
import { BridgeKind, BridgeRuntime } from "../core";
|
|
4
5
|
import { BridgeLifecycleSubject, BridgeStopSignal } from "../modules";
|
|
5
|
-
import { BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
|
|
6
|
+
import { BridgeCrashCause, BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
|
|
7
|
+
import { createEventRing } from "./bridgeEvents";
|
|
6
8
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
7
9
|
import { createBridgeRedaction, REDACTED } from "./bridgeRedaction";
|
|
8
10
|
import { readProcessGroup } from "./bridgeSignal";
|
|
9
|
-
import { type BridgeSupervisor, createSupervisor } from "./bridgeSupervisor";
|
|
11
|
+
import { type BridgeSupervisor, createSupervisor, type SupervisorOptions } from "./bridgeSupervisor";
|
|
10
12
|
|
|
11
13
|
const CHILD_PATTERN = /child=(?<pid>\d+)/;
|
|
12
14
|
|
|
@@ -1277,3 +1279,161 @@ describe("the lifecycle subject", () => {
|
|
|
1277
1279
|
await expect(supervisor.start()).rejects.toThrow('the bot environment names "BAD-KEY"');
|
|
1278
1280
|
});
|
|
1279
1281
|
});
|
|
1282
|
+
|
|
1283
|
+
const GRACE_SECONDS = 1;
|
|
1284
|
+
|
|
1285
|
+
const READY_THEN_ALIVE = "echo ready; exec sleep 300";
|
|
1286
|
+
|
|
1287
|
+
const READY_THEN_LOGIN = "echo ready; sleep 0.2; echo 'Logged in as serverk-bot#0001'; exec sleep 300";
|
|
1288
|
+
|
|
1289
|
+
const READY_THEN_TOKEN_CRASH =
|
|
1290
|
+
"echo ready; sleep 0.3; echo 'Error [TokenInvalid]: An invalid token was provided.' >&2; exit 1";
|
|
1291
|
+
|
|
1292
|
+
const graceSupervisor = (script: string, grace: Partial<Pick<SupervisorOptions, "graceSeconds" | "stableLine">>) => {
|
|
1293
|
+
const records: BridgeCrashRecord[] = [];
|
|
1294
|
+
const causes: BridgeCrashCause[] = [];
|
|
1295
|
+
|
|
1296
|
+
let stables = 0;
|
|
1297
|
+
|
|
1298
|
+
const supervisor: BridgeSupervisor = createSupervisor({
|
|
1299
|
+
cwd: tmpdir(),
|
|
1300
|
+
uid: process.getuid?.() ?? 0,
|
|
1301
|
+
gid: process.getgid?.() ?? 0,
|
|
1302
|
+
stopTimeoutSeconds: 1,
|
|
1303
|
+
ready: READY,
|
|
1304
|
+
...grace,
|
|
1305
|
+
onLog: silent,
|
|
1306
|
+
resolveCommand: async () => {
|
|
1307
|
+
return [
|
|
1308
|
+
"bash",
|
|
1309
|
+
"-c",
|
|
1310
|
+
script,
|
|
1311
|
+
];
|
|
1312
|
+
},
|
|
1313
|
+
onReady() {
|
|
1314
|
+
ring.discard();
|
|
1315
|
+
},
|
|
1316
|
+
onStable() {
|
|
1317
|
+
stables += 1;
|
|
1318
|
+
},
|
|
1319
|
+
onExit(record) {
|
|
1320
|
+
records.push(record);
|
|
1321
|
+
causes.push(ring.crash(record));
|
|
1322
|
+
},
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
const ring = createEventRing(
|
|
1326
|
+
{
|
|
1327
|
+
kind: BridgeKind.Events,
|
|
1328
|
+
patterns: [
|
|
1329
|
+
...BOT_CRASH_EVIDENCE,
|
|
1330
|
+
],
|
|
1331
|
+
},
|
|
1332
|
+
supervisor,
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
return {
|
|
1336
|
+
supervisor,
|
|
1337
|
+
records,
|
|
1338
|
+
causes,
|
|
1339
|
+
stables: () => stables,
|
|
1340
|
+
};
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
describe("the grace window", () => {
|
|
1344
|
+
it("marks a driver from an older bridge that declares no grace stable at its ready line", async () => {
|
|
1345
|
+
const { supervisor, stables } = graceSupervisor(READY_THEN_ALIVE, {});
|
|
1346
|
+
|
|
1347
|
+
await supervisor.start();
|
|
1348
|
+
|
|
1349
|
+
expect(await waitFor(() => stables() === 1, 5000)).toBe(true);
|
|
1350
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Running);
|
|
1351
|
+
|
|
1352
|
+
await supervisor.stop();
|
|
1353
|
+
}, 30_000);
|
|
1354
|
+
|
|
1355
|
+
it("holds a bot that printed no login line at booting until the grace passes", async () => {
|
|
1356
|
+
const { supervisor, stables } = graceSupervisor(READY_THEN_ALIVE, {
|
|
1357
|
+
graceSeconds: GRACE_SECONDS,
|
|
1358
|
+
stableLine: LOGIN_LINE,
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
await supervisor.start();
|
|
1362
|
+
|
|
1363
|
+
expect(await waitFor(() => supervisor.runtime === BridgeRuntime.Running, 5000)).toBe(true);
|
|
1364
|
+
|
|
1365
|
+
await Bun.sleep(400);
|
|
1366
|
+
|
|
1367
|
+
expect(stables(), "the bot was stable before the grace passed").toBe(0);
|
|
1368
|
+
expect(await waitFor(() => stables() === 1, 5000)).toBe(true);
|
|
1369
|
+
|
|
1370
|
+
await Bun.sleep(GRACE_SECONDS * 1000);
|
|
1371
|
+
|
|
1372
|
+
expect(stables(), "the bot was reported stable twice").toBe(1);
|
|
1373
|
+
|
|
1374
|
+
await supervisor.stop();
|
|
1375
|
+
}, 30_000);
|
|
1376
|
+
|
|
1377
|
+
it("marks a bot stable at its login line, before the grace passes", async () => {
|
|
1378
|
+
const { supervisor, stables } = graceSupervisor(READY_THEN_LOGIN, {
|
|
1379
|
+
graceSeconds: 30,
|
|
1380
|
+
stableLine: LOGIN_LINE,
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
await supervisor.start();
|
|
1384
|
+
|
|
1385
|
+
expect(await waitFor(() => stables() === 1, 5000)).toBe(true);
|
|
1386
|
+
|
|
1387
|
+
await supervisor.stop();
|
|
1388
|
+
}, 30_000);
|
|
1389
|
+
|
|
1390
|
+
it("counts an exit inside the grace as a boot failure with the cause the bot printed", async () => {
|
|
1391
|
+
const { supervisor, records, causes, stables } = graceSupervisor(READY_THEN_TOKEN_CRASH, {
|
|
1392
|
+
graceSeconds: 30,
|
|
1393
|
+
stableLine: LOGIN_LINE,
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
await supervisor.start();
|
|
1397
|
+
|
|
1398
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
1399
|
+
expect(records.at(0)?.phase).toBe(BridgeCrashPhase.Booting);
|
|
1400
|
+
expect(records.at(0)?.restarting).toBe(false);
|
|
1401
|
+
expect(causes).toEqual([
|
|
1402
|
+
BridgeCrashCause.TokenInvalid,
|
|
1403
|
+
]);
|
|
1404
|
+
expect(stables()).toBe(0);
|
|
1405
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
|
|
1406
|
+
}, 30_000);
|
|
1407
|
+
|
|
1408
|
+
it("forgets what the install printed once the launcher hands over, so only the bot's own lines name the cause", async () => {
|
|
1409
|
+
const { supervisor, records, causes } = graceSupervisor(
|
|
1410
|
+
"echo 'gyp ERR! stack Error: not found: make'; echo ready; sleep 0.3; exit 1",
|
|
1411
|
+
{
|
|
1412
|
+
graceSeconds: 30,
|
|
1413
|
+
stableLine: LOGIN_LINE,
|
|
1414
|
+
},
|
|
1415
|
+
);
|
|
1416
|
+
|
|
1417
|
+
await supervisor.start();
|
|
1418
|
+
|
|
1419
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
1420
|
+
expect(records.at(0)?.phase).toBe(BridgeCrashPhase.Booting);
|
|
1421
|
+
expect(causes).toEqual([
|
|
1422
|
+
BridgeCrashCause.Unknown,
|
|
1423
|
+
]);
|
|
1424
|
+
}, 30_000);
|
|
1425
|
+
|
|
1426
|
+
it("restarts the same exit when it comes after the grace", async () => {
|
|
1427
|
+
const { supervisor, records } = graceSupervisor("echo ready; sleep 0.5; exit 1", {
|
|
1428
|
+
graceSeconds: 0.2,
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
await supervisor.start();
|
|
1432
|
+
|
|
1433
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
1434
|
+
expect(records.at(0)?.phase).toBe(BridgeCrashPhase.Running);
|
|
1435
|
+
expect(records.at(0)?.restarting).toBe(true);
|
|
1436
|
+
|
|
1437
|
+
await supervisor.stop();
|
|
1438
|
+
}, 30_000);
|
|
1439
|
+
});
|
|
@@ -46,6 +46,8 @@ export interface SupervisorOptions {
|
|
|
46
46
|
subject?: BridgeLifecycleSubject;
|
|
47
47
|
ready?: RegExp;
|
|
48
48
|
failed?: RegExp;
|
|
49
|
+
graceSeconds?: number;
|
|
50
|
+
stableLine?: RegExp;
|
|
49
51
|
resolveCommand: () => Promise<string[]>;
|
|
50
52
|
resolveEnvironment?: () => Promise<Record<string, string>>;
|
|
51
53
|
redact?: (text: string) => string;
|
|
@@ -208,6 +210,11 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
208
210
|
|
|
209
211
|
const readyTimeoutMs = (options.readyTimeoutSeconds ?? options.stopTimeoutSeconds * READY_TIMEOUT_STOP_FACTOR) * 1000;
|
|
210
212
|
|
|
213
|
+
const graceMs =
|
|
214
|
+
options.graceSeconds === undefined ? (options.stableAfterMs ?? STABLE_UPTIME_MS) : options.graceSeconds * 1000;
|
|
215
|
+
|
|
216
|
+
const deferred = options.graceSeconds !== undefined || options.stableLine !== undefined;
|
|
217
|
+
|
|
211
218
|
const awaitingReady = () => {
|
|
212
219
|
return options.readyTimeoutSeconds !== undefined && runtime === BridgeRuntime.Starting;
|
|
213
220
|
};
|
|
@@ -241,6 +248,36 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
241
248
|
options.onStable?.();
|
|
242
249
|
};
|
|
243
250
|
|
|
251
|
+
const settle = (launchStamp: number) => {
|
|
252
|
+
if (!deferred) {
|
|
253
|
+
markStable();
|
|
254
|
+
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const current = () => launchStamp === launchedAt && child !== null;
|
|
259
|
+
|
|
260
|
+
if (stableTimer) {
|
|
261
|
+
clearTimeout(stableTimer);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
stableTimer = setTimeout(() => {
|
|
265
|
+
if (current()) {
|
|
266
|
+
markStable();
|
|
267
|
+
}
|
|
268
|
+
}, graceMs);
|
|
269
|
+
|
|
270
|
+
if (!options.stableLine) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
void watch(options.stableLine, graceMs).then((match) => {
|
|
275
|
+
if (match && current()) {
|
|
276
|
+
markStable();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
|
|
244
281
|
const emit = (raw: SupervisorLine) => {
|
|
245
282
|
const entry = options.redact
|
|
246
283
|
? {
|
|
@@ -491,6 +528,10 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
491
528
|
if (!options.ready) {
|
|
492
529
|
runtime = BridgeRuntime.Running;
|
|
493
530
|
|
|
531
|
+
if (deferred) {
|
|
532
|
+
settle(launchStamp);
|
|
533
|
+
}
|
|
534
|
+
|
|
494
535
|
return;
|
|
495
536
|
}
|
|
496
537
|
|
|
@@ -509,7 +550,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
509
550
|
options.onReady?.();
|
|
510
551
|
}
|
|
511
552
|
|
|
512
|
-
|
|
553
|
+
settle(launchStamp);
|
|
513
554
|
|
|
514
555
|
return;
|
|
515
556
|
}
|