@serverkgg/bridge 0.0.21 → 0.1.2
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 +3 -1
- package/src/bot/botDeploy.test.ts +363 -0
- package/src/bot/botDeploy.ts +136 -0
- package/src/bot/botEnvironment.test.ts +189 -0
- package/src/bot/botEnvironment.ts +141 -0
- package/src/bot/botIdentity.test.ts +251 -0
- package/src/bot/botIdentity.ts +140 -0
- package/src/bot/botPanel.test.ts +246 -0
- package/src/bot/botPanel.ts +129 -0
- package/src/bot/botState.test.ts +45 -0
- package/src/bot/botState.ts +54 -0
- package/src/bot/botTerminal.test.ts +79 -0
- package/src/bot/botTerminal.ts +62 -0
- package/src/bot/botVariables.test.ts +193 -0
- package/src/bot/botVariables.ts +73 -0
- package/src/bot/detect/botFramework.ts +23 -0
- package/src/bot/detect/botFrameworks.ts +198 -0
- package/src/bot/detect/botInspectWarning.ts +15 -0
- package/src/bot/detect/botPackageManager.ts +6 -0
- package/src/bot/detect/botPlatform.ts +10 -0
- package/src/bot/detect/botRuntime.ts +15 -0
- package/src/bot/detect/botToken.test.ts +71 -0
- package/src/bot/detect/botToken.ts +126 -0
- package/src/bot/detect/detect.test.ts +543 -0
- package/src/bot/detect/detect.ts +842 -0
- package/src/bot/detect/detectResult.ts +40 -0
- package/src/bot/detect/detectTable.test.ts +72 -0
- package/src/bot/detect/detectTable.ts +166 -0
- package/src/bot/detect/envExample.test.ts +29 -0
- package/src/bot/detect/envExample.ts +48 -0
- package/src/bot/detect/envKey.ts +7 -0
- package/src/bot/detect/envScan.test.ts +39 -0
- package/src/bot/detect/envScan.ts +87 -0
- package/src/bot/detect/index.ts +17 -0
- package/src/bot/detect/inferBotPlatform.test.ts +60 -0
- package/src/bot/detect/inferBotPlatform.ts +28 -0
- package/src/bot/detect/keys.test.ts +28 -0
- package/src/bot/detect/keys.ts +64 -0
- package/src/bot/detect/sensitiveKey.test.ts +91 -0
- package/src/bot/detect/sensitiveKey.ts +44 -0
- package/src/bot/detect/serverkJson.test.ts +88 -0
- package/src/bot/detect/serverkJson.ts +174 -0
- package/src/bot/index.ts +8 -0
- package/src/cli/detectTable.test.ts +66 -0
- package/src/cli/detectTable.ts +43 -0
- package/src/cli/main.ts +12 -4
- package/src/cli/renderedFile.ts +35 -0
- package/src/cli/schema.ts +7 -26
- package/src/cli/validate.test.ts +283 -3
- package/src/cli/validate.ts +105 -7
- package/src/context/bridgeContext.ts +17 -1
- package/src/core/bridgeAction.ts +2 -0
- package/src/core/bridgeActivity.ts +9 -0
- package/src/core/bridgeAvailability.ts +10 -0
- package/src/core/bridgePort.test.ts +45 -0
- package/src/core/bridgePort.ts +26 -0
- package/src/core/bridgeUserError.test.ts +10 -0
- package/src/core/bridgeUserError.ts +6 -2
- package/src/core/describeDownloadFailure.test.ts +58 -0
- package/src/core/describeDownloadFailure.ts +69 -0
- package/src/core/index.ts +3 -0
- package/src/guides/compile.ts +2 -2
- package/src/manifest/index.ts +1 -0
- package/src/manifest/kind.ts +5 -0
- package/src/manifest/manifest.test.ts +630 -3
- package/src/manifest/manifest.ts +391 -87
- package/src/manifest/runtime.ts +2 -2
- package/src/manifest/source.ts +4 -4
- package/src/modules/bridge.ts +8 -1
- package/src/modules/bridgeLifecycleSubject.ts +8 -0
- package/src/modules/bridgeModule.ts +19 -1
- package/src/modules/bridgeStopSignal.ts +13 -0
- package/src/modules/index.ts +2 -0
- package/src/panel/bridgeDetail.ts +4 -1
- package/src/protocol/bridgeActivity.ts +54 -0
- package/src/protocol/bridgeConfig.ts +2 -0
- package/src/protocol/bridgeEvents.ts +30 -0
- package/src/protocol/bridgeFrame.ts +10 -1
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeActions.ts +19 -2
- package/src/runtime/bridgeActivity.test.ts +318 -0
- package/src/runtime/bridgeActivity.ts +268 -0
- package/src/runtime/bridgeConfigReader.test.ts +80 -0
- package/src/runtime/bridgeConfigReader.ts +20 -0
- package/src/runtime/bridgeContext.test.ts +247 -0
- package/src/runtime/bridgeContext.ts +43 -10
- package/src/runtime/bridgeDriver.test.ts +153 -1
- package/src/runtime/bridgeDriver.ts +64 -8
- package/src/runtime/bridgeFiles.test.ts +62 -0
- package/src/runtime/bridgeFiles.ts +12 -2
- package/src/runtime/bridgeLiveConfig.ts +18 -0
- package/src/runtime/bridgeLog.ts +10 -0
- package/src/runtime/bridgeRedaction.test.ts +410 -0
- package/src/runtime/bridgeRedaction.ts +214 -0
- package/src/runtime/bridgeRefusalError.ts +13 -0
- package/src/runtime/bridgeRegistry.test.ts +1056 -10
- package/src/runtime/bridgeRegistry.ts +464 -50
- package/src/runtime/bridgeScopedContext.ts +55 -0
- package/src/runtime/bridgeServer.test.ts +534 -4
- package/src/runtime/bridgeServer.ts +145 -38
- package/src/runtime/bridgeSetupHandlers.test.ts +156 -5
- package/src/runtime/bridgeSetupHandlers.ts +59 -14
- package/src/runtime/bridgeSupervisor.test.ts +670 -2
- package/src/runtime/bridgeSupervisor.ts +219 -67
- package/src/runtime/serve.ts +87 -11
- package/src/steam/steamcmd.test.ts +32 -1
- package/src/steam/steamcmd.ts +27 -4
- package/src/utils/changedSettingKeys.test.ts +65 -0
- package/src/utils/changedSettingKeys.ts +6 -0
- package/src/utils/describeExecFailure.test.ts +34 -0
- package/src/utils/describeExecFailure.ts +9 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/sameSettingValue.test.ts +35 -0
- package/src/utils/sameSettingValue.ts +40 -0
- package/src/wire/activity.test.ts +95 -0
- package/src/wire/activity.ts +41 -0
- package/src/wire/index.ts +1 -0
- package/src/wire/panel.test.ts +67 -1
- package/src/wire/panel.ts +11 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { BridgeRuntime } from "../core";
|
|
4
|
+
import { BridgeLifecycleSubject, BridgeStopSignal } from "../modules";
|
|
4
5
|
import { BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
|
|
5
6
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
7
|
+
import { createBridgeRedaction, REDACTED } from "./bridgeRedaction";
|
|
6
8
|
import { readProcessGroup } from "./bridgeSignal";
|
|
7
9
|
import { type BridgeSupervisor, createSupervisor } from "./bridgeSupervisor";
|
|
8
10
|
|
|
@@ -142,7 +144,7 @@ const startWrapper = async (script: string, stopTimeoutSeconds: number) => {
|
|
|
142
144
|
};
|
|
143
145
|
};
|
|
144
146
|
|
|
145
|
-
const timedSupervisor = (command: () => string, ready: RegExp, stableAfterMs: number) => {
|
|
147
|
+
const timedSupervisor = (command: () => string, ready: RegExp, stableAfterMs: number, readyTimeoutSeconds?: number) => {
|
|
146
148
|
const records: BridgeCrashRecord[] = [];
|
|
147
149
|
|
|
148
150
|
let stables = 0;
|
|
@@ -152,6 +154,7 @@ const timedSupervisor = (command: () => string, ready: RegExp, stableAfterMs: nu
|
|
|
152
154
|
uid: process.getuid?.() ?? 0,
|
|
153
155
|
gid: process.getgid?.() ?? 0,
|
|
154
156
|
stopTimeoutSeconds: 1,
|
|
157
|
+
readyTimeoutSeconds,
|
|
155
158
|
ready,
|
|
156
159
|
stableAfterMs,
|
|
157
160
|
onLog: silent,
|
|
@@ -435,6 +438,56 @@ describe("createSupervisor", () => {
|
|
|
435
438
|
await supervisor.stop();
|
|
436
439
|
}, 30_000);
|
|
437
440
|
|
|
441
|
+
test("keeps a slow boot starting past the stable window when the driver declares a ready timeout", async () => {
|
|
442
|
+
const { supervisor, records, stables } = timedSupervisor(() => LATE_CRASH, NEVER_READY, 300, 30);
|
|
443
|
+
|
|
444
|
+
await supervisor.start();
|
|
445
|
+
|
|
446
|
+
await Bun.sleep(600);
|
|
447
|
+
|
|
448
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Starting);
|
|
449
|
+
|
|
450
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
451
|
+
|
|
452
|
+
const record = records.at(0);
|
|
453
|
+
|
|
454
|
+
expect(record?.phase).toBe(BridgeCrashPhase.Booting);
|
|
455
|
+
expect(record?.restarting).toBe(false);
|
|
456
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
|
|
457
|
+
expect(stables()).toBe(0);
|
|
458
|
+
}, 30_000);
|
|
459
|
+
|
|
460
|
+
test("fails the boot when the declared ready timeout passes, never flipping it to running", async () => {
|
|
461
|
+
const { supervisor, records, stables } = timedSupervisor(() => "exec sleep 300", NEVER_READY, 60_000, 1);
|
|
462
|
+
|
|
463
|
+
await supervisor.start();
|
|
464
|
+
|
|
465
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Starting);
|
|
466
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
467
|
+
|
|
468
|
+
const record = records.at(0);
|
|
469
|
+
|
|
470
|
+
expect(record?.phase).toBe(BridgeCrashPhase.Booting);
|
|
471
|
+
expect(record?.restarting).toBe(false);
|
|
472
|
+
expect(record?.outOfMemory).toBe(false);
|
|
473
|
+
expect(record?.lastLine).toBe("the game did not report ready within 1 seconds");
|
|
474
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
|
|
475
|
+
expect(supervisor.running).toBe(false);
|
|
476
|
+
expect(stables()).toBe(0);
|
|
477
|
+
}, 30_000);
|
|
478
|
+
|
|
479
|
+
test("still treats a game with no declared ready timeout as running once the default wait passes", async () => {
|
|
480
|
+
const { supervisor, stables } = timedSupervisor(() => "exec sleep 300", NEVER_READY, 60_000);
|
|
481
|
+
|
|
482
|
+
await supervisor.start();
|
|
483
|
+
|
|
484
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Starting);
|
|
485
|
+
expect(await waitFor(() => supervisor.runtime === BridgeRuntime.Running, 15_000)).toBe(true);
|
|
486
|
+
expect(stables()).toBe(1);
|
|
487
|
+
|
|
488
|
+
await supervisor.stop();
|
|
489
|
+
}, 30_000);
|
|
490
|
+
|
|
438
491
|
test("opens a new episode when start is asked after the breaker tripped", async () => {
|
|
439
492
|
let script = RUNNING_CRASH;
|
|
440
493
|
|
|
@@ -609,3 +662,618 @@ describe("createSupervisor", () => {
|
|
|
609
662
|
await supervisor.stop();
|
|
610
663
|
}, 30_000);
|
|
611
664
|
});
|
|
665
|
+
|
|
666
|
+
const PID_LINE = /^pid=(?<pid>\d+)$/;
|
|
667
|
+
|
|
668
|
+
const READY_THEN_WAIT = "echo pid=$$; echo ready; exec sleep 300";
|
|
669
|
+
|
|
670
|
+
const restartSupervisor = (script: () => string, resolveEnvironment?: () => Promise<Record<string, string>>) => {
|
|
671
|
+
const records: BridgeCrashRecord[] = [];
|
|
672
|
+
const pids: number[] = [];
|
|
673
|
+
|
|
674
|
+
let stopped = 0;
|
|
675
|
+
|
|
676
|
+
const supervisor = createSupervisor({
|
|
677
|
+
cwd: tmpdir(),
|
|
678
|
+
uid: process.getuid?.() ?? 0,
|
|
679
|
+
gid: process.getgid?.() ?? 0,
|
|
680
|
+
stopTimeoutSeconds: 1,
|
|
681
|
+
ready: READY,
|
|
682
|
+
onLog: silent,
|
|
683
|
+
resolveCommand: async () => {
|
|
684
|
+
return [
|
|
685
|
+
"bash",
|
|
686
|
+
"-c",
|
|
687
|
+
script(),
|
|
688
|
+
];
|
|
689
|
+
},
|
|
690
|
+
resolveEnvironment,
|
|
691
|
+
onStopped() {
|
|
692
|
+
stopped += 1;
|
|
693
|
+
},
|
|
694
|
+
onExit(record) {
|
|
695
|
+
records.push(record);
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
supervisor.subscribe((entry) => {
|
|
700
|
+
const pid = Number(entry.line.match(PID_LINE)?.groups?.pid);
|
|
701
|
+
|
|
702
|
+
if (Number.isSafeInteger(pid)) {
|
|
703
|
+
pids.push(pid);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
supervisor,
|
|
709
|
+
records,
|
|
710
|
+
pids,
|
|
711
|
+
stopped: () => stopped,
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
describe("the resolved environment", () => {
|
|
716
|
+
test("reaches the game beside what the bridge inherited", async () => {
|
|
717
|
+
const { supervisor } = restartSupervisor(
|
|
718
|
+
() => 'echo "probe=$SERVERK_PROBE"; echo "path=$PATH"; echo ready; exec sleep 300',
|
|
719
|
+
async () => ({
|
|
720
|
+
SERVERK_PROBE: "from the driver",
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
const probe = supervisor.watch(/^probe=(?<value>.*)$/, 10_000);
|
|
724
|
+
const path = supervisor.watch(/^path=(?<value>.*)$/, 10_000);
|
|
725
|
+
|
|
726
|
+
await supervisor.start();
|
|
727
|
+
|
|
728
|
+
expect((await probe)?.groups?.value).toBe("from the driver");
|
|
729
|
+
expect((await path)?.groups?.value).toBe(process.env.PATH ?? "");
|
|
730
|
+
|
|
731
|
+
await supervisor.stop();
|
|
732
|
+
}, 20_000);
|
|
733
|
+
|
|
734
|
+
test("leaves the inherited environment alone when the driver resolves none", async () => {
|
|
735
|
+
const { supervisor } = restartSupervisor(() => 'echo "path=$PATH"; echo ready; exec sleep 300');
|
|
736
|
+
const path = supervisor.watch(/^path=(?<value>.*)$/, 10_000);
|
|
737
|
+
|
|
738
|
+
await supervisor.start();
|
|
739
|
+
|
|
740
|
+
const value = (await path)?.groups?.value ?? "";
|
|
741
|
+
|
|
742
|
+
expect(value.length).toBeGreaterThan(0);
|
|
743
|
+
expect(value).toBe(process.env.PATH ?? "");
|
|
744
|
+
|
|
745
|
+
await supervisor.stop();
|
|
746
|
+
}, 20_000);
|
|
747
|
+
|
|
748
|
+
test("refuses a key that is not a variable name as a start failure", async () => {
|
|
749
|
+
const { supervisor } = restartSupervisor(
|
|
750
|
+
() => READY_THEN_WAIT,
|
|
751
|
+
async () => ({
|
|
752
|
+
"BAD-KEY": "value",
|
|
753
|
+
}),
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
await expect(supervisor.start()).rejects.toThrow('the game environment names "BAD-KEY"');
|
|
757
|
+
expect(supervisor.running).toBe(false);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("refuses a value carrying a NUL byte without echoing it", async () => {
|
|
761
|
+
const { supervisor } = restartSupervisor(
|
|
762
|
+
() => READY_THEN_WAIT,
|
|
763
|
+
async () => ({
|
|
764
|
+
BOT_TOKEN: "secret\0tail",
|
|
765
|
+
}),
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
const failure = await supervisor.start().then(
|
|
769
|
+
() => null,
|
|
770
|
+
(error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
expect(failure).toBe('the game environment value for "BOT_TOKEN" contains a NUL byte');
|
|
774
|
+
expect(supervisor.running).toBe(false);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe("restart", () => {
|
|
779
|
+
test("replaces the process without ever reporting it stopped", async () => {
|
|
780
|
+
const { supervisor, records, pids, stopped } = restartSupervisor(() => READY_THEN_WAIT);
|
|
781
|
+
|
|
782
|
+
await supervisor.start();
|
|
783
|
+
|
|
784
|
+
expect(await waitFor(() => pids.length === 1 && supervisor.runtime === BridgeRuntime.Running, 10_000)).toBe(true);
|
|
785
|
+
|
|
786
|
+
const seen = new Set<BridgeRuntime>();
|
|
787
|
+
|
|
788
|
+
let sampling = true;
|
|
789
|
+
|
|
790
|
+
const sampler = (async () => {
|
|
791
|
+
while (sampling) {
|
|
792
|
+
seen.add(supervisor.runtime);
|
|
793
|
+
|
|
794
|
+
await Bun.sleep(5);
|
|
795
|
+
}
|
|
796
|
+
})();
|
|
797
|
+
|
|
798
|
+
await supervisor.restart();
|
|
799
|
+
|
|
800
|
+
expect(await waitFor(() => pids.length === 2 && supervisor.runtime === BridgeRuntime.Running, 10_000)).toBe(true);
|
|
801
|
+
|
|
802
|
+
sampling = false;
|
|
803
|
+
|
|
804
|
+
await sampler;
|
|
805
|
+
|
|
806
|
+
const [first, second] = pids;
|
|
807
|
+
|
|
808
|
+
expect(second).not.toBe(first);
|
|
809
|
+
expect(await waitGone(first ?? 0, 5000), "the replaced process outlived the restart").toBe(true);
|
|
810
|
+
expect(seen.has(BridgeRuntime.Stopping)).toBe(true);
|
|
811
|
+
expect(seen.has(BridgeRuntime.Stopped), "a restart reported the game stopped").toBe(false);
|
|
812
|
+
expect(stopped(), "a restart ran the stopped hook").toBe(0);
|
|
813
|
+
expect(records, "a restart was recorded as a crash").toHaveLength(0);
|
|
814
|
+
expect(supervisor.restarts).toBe(0);
|
|
815
|
+
expect(supervisor.running).toBe(true);
|
|
816
|
+
|
|
817
|
+
await supervisor.stop();
|
|
818
|
+
|
|
819
|
+
expect(stopped()).toBe(1);
|
|
820
|
+
}, 30_000);
|
|
821
|
+
|
|
822
|
+
test("counts a quick exit after a restart as a boot crash, not a running one", async () => {
|
|
823
|
+
let script = READY_THEN_WAIT;
|
|
824
|
+
|
|
825
|
+
const { supervisor, records, pids } = restartSupervisor(() => script);
|
|
826
|
+
|
|
827
|
+
await supervisor.start();
|
|
828
|
+
|
|
829
|
+
expect(await waitFor(() => pids.length === 1 && supervisor.runtime === BridgeRuntime.Running, 10_000)).toBe(true);
|
|
830
|
+
|
|
831
|
+
script = BOOT_CRASH;
|
|
832
|
+
|
|
833
|
+
await supervisor.restart();
|
|
834
|
+
|
|
835
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
836
|
+
|
|
837
|
+
const record = records.at(0);
|
|
838
|
+
|
|
839
|
+
expect(record?.phase).toBe(BridgeCrashPhase.Booting);
|
|
840
|
+
expect(record?.restarting).toBe(false);
|
|
841
|
+
expect(record?.exitCode).toBe(3);
|
|
842
|
+
expect(record?.restarts).toBe(0);
|
|
843
|
+
|
|
844
|
+
await Bun.sleep(3000);
|
|
845
|
+
|
|
846
|
+
expect(records, "the boot crash after a restart was retried").toHaveLength(1);
|
|
847
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
|
|
848
|
+
expect(supervisor.restarts).toBe(0);
|
|
849
|
+
}, 30_000);
|
|
850
|
+
|
|
851
|
+
test("refuses to restart a game that is meant to be stopped", async () => {
|
|
852
|
+
const { supervisor } = restartSupervisor(() => READY_THEN_WAIT);
|
|
853
|
+
|
|
854
|
+
await expect(supervisor.restart()).rejects.toThrow("the game is not meant to be running");
|
|
855
|
+
expect(supervisor.running).toBe(false);
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const SIGNAL_EXITS = "echo pid=$$; echo ready; exec sleep 300";
|
|
860
|
+
|
|
861
|
+
const SIGNAL_IGNORED = 'trap "" TERM INT; sleep 300 & echo child=$!; echo ready; wait';
|
|
862
|
+
|
|
863
|
+
const signalSupervisor = (script: string, stopTimeoutSeconds: number, stopSignal?: BridgeStopSignal) => {
|
|
864
|
+
const pids: number[] = [];
|
|
865
|
+
|
|
866
|
+
const supervisor = createSupervisor({
|
|
867
|
+
cwd: tmpdir(),
|
|
868
|
+
uid: process.getuid?.() ?? 0,
|
|
869
|
+
gid: process.getgid?.() ?? 0,
|
|
870
|
+
stopTimeoutSeconds,
|
|
871
|
+
stopSignal,
|
|
872
|
+
ready: READY,
|
|
873
|
+
onLog: silent,
|
|
874
|
+
resolveCommand: async () => {
|
|
875
|
+
return [
|
|
876
|
+
"bash",
|
|
877
|
+
"-c",
|
|
878
|
+
script,
|
|
879
|
+
];
|
|
880
|
+
},
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
supervisor.subscribe((entry) => {
|
|
884
|
+
const pid = Number((entry.line.match(PID_LINE) ?? entry.line.match(CHILD_PATTERN))?.groups?.pid);
|
|
885
|
+
|
|
886
|
+
if (Number.isSafeInteger(pid)) {
|
|
887
|
+
pids.push(pid);
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
supervisor,
|
|
893
|
+
pids,
|
|
894
|
+
};
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const timed = async (run: () => Promise<void>) => {
|
|
898
|
+
const started = performance.now();
|
|
899
|
+
|
|
900
|
+
await run();
|
|
901
|
+
|
|
902
|
+
return performance.now() - started;
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
const runningWith = async (supervisor: BridgeSupervisor, pids: number[], count: number) => {
|
|
906
|
+
return await waitFor(() => pids.length === count && supervisor.runtime === BridgeRuntime.Running, 10_000);
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
describe("the stop signal", () => {
|
|
910
|
+
test("stops a game that exits on SIGTERM well inside the timeout", async () => {
|
|
911
|
+
const { supervisor, pids } = signalSupervisor(SIGNAL_EXITS, 10, BridgeStopSignal.Term);
|
|
912
|
+
|
|
913
|
+
await supervisor.start();
|
|
914
|
+
|
|
915
|
+
expect(await runningWith(supervisor, pids, 1)).toBe(true);
|
|
916
|
+
|
|
917
|
+
const elapsed = await timed(() => supervisor.stop());
|
|
918
|
+
|
|
919
|
+
expect(elapsed, "the stop waited for the timeout instead of signalling first").toBeLessThan(3000);
|
|
920
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Stopped);
|
|
921
|
+
expect(await waitGone(pids[0] ?? 0, 5000)).toBe(true);
|
|
922
|
+
}, 30_000);
|
|
923
|
+
|
|
924
|
+
test("stops a game that exits on SIGINT when the driver asks for it", async () => {
|
|
925
|
+
const { supervisor, pids } = signalSupervisor(SIGNAL_EXITS, 10, BridgeStopSignal.Interrupt);
|
|
926
|
+
|
|
927
|
+
await supervisor.start();
|
|
928
|
+
|
|
929
|
+
expect(await runningWith(supervisor, pids, 1)).toBe(true);
|
|
930
|
+
|
|
931
|
+
const elapsed = await timed(() => supervisor.stop());
|
|
932
|
+
|
|
933
|
+
expect(elapsed).toBeLessThan(3000);
|
|
934
|
+
expect(await waitGone(pids[0] ?? 0, 5000)).toBe(true);
|
|
935
|
+
}, 30_000);
|
|
936
|
+
|
|
937
|
+
test("still kills a game that ignores the signal once the timeout passes", async () => {
|
|
938
|
+
const { supervisor, pids } = signalSupervisor(SIGNAL_IGNORED, 1, BridgeStopSignal.Term);
|
|
939
|
+
|
|
940
|
+
await supervisor.start();
|
|
941
|
+
|
|
942
|
+
expect(await runningWith(supervisor, pids, 1)).toBe(true);
|
|
943
|
+
|
|
944
|
+
const elapsed = await timed(() => supervisor.stop());
|
|
945
|
+
|
|
946
|
+
expect(elapsed, "the kill came before the timeout").toBeGreaterThanOrEqual(900);
|
|
947
|
+
expect(elapsed, "the kill waited for the SIGTERM grace a signalled stop does not take").toBeLessThan(4000);
|
|
948
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Stopped);
|
|
949
|
+
expect(await waitGone(pids[0] ?? 0, 5000), "the ignoring child outlived the kill").toBe(true);
|
|
950
|
+
}, 30_000);
|
|
951
|
+
|
|
952
|
+
test("keeps waiting the timeout before SIGTERM when the driver declares no signal", async () => {
|
|
953
|
+
const { supervisor, pids } = signalSupervisor(SIGNAL_EXITS, 2);
|
|
954
|
+
|
|
955
|
+
await supervisor.start();
|
|
956
|
+
|
|
957
|
+
expect(await runningWith(supervisor, pids, 1)).toBe(true);
|
|
958
|
+
|
|
959
|
+
const elapsed = await timed(() => supervisor.stop());
|
|
960
|
+
|
|
961
|
+
expect(elapsed, "the stop signalled before the timeout without being asked to").toBeGreaterThanOrEqual(1900);
|
|
962
|
+
expect(elapsed).toBeLessThan(6000);
|
|
963
|
+
expect(await waitGone(pids[0] ?? 0, 5000)).toBe(true);
|
|
964
|
+
}, 30_000);
|
|
965
|
+
|
|
966
|
+
test("restarts through the same signal-first path", async () => {
|
|
967
|
+
const { supervisor, pids } = signalSupervisor(SIGNAL_EXITS, 10, BridgeStopSignal.Term);
|
|
968
|
+
|
|
969
|
+
await supervisor.start();
|
|
970
|
+
|
|
971
|
+
expect(await runningWith(supervisor, pids, 1)).toBe(true);
|
|
972
|
+
|
|
973
|
+
const elapsed = await timed(() => supervisor.restart());
|
|
974
|
+
|
|
975
|
+
expect(elapsed, "the restart waited for the timeout instead of signalling first").toBeLessThan(3000);
|
|
976
|
+
expect(await runningWith(supervisor, pids, 2)).toBe(true);
|
|
977
|
+
expect(pids[1]).not.toBe(pids[0]);
|
|
978
|
+
expect(await waitGone(pids[0] ?? 0, 5000)).toBe(true);
|
|
979
|
+
expect(supervisor.restarts).toBe(0);
|
|
980
|
+
|
|
981
|
+
await supervisor.stop();
|
|
982
|
+
}, 30_000);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
const LEAKED = "tok-1234567890-secret";
|
|
986
|
+
|
|
987
|
+
const LEAK_AND_CRASH = `echo "out ${LEAKED}"; echo "err ${LEAKED}" >&2; sleep 0.2; echo "fatal: bad token ${LEAKED}"; exit 3`;
|
|
988
|
+
|
|
989
|
+
const TICKING = `while true; do echo "tick ${LEAKED}"; sleep 0.1; done`;
|
|
990
|
+
|
|
991
|
+
const redactingSupervisor = (script: string, redact?: (text: string) => string) => {
|
|
992
|
+
const records: BridgeCrashRecord[] = [];
|
|
993
|
+
|
|
994
|
+
const supervisor = createSupervisor({
|
|
995
|
+
cwd: tmpdir(),
|
|
996
|
+
uid: process.getuid?.() ?? 0,
|
|
997
|
+
gid: process.getgid?.() ?? 0,
|
|
998
|
+
stopTimeoutSeconds: 1,
|
|
999
|
+
stopSignal: BridgeStopSignal.Term,
|
|
1000
|
+
onLog: silent,
|
|
1001
|
+
redact,
|
|
1002
|
+
resolveCommand: async () => {
|
|
1003
|
+
return [
|
|
1004
|
+
"bash",
|
|
1005
|
+
"-c",
|
|
1006
|
+
script,
|
|
1007
|
+
];
|
|
1008
|
+
},
|
|
1009
|
+
onExit(record) {
|
|
1010
|
+
records.push(record);
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
return {
|
|
1015
|
+
supervisor,
|
|
1016
|
+
records,
|
|
1017
|
+
};
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
const variablesSource = (initial: Record<string, string>, secrets: Record<string, string> = {}) => {
|
|
1021
|
+
let variables: Readonly<Record<string, string>> = initial;
|
|
1022
|
+
|
|
1023
|
+
return {
|
|
1024
|
+
source: {
|
|
1025
|
+
variables: () => variables,
|
|
1026
|
+
secrets: () => secrets,
|
|
1027
|
+
},
|
|
1028
|
+
save(next: Record<string, string>) {
|
|
1029
|
+
variables = next;
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
describe("secret redaction", () => {
|
|
1035
|
+
let written: string[] = [];
|
|
1036
|
+
let restore = () => {};
|
|
1037
|
+
|
|
1038
|
+
beforeEach(() => {
|
|
1039
|
+
written = [];
|
|
1040
|
+
|
|
1041
|
+
const capture = (chunk: unknown) => {
|
|
1042
|
+
written.push(String(chunk));
|
|
1043
|
+
|
|
1044
|
+
return true;
|
|
1045
|
+
};
|
|
1046
|
+
const out = spyOn(process.stdout, "write").mockImplementation(capture);
|
|
1047
|
+
const err = spyOn(process.stderr, "write").mockImplementation(capture);
|
|
1048
|
+
|
|
1049
|
+
restore = () => {
|
|
1050
|
+
out.mockRestore();
|
|
1051
|
+
err.mockRestore();
|
|
1052
|
+
};
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
afterEach(() => {
|
|
1056
|
+
restore();
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test("hides a secret value from stdout, stderr, the stored lines and the crash last line", async () => {
|
|
1060
|
+
const redact = createBridgeRedaction(
|
|
1061
|
+
[
|
|
1062
|
+
"BOT_TOKEN",
|
|
1063
|
+
],
|
|
1064
|
+
variablesSource({
|
|
1065
|
+
BOT_TOKEN: LEAKED,
|
|
1066
|
+
}).source,
|
|
1067
|
+
);
|
|
1068
|
+
const { supervisor, records } = redactingSupervisor(LEAK_AND_CRASH, redact);
|
|
1069
|
+
|
|
1070
|
+
await supervisor.start();
|
|
1071
|
+
|
|
1072
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
1073
|
+
|
|
1074
|
+
const lines = supervisor.tail(10);
|
|
1075
|
+
|
|
1076
|
+
expect(lines).toContain(`out ${REDACTED}`);
|
|
1077
|
+
expect(lines).toContain(`err ${REDACTED}`);
|
|
1078
|
+
expect(records.at(0)?.lastLine).toBe(`fatal: bad token ${REDACTED}`);
|
|
1079
|
+
expect(lines.join("\n")).not.toContain(LEAKED);
|
|
1080
|
+
expect(written.join("")).not.toContain(LEAKED);
|
|
1081
|
+
expect(written.join("")).toContain(REDACTED);
|
|
1082
|
+
}, 30_000);
|
|
1083
|
+
|
|
1084
|
+
test("hides a platform secret from the manifest that the game prints", async () => {
|
|
1085
|
+
const redact = createBridgeRedaction(
|
|
1086
|
+
[],
|
|
1087
|
+
variablesSource(
|
|
1088
|
+
{},
|
|
1089
|
+
{
|
|
1090
|
+
CURSEFORGE_API_KEY: LEAKED,
|
|
1091
|
+
},
|
|
1092
|
+
).source,
|
|
1093
|
+
);
|
|
1094
|
+
const { supervisor, records } = redactingSupervisor(LEAK_AND_CRASH, redact);
|
|
1095
|
+
|
|
1096
|
+
await supervisor.start();
|
|
1097
|
+
|
|
1098
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
1099
|
+
|
|
1100
|
+
const lines = supervisor.tail(10);
|
|
1101
|
+
|
|
1102
|
+
expect(lines).toContain(`out ${REDACTED}`);
|
|
1103
|
+
expect(lines).toContain(`err ${REDACTED}`);
|
|
1104
|
+
expect(lines.join("\n")).not.toContain(LEAKED);
|
|
1105
|
+
expect(records.at(0)?.lastLine).toBe(`fatal: bad token ${REDACTED}`);
|
|
1106
|
+
}, 30_000);
|
|
1107
|
+
|
|
1108
|
+
test("leaves a value shorter than eight characters in the lines", async () => {
|
|
1109
|
+
const redact = createBridgeRedaction(
|
|
1110
|
+
[
|
|
1111
|
+
"RCON_PASSWORD",
|
|
1112
|
+
],
|
|
1113
|
+
variablesSource({
|
|
1114
|
+
RCON_PASSWORD: "tok-123",
|
|
1115
|
+
}).source,
|
|
1116
|
+
);
|
|
1117
|
+
const { supervisor, records } = redactingSupervisor(LEAK_AND_CRASH, redact);
|
|
1118
|
+
|
|
1119
|
+
await supervisor.start();
|
|
1120
|
+
|
|
1121
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
1122
|
+
expect(records.at(0)?.lastLine).toBe(`fatal: bad token ${LEAKED}`);
|
|
1123
|
+
}, 30_000);
|
|
1124
|
+
|
|
1125
|
+
test("hides a value saved after the start from the lines that follow", async () => {
|
|
1126
|
+
const { source, save } = variablesSource({});
|
|
1127
|
+
const redact = createBridgeRedaction(
|
|
1128
|
+
[
|
|
1129
|
+
"BOT_TOKEN",
|
|
1130
|
+
],
|
|
1131
|
+
source,
|
|
1132
|
+
);
|
|
1133
|
+
const { supervisor } = redactingSupervisor(TICKING, redact);
|
|
1134
|
+
|
|
1135
|
+
await supervisor.start();
|
|
1136
|
+
|
|
1137
|
+
expect(await waitFor(() => supervisor.tail(1).at(0) === `tick ${LEAKED}`, 10_000)).toBe(true);
|
|
1138
|
+
|
|
1139
|
+
save({
|
|
1140
|
+
BOT_TOKEN: LEAKED,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
const seen = supervisor.tail(500).length;
|
|
1144
|
+
|
|
1145
|
+
expect(await waitFor(() => supervisor.tail(500).length >= seen + 3, 10_000)).toBe(true);
|
|
1146
|
+
|
|
1147
|
+
const following = supervisor.tail(500).slice(seen);
|
|
1148
|
+
|
|
1149
|
+
expect(following.length).toBeGreaterThan(0);
|
|
1150
|
+
expect(following.every((line) => line === `tick ${REDACTED}`)).toBe(true);
|
|
1151
|
+
|
|
1152
|
+
await supervisor.stop();
|
|
1153
|
+
}, 30_000);
|
|
1154
|
+
|
|
1155
|
+
test("runs a driver with no secret fields and no secrets without touching a line", async () => {
|
|
1156
|
+
const redact = createBridgeRedaction([], variablesSource({}).source);
|
|
1157
|
+
|
|
1158
|
+
const { supervisor, records } = redactingSupervisor(LEAK_AND_CRASH, redact);
|
|
1159
|
+
|
|
1160
|
+
await supervisor.start();
|
|
1161
|
+
|
|
1162
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
1163
|
+
expect(supervisor.tail(10)).toContain(`out ${LEAKED}`);
|
|
1164
|
+
expect(records.at(0)?.lastLine).toBe(`fatal: bad token ${LEAKED}`);
|
|
1165
|
+
}, 30_000);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
const recordingLogger = () => {
|
|
1169
|
+
const messages: string[] = [];
|
|
1170
|
+
|
|
1171
|
+
const logger: BridgeLeveledLogger = Object.assign(
|
|
1172
|
+
(message: string) => {
|
|
1173
|
+
messages.push(message);
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
warn: (message: string) => {
|
|
1177
|
+
messages.push(message);
|
|
1178
|
+
},
|
|
1179
|
+
error: (message: string) => {
|
|
1180
|
+
messages.push(message);
|
|
1181
|
+
},
|
|
1182
|
+
},
|
|
1183
|
+
);
|
|
1184
|
+
|
|
1185
|
+
return {
|
|
1186
|
+
logger,
|
|
1187
|
+
messages,
|
|
1188
|
+
};
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
const subjectSupervisor = (script: string, subject?: BridgeLifecycleSubject) => {
|
|
1192
|
+
const { logger, messages } = recordingLogger();
|
|
1193
|
+
const records: BridgeCrashRecord[] = [];
|
|
1194
|
+
|
|
1195
|
+
const supervisor = createSupervisor({
|
|
1196
|
+
cwd: tmpdir(),
|
|
1197
|
+
uid: process.getuid?.() ?? 0,
|
|
1198
|
+
gid: process.getgid?.() ?? 0,
|
|
1199
|
+
stopTimeoutSeconds: 1,
|
|
1200
|
+
ready: READY,
|
|
1201
|
+
subject,
|
|
1202
|
+
onLog: logger,
|
|
1203
|
+
resolveCommand: async () => {
|
|
1204
|
+
return [
|
|
1205
|
+
"bash",
|
|
1206
|
+
"-c",
|
|
1207
|
+
script,
|
|
1208
|
+
];
|
|
1209
|
+
},
|
|
1210
|
+
onExit(record) {
|
|
1211
|
+
records.push(record);
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
return {
|
|
1216
|
+
supervisor,
|
|
1217
|
+
messages,
|
|
1218
|
+
records,
|
|
1219
|
+
};
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
describe("the lifecycle subject", () => {
|
|
1223
|
+
test("names the game in the lifecycle lines when the driver declares no subject", async () => {
|
|
1224
|
+
const { supervisor, messages } = subjectSupervisor(READY_THEN_WAIT);
|
|
1225
|
+
|
|
1226
|
+
await supervisor.start();
|
|
1227
|
+
|
|
1228
|
+
expect(await waitFor(() => messages.includes("game ready"), 10_000)).toBe(true);
|
|
1229
|
+
|
|
1230
|
+
await supervisor.stop();
|
|
1231
|
+
|
|
1232
|
+
expect(messages).toEqual([
|
|
1233
|
+
"game started",
|
|
1234
|
+
"game ready",
|
|
1235
|
+
"game stopped",
|
|
1236
|
+
]);
|
|
1237
|
+
}, 20_000);
|
|
1238
|
+
|
|
1239
|
+
test("names the bot in every lifecycle line of a bot, the crash loop included", async () => {
|
|
1240
|
+
const { supervisor, messages, records } = subjectSupervisor(RUNNING_CRASH, BridgeLifecycleSubject.Bot);
|
|
1241
|
+
|
|
1242
|
+
await supervisor.start();
|
|
1243
|
+
|
|
1244
|
+
expect(await waitFor(() => records.length >= 3, 25_000)).toBe(true);
|
|
1245
|
+
|
|
1246
|
+
await supervisor.stop();
|
|
1247
|
+
|
|
1248
|
+
expect(messages).toContain("bot started");
|
|
1249
|
+
expect(messages).toContain("bot ready");
|
|
1250
|
+
expect(messages).toContain("bot exited, restarting");
|
|
1251
|
+
expect(messages).toContain("bot crash loop detected, not restarting");
|
|
1252
|
+
expect(messages.filter((message) => message.includes("game"))).toEqual([]);
|
|
1253
|
+
}, 40_000);
|
|
1254
|
+
|
|
1255
|
+
test("names the bot when it refuses an environment it cannot pass on", async () => {
|
|
1256
|
+
const { logger } = recordingLogger();
|
|
1257
|
+
|
|
1258
|
+
const supervisor = createSupervisor({
|
|
1259
|
+
cwd: tmpdir(),
|
|
1260
|
+
uid: process.getuid?.() ?? 0,
|
|
1261
|
+
gid: process.getgid?.() ?? 0,
|
|
1262
|
+
stopTimeoutSeconds: 1,
|
|
1263
|
+
subject: BridgeLifecycleSubject.Bot,
|
|
1264
|
+
onLog: logger,
|
|
1265
|
+
resolveCommand: async () => {
|
|
1266
|
+
return [
|
|
1267
|
+
"bash",
|
|
1268
|
+
"-c",
|
|
1269
|
+
READY_THEN_WAIT,
|
|
1270
|
+
];
|
|
1271
|
+
},
|
|
1272
|
+
resolveEnvironment: async () => ({
|
|
1273
|
+
"BAD-KEY": "value",
|
|
1274
|
+
}),
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
await expect(supervisor.start()).rejects.toThrow('the bot environment names "BAD-KEY"');
|
|
1278
|
+
});
|
|
1279
|
+
});
|