@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.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/rules/package.md +5 -0
  3. package/src/bot/botCauses.test.ts +284 -0
  4. package/src/bot/botCauses.ts +58 -0
  5. package/src/bot/botDeploy.test.ts +260 -1
  6. package/src/bot/botDeploy.ts +125 -6
  7. package/src/bot/botState.ts +6 -0
  8. package/src/bot/detect/botInspectWarning.ts +3 -1
  9. package/src/bot/detect/botPackageManager.ts +3 -0
  10. package/src/bot/detect/corpus/alexmubarakshin-ts-telegraf-boilerplate.json +37 -0
  11. package/src/bot/detect/corpus/alsarmad-discord-quran-bot.json +40 -0
  12. package/src/bot/detect/corpus/anasty17-mirror-leech-telegram-bot.json +50 -0
  13. package/src/bot/detect/corpus/anidiotsguide-guidebot.json +36 -0
  14. package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +45 -0
  15. package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +35 -0
  16. package/src/bot/detect/corpus/bracketed-templates-discordjs.json +40 -0
  17. package/src/bot/detect/corpus/cog-creators-red-discordbot.json +49 -0
  18. package/src/bot/detect/corpus/cranberriez-discord.ts-bot-template.json +35 -0
  19. package/src/bot/detect/corpus/crazycat256-nextcord-bot-template.json +38 -0
  20. package/src/bot/detect/corpus/discord-tickets-bot.json +45 -0
  21. package/src/bot/detect/corpus/edjopato-telegram-typescript-bot-template.json +37 -0
  22. package/src/bot/detect/corpus/haniali3161-bot-adhkar-telegram.json +33 -0
  23. package/src/bot/detect/corpus/hellosverre-discord-ts-template.json +34 -0
  24. package/src/bot/detect/corpus/kevinnovak-discord-bot-typescript-template.json +43 -0
  25. package/src/bot/detect/corpus/kkrypt0nn-python-discord-bot-template.json +41 -0
  26. package/src/bot/detect/corpus/kolezka-telegram-grammy-template.json +37 -0
  27. package/src/bot/detect/corpus/latand-tgbot_template_v3.json +37 -0
  28. package/src/bot/detect/corpus/mahmoudbm77-zekr-telegram-bot.json +36 -0
  29. package/src/bot/detect/corpus/mericcan41-discordjs-v14-template-ts.json +33 -0
  30. package/src/bot/detect/corpus/modmail-dev-modmail.json +52 -0
  31. package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +39 -0
  32. package/src/bot/detect/corpus/notchum-discord-bot-template.json +39 -0
  33. package/src/bot/detect/corpus/python-discord-bot.json +44 -0
  34. package/src/bot/detect/corpus/python-telegram-bot-rules-bot.json +39 -0
  35. package/src/bot/detect/corpus/rajdave69-pycord-bot-template.json +31 -0
  36. package/src/bot/detect/corpus/rapptz-robodanny.json +36 -0
  37. package/src/bot/detect/corpus/ry2x-typescript-discord.js-v.14-template.json +35 -0
  38. package/src/bot/detect/corpus/saiteja-madha-discord-js-bot.json +45 -0
  39. package/src/bot/detect/corpus/seegreatness-discord.js-for-heroku.json +37 -0
  40. package/src/bot/detect/corpus/serverk-discord-node.json +36 -0
  41. package/src/bot/detect/corpus/serverk-discord-python.json +35 -0
  42. package/src/bot/detect/corpus/serverk-telegram-node.json +36 -0
  43. package/src/bot/detect/corpus/serverk-telegram-python.json +35 -0
  44. package/src/bot/detect/corpus/shuruhatik-quran-radio-bot-discord.json +33 -0
  45. package/src/bot/detect/corpus/skyra-project-skyra.json +46 -0
  46. package/src/bot/detect/corpus/tgbot-collection-ytdlbot.json +41 -0
  47. package/src/bot/detect/corpus/zeriodev-music-bot.json +38 -0
  48. package/src/bot/detect/detect.corpus.test.ts +77 -0
  49. package/src/bot/detect/detect.test.ts +414 -17
  50. package/src/bot/detect/detect.ts +347 -36
  51. package/src/bot/detect/detectResult.ts +4 -0
  52. package/src/bot/detect/detectTable.test.ts +22 -6
  53. package/src/bot/detect/detectTable.ts +187 -19
  54. package/src/bot/detect/detectedVersion.ts +5 -0
  55. package/src/bot/detect/envExample.test.ts +55 -1
  56. package/src/bot/detect/envExample.ts +14 -0
  57. package/src/bot/detect/index.ts +1 -0
  58. package/src/bot/detect/keys.test.ts +32 -1
  59. package/src/bot/detect/keys.ts +15 -0
  60. package/src/bot/detect/serverkJson.test.ts +2 -0
  61. package/src/bot/detect/serverkJson.ts +4 -1
  62. package/src/bot/index.ts +1 -0
  63. package/src/cli/validate.test.ts +127 -1
  64. package/src/cli/validate.ts +42 -0
  65. package/src/manifest/manifest.ts +2 -1
  66. package/src/modules/bridge.ts +2 -0
  67. package/src/modules/bridgeModule.ts +5 -0
  68. package/src/protocol/bridgeCrash.ts +49 -0
  69. package/src/protocol/bridgeEvents.ts +9 -0
  70. package/src/protocol/bridgePreview.ts +1 -0
  71. package/src/rcon/rconAccess.ts +1 -1
  72. package/src/runtime/bridgeDriver.test.ts +45 -0
  73. package/src/runtime/bridgeDriver.ts +25 -0
  74. package/src/runtime/bridgeEvents.ts +2 -0
  75. package/src/runtime/bridgeRegistry.test.ts +32 -0
  76. package/src/runtime/bridgeSupervisor.test.ts +163 -3
  77. package/src/runtime/bridgeSupervisor.ts +42 -1
  78. package/src/runtime/serve.ts +26 -5
@@ -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;
@@ -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: "Paths the player cannot edit, rename or delete from the file manager.",
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.",
@@ -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);
@@ -5,4 +5,5 @@ export interface BridgePreview {
5
5
  variables?: Record<string, string>;
6
6
  confirm: BridgeConfirm;
7
7
  lines: LocalizedText[];
8
+ blocked?: boolean;
8
9
  }
@@ -249,7 +249,7 @@ export const rconAccessSections = (options: RconAccessSectionsOptions = {}): Bri
249
249
  ar: "غيّر كلمة المرور",
250
250
  en: "Rotate the password",
251
251
  },
252
- confirm: BridgeConfirm.Strong,
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)) {
@@ -111,6 +111,8 @@ export const createEventRing = (events: BridgeEventsModule | undefined, supervis
111
111
  push(BridgeEventName.ServerCrashed, payload);
112
112
 
113
113
  evidence.clear();
114
+
115
+ return cause;
114
116
  },
115
117
 
116
118
  discard() {
@@ -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 { BridgeRuntime } from "../core";
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
- markStable();
553
+ settle(launchStamp);
513
554
 
514
555
  return;
515
556
  }