@serverkgg/bridge 0.0.19 → 0.0.20
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
CHANGED
|
@@ -981,19 +981,73 @@ describe("list", () => {
|
|
|
981
981
|
expect((await files.list("mods/*/*.jar")).map((entry) => entry.path)).toEqual([
|
|
982
982
|
"mods/custom/plugin.jar",
|
|
983
983
|
]);
|
|
984
|
-
await
|
|
984
|
+
expect((await files.list("**/*.jar")).map((entry) => entry.path).sort()).toEqual([
|
|
985
|
+
"mods/custom/plugin.jar",
|
|
986
|
+
"server.jar",
|
|
987
|
+
]);
|
|
988
|
+
});
|
|
989
|
+
test("prunes subtrees past the depth limit instead of failing the whole listing", async () => {
|
|
990
|
+
const nested = Array.from(
|
|
991
|
+
{
|
|
992
|
+
length: 14,
|
|
993
|
+
},
|
|
994
|
+
() => "nested",
|
|
995
|
+
).join("/");
|
|
996
|
+
await files.write("resource_packs/pack/manifest.json", "{}");
|
|
997
|
+
await files.write("resource_packs/pack/pack_icon.png", "icon");
|
|
998
|
+
await files.write(`resource_packs/pack/models/${nested}/obfuscated.json`, "{}");
|
|
999
|
+
expect((await files.list("**/*")).map((entry) => entry.path).sort()).toEqual([
|
|
1000
|
+
"resource_packs/pack/manifest.json",
|
|
1001
|
+
"resource_packs/pack/pack_icon.png",
|
|
1002
|
+
]);
|
|
1003
|
+
expect((await files.list("resource_packs/**/*.json")).map((entry) => entry.path)).toEqual([
|
|
1004
|
+
"resource_packs/pack/manifest.json",
|
|
1005
|
+
]);
|
|
1006
|
+
expect(
|
|
1007
|
+
(
|
|
1008
|
+
await files.list("**/*", {
|
|
1009
|
+
directory: "resource_packs/pack",
|
|
1010
|
+
})
|
|
1011
|
+
)
|
|
1012
|
+
.map((entry) => entry.path)
|
|
1013
|
+
.sort(),
|
|
1014
|
+
).toEqual([
|
|
1015
|
+
"resource_packs/pack/manifest.json",
|
|
1016
|
+
"resource_packs/pack/pack_icon.png",
|
|
1017
|
+
]);
|
|
985
1018
|
});
|
|
986
|
-
test("
|
|
987
|
-
|
|
988
|
-
|
|
1019
|
+
test("warns about what a pruned listing left out", async () => {
|
|
1020
|
+
const lines: string[] = [];
|
|
1021
|
+
const noisy = createBridgeFiles({
|
|
1022
|
+
root,
|
|
1023
|
+
uid: 0,
|
|
1024
|
+
gid: 0,
|
|
1025
|
+
egress,
|
|
1026
|
+
onLog: Object.assign(() => undefined, {
|
|
1027
|
+
warn: (message: string) => {
|
|
1028
|
+
lines.push(message);
|
|
1029
|
+
},
|
|
1030
|
+
error: () => undefined,
|
|
1031
|
+
}),
|
|
1032
|
+
});
|
|
1033
|
+
await noisy.write("manifest.json", "{}");
|
|
1034
|
+
await noisy.write(
|
|
1035
|
+
`models/${Array.from(
|
|
989
1036
|
{
|
|
990
1037
|
length: 14,
|
|
991
1038
|
},
|
|
992
1039
|
() => "nested",
|
|
993
|
-
).join("/")}/
|
|
994
|
-
"
|
|
1040
|
+
).join("/")}/obfuscated.json`,
|
|
1041
|
+
"{}",
|
|
995
1042
|
);
|
|
996
|
-
await
|
|
1043
|
+
expect((await noisy.list("**/*.json")).map((entry) => entry.path)).toEqual([
|
|
1044
|
+
"manifest.json",
|
|
1045
|
+
]);
|
|
1046
|
+
expect(lines).toEqual([
|
|
1047
|
+
"file listing skipped directories deeper than its depth limit",
|
|
1048
|
+
]);
|
|
1049
|
+
});
|
|
1050
|
+
test("refuses unsafe listing roots", async () => {
|
|
997
1051
|
await expect(
|
|
998
1052
|
files.list("**/*", {
|
|
999
1053
|
directory: "../outside",
|
|
@@ -209,10 +209,13 @@ const walk = async (
|
|
|
209
209
|
directoryPatterns: RegExp[],
|
|
210
210
|
scan: {
|
|
211
211
|
visited: number;
|
|
212
|
+
pruned: number;
|
|
212
213
|
},
|
|
213
214
|
) => {
|
|
214
215
|
if (depth > WALK_DEPTH) {
|
|
215
|
-
|
|
216
|
+
scan.pruned += 1;
|
|
217
|
+
|
|
218
|
+
return;
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
let entries: Dirent[];
|
|
@@ -392,9 +395,18 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
392
395
|
if (!resolved.isDirectory) {
|
|
393
396
|
throw new BridgePathError("invalid", `path "${prefix}" is not a directory to list`);
|
|
394
397
|
}
|
|
395
|
-
|
|
398
|
+
const scan = {
|
|
396
399
|
visited: 0,
|
|
397
|
-
|
|
400
|
+
pruned: 0,
|
|
401
|
+
};
|
|
402
|
+
await walk(resolved.target, prefix, 0, entries, pattern, directoryPatterns, scan);
|
|
403
|
+
if (scan.pruned > 0) {
|
|
404
|
+
options.onLog.warn("file listing skipped directories deeper than its depth limit", {
|
|
405
|
+
glob,
|
|
406
|
+
depth: WALK_DEPTH,
|
|
407
|
+
skipped: scan.pruned,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
398
410
|
return entries;
|
|
399
411
|
},
|
|
400
412
|
|
|
@@ -4,7 +4,7 @@ import { BridgeRuntime } from "../core";
|
|
|
4
4
|
import { BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
|
|
5
5
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
6
6
|
import { readProcessGroup } from "./bridgeSignal";
|
|
7
|
-
import { createSupervisor } from "./bridgeSupervisor";
|
|
7
|
+
import { type BridgeSupervisor, createSupervisor } from "./bridgeSupervisor";
|
|
8
8
|
|
|
9
9
|
const CHILD_PATTERN = /child=(?<pid>\d+)/;
|
|
10
10
|
|
|
@@ -169,6 +169,55 @@ const timedSupervisor = (command: () => string, ready: RegExp, stableAfterMs: nu
|
|
|
169
169
|
};
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
+
const racedSupervisor = (delayMs: number, resolve: () => string[]) => {
|
|
173
|
+
const pids: number[] = [];
|
|
174
|
+
|
|
175
|
+
let launches = 0;
|
|
176
|
+
let overlapped = false;
|
|
177
|
+
|
|
178
|
+
const supervisor: BridgeSupervisor = createSupervisor({
|
|
179
|
+
cwd: tmpdir(),
|
|
180
|
+
uid: process.getuid?.() ?? 0,
|
|
181
|
+
gid: process.getgid?.() ?? 0,
|
|
182
|
+
stopTimeoutSeconds: 1,
|
|
183
|
+
onLog: silent,
|
|
184
|
+
async resolveCommand() {
|
|
185
|
+
launches += 1;
|
|
186
|
+
|
|
187
|
+
if (supervisor.running) {
|
|
188
|
+
overlapped = true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await Bun.sleep(delayMs);
|
|
192
|
+
|
|
193
|
+
return resolve();
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
supervisor.subscribe((entry) => {
|
|
198
|
+
const pid = Number(entry.line.match(CHILD_PATTERN)?.groups?.pid);
|
|
199
|
+
|
|
200
|
+
if (Number.isSafeInteger(pid)) {
|
|
201
|
+
pids.push(pid);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
supervisor,
|
|
207
|
+
pids,
|
|
208
|
+
launches: () => launches,
|
|
209
|
+
overlapped: () => overlapped,
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const wrapperArgv = () => {
|
|
214
|
+
return [
|
|
215
|
+
"bash",
|
|
216
|
+
"-c",
|
|
217
|
+
WRAPPER,
|
|
218
|
+
];
|
|
219
|
+
};
|
|
220
|
+
|
|
172
221
|
describe("createSupervisor", () => {
|
|
173
222
|
test("runs the game outside the bridge's own process group", async () => {
|
|
174
223
|
const { supervisor, pid } = await startWrapper(WRAPPER, 1);
|
|
@@ -397,4 +446,77 @@ describe("createSupervisor", () => {
|
|
|
397
446
|
|
|
398
447
|
await supervisor.stop();
|
|
399
448
|
}, 40_000);
|
|
449
|
+
|
|
450
|
+
test("spawns one game when two starts race inside the launch window", async () => {
|
|
451
|
+
const { supervisor, pids, launches, overlapped } = racedSupervisor(200, wrapperArgv);
|
|
452
|
+
|
|
453
|
+
await Promise.all([
|
|
454
|
+
supervisor.start(),
|
|
455
|
+
supervisor.start(),
|
|
456
|
+
]);
|
|
457
|
+
|
|
458
|
+
expect(launches(), "the second start entered the launch path beside the first").toBe(1);
|
|
459
|
+
expect(await waitFor(() => pids.length > 0, 10_000)).toBe(true);
|
|
460
|
+
|
|
461
|
+
await Bun.sleep(500);
|
|
462
|
+
|
|
463
|
+
expect(pids, "the race spawned a second game").toHaveLength(1);
|
|
464
|
+
expect(overlapped()).toBe(false);
|
|
465
|
+
|
|
466
|
+
await supervisor.stop();
|
|
467
|
+
|
|
468
|
+
expect(await waitGone(pids.at(0) ?? 0, 5000)).toBe(true);
|
|
469
|
+
}, 30_000);
|
|
470
|
+
|
|
471
|
+
test("never spawns beside the game a stop is taking down", async () => {
|
|
472
|
+
const { supervisor, pids, launches, overlapped } = racedSupervisor(0, wrapperArgv);
|
|
473
|
+
|
|
474
|
+
await supervisor.start();
|
|
475
|
+
|
|
476
|
+
expect(await waitFor(() => pids.length === 1, 10_000)).toBe(true);
|
|
477
|
+
|
|
478
|
+
const first = pids.at(0) ?? 0;
|
|
479
|
+
|
|
480
|
+
const stopping = supervisor.stop();
|
|
481
|
+
const starting = supervisor.start();
|
|
482
|
+
|
|
483
|
+
await stopping;
|
|
484
|
+
await starting;
|
|
485
|
+
|
|
486
|
+
expect(await waitFor(() => pids.length === 2, 10_000)).toBe(true);
|
|
487
|
+
expect(launches()).toBe(2);
|
|
488
|
+
expect(overlapped(), "the queued start spawned while the stopped game was still alive").toBe(false);
|
|
489
|
+
expect(await waitGone(first, 5000)).toBe(true);
|
|
490
|
+
expect(supervisor.running).toBe(true);
|
|
491
|
+
|
|
492
|
+
await supervisor.stop();
|
|
493
|
+
|
|
494
|
+
expect(await waitGone(pids.at(1) ?? 0, 5000)).toBe(true);
|
|
495
|
+
}, 30_000);
|
|
496
|
+
|
|
497
|
+
test("starts again after a launch that threw before the game was spawned", async () => {
|
|
498
|
+
let resolvable = false;
|
|
499
|
+
|
|
500
|
+
const { supervisor, pids } = racedSupervisor(0, () => {
|
|
501
|
+
if (!resolvable) {
|
|
502
|
+
throw new Error("the start command could not be resolved");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return wrapperArgv();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await expect(supervisor.start()).rejects.toThrow("the start command could not be resolved");
|
|
509
|
+
expect(supervisor.running).toBe(false);
|
|
510
|
+
|
|
511
|
+
resolvable = true;
|
|
512
|
+
|
|
513
|
+
await supervisor.start();
|
|
514
|
+
|
|
515
|
+
expect(supervisor.running, "the failed launch wedged the supervisor").toBe(true);
|
|
516
|
+
expect(await waitFor(() => pids.length === 1, 10_000)).toBe(true);
|
|
517
|
+
|
|
518
|
+
await supervisor.stop();
|
|
519
|
+
|
|
520
|
+
expect(await waitGone(pids.at(0) ?? 0, 5000)).toBe(true);
|
|
521
|
+
}, 30_000);
|
|
400
522
|
});
|
|
@@ -135,6 +135,21 @@ const drainPumps = async (pumps: readonly Promise<unknown>[]) => {
|
|
|
135
135
|
]);
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
+
const chain = () => {
|
|
139
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
140
|
+
|
|
141
|
+
return <Result>(run: () => Promise<Result>): Promise<Result> => {
|
|
142
|
+
const next = queue.then(run, run);
|
|
143
|
+
|
|
144
|
+
queue = next.then(
|
|
145
|
+
() => undefined,
|
|
146
|
+
() => undefined,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return next;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
138
153
|
export const createSupervisor = (options: SupervisorOptions) => {
|
|
139
154
|
const tail: SupervisorLine[] = [];
|
|
140
155
|
const watchers = new Set<LineWatcher>();
|
|
@@ -151,7 +166,9 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
151
166
|
let launchedAt = performance.now();
|
|
152
167
|
let stable = false;
|
|
153
168
|
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
154
|
-
|
|
169
|
+
|
|
170
|
+
const serialize = chain();
|
|
171
|
+
const transition = chain();
|
|
155
172
|
|
|
156
173
|
const markStable = () => {
|
|
157
174
|
if (stable) {
|
|
@@ -299,7 +316,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
299
316
|
|
|
300
317
|
reported = true;
|
|
301
318
|
|
|
302
|
-
void handleExit(code, pumps).catch((error: unknown) => {
|
|
319
|
+
void handleExit(spawned, code, pumps).catch((error: unknown) => {
|
|
303
320
|
options.onLog.error("the game exit was not reported", {
|
|
304
321
|
error: errorText(error),
|
|
305
322
|
});
|
|
@@ -418,24 +435,24 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
418
435
|
const restart = async () => {
|
|
419
436
|
await Bun.sleep(RESTART_DELAY_MS);
|
|
420
437
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
438
|
+
await transition(async () => {
|
|
439
|
+
if (!shouldRun || breaker || child !== null) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
424
442
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
443
|
+
try {
|
|
444
|
+
await launch();
|
|
445
|
+
} catch (error) {
|
|
446
|
+
runtime = BridgeRuntime.Crashed;
|
|
429
447
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
448
|
+
options.onLog.error("game failed to restart", {
|
|
449
|
+
error: errorText(error),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
});
|
|
434
453
|
};
|
|
435
454
|
|
|
436
|
-
|
|
437
|
-
const finished = child;
|
|
438
|
-
|
|
455
|
+
const reap = (finished: SpawnedGame, code: number) => {
|
|
439
456
|
child = null;
|
|
440
457
|
exitCode = code;
|
|
441
458
|
|
|
@@ -445,11 +462,17 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
445
462
|
stableTimer = null;
|
|
446
463
|
}
|
|
447
464
|
|
|
448
|
-
|
|
449
|
-
sweep(finished.pid, childGroup);
|
|
450
|
-
}
|
|
465
|
+
sweep(finished.pid, childGroup);
|
|
451
466
|
|
|
452
467
|
childGroup = null;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
async function handleExit(finished: SpawnedGame, code: number, pumps: readonly Promise<unknown>[]) {
|
|
471
|
+
if (finished !== child) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
reap(finished, code);
|
|
453
476
|
|
|
454
477
|
if (!shouldRun) {
|
|
455
478
|
outOfMemory = false;
|
|
@@ -535,17 +558,6 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
535
558
|
void restart();
|
|
536
559
|
}
|
|
537
560
|
|
|
538
|
-
const serialize = <Result>(run: () => Promise<Result>): Promise<Result> => {
|
|
539
|
-
const next = queue.then(run, run);
|
|
540
|
-
|
|
541
|
-
queue = next.then(
|
|
542
|
-
() => undefined,
|
|
543
|
-
() => undefined,
|
|
544
|
-
);
|
|
545
|
-
|
|
546
|
-
return next;
|
|
547
|
-
};
|
|
548
|
-
|
|
549
561
|
const writeLine = async (input: string) => {
|
|
550
562
|
if (!child) {
|
|
551
563
|
throw new Error("the game is not running");
|
|
@@ -578,73 +590,89 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
578
590
|
},
|
|
579
591
|
|
|
580
592
|
async start() {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
593
|
+
await transition(async () => {
|
|
594
|
+
shouldRun = true;
|
|
595
|
+
breaker = false;
|
|
596
|
+
outOfMemory = false;
|
|
597
|
+
restarts = 0;
|
|
598
|
+
restartStamps.length = 0;
|
|
599
|
+
|
|
600
|
+
if (child) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
590
603
|
|
|
591
|
-
|
|
604
|
+
await launch();
|
|
605
|
+
});
|
|
592
606
|
},
|
|
593
607
|
|
|
594
608
|
async stop() {
|
|
595
609
|
shouldRun = false;
|
|
596
610
|
|
|
597
|
-
|
|
598
|
-
|
|
611
|
+
await transition(async () => {
|
|
612
|
+
shouldRun = false;
|
|
599
613
|
|
|
600
|
-
|
|
614
|
+
if (!child) {
|
|
615
|
+
runtime = BridgeRuntime.Stopped;
|
|
601
616
|
|
|
602
|
-
|
|
603
|
-
|
|
617
|
+
options.onStopped?.();
|
|
618
|
+
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
604
621
|
|
|
605
|
-
|
|
606
|
-
|
|
622
|
+
const active = child;
|
|
623
|
+
const group = childGroup;
|
|
607
624
|
|
|
608
|
-
|
|
625
|
+
runtime = BridgeRuntime.Stopping;
|
|
609
626
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
627
|
+
if (options.gracefulStop) {
|
|
628
|
+
const graceful = options.gracefulStop().catch((error: unknown) => {
|
|
629
|
+
options.onLog.warn("graceful stop failed", {
|
|
630
|
+
error: errorText(error),
|
|
631
|
+
});
|
|
614
632
|
});
|
|
615
|
-
});
|
|
616
633
|
|
|
617
|
-
|
|
618
|
-
|
|
634
|
+
await Promise.race([
|
|
635
|
+
graceful,
|
|
636
|
+
active.exited,
|
|
637
|
+
]);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const settled = await Promise.race([
|
|
619
641
|
active.exited,
|
|
642
|
+
Bun.sleep(options.stopTimeoutSeconds * 1000).then(() => null),
|
|
620
643
|
]);
|
|
621
|
-
}
|
|
622
644
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
Bun.sleep(options.stopTimeoutSeconds * 1000).then(() => null),
|
|
626
|
-
]);
|
|
645
|
+
if (settled === null) {
|
|
646
|
+
terminate(active, group, "SIGTERM");
|
|
627
647
|
|
|
628
|
-
|
|
629
|
-
|
|
648
|
+
const afterTerm = await Promise.race([
|
|
649
|
+
active.exited,
|
|
650
|
+
Bun.sleep(KILL_GRACE_MS).then(() => null),
|
|
651
|
+
]);
|
|
630
652
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
Bun.sleep(KILL_GRACE_MS).then(() => null),
|
|
634
|
-
]);
|
|
653
|
+
if (afterTerm === null) {
|
|
654
|
+
terminate(active, group, "SIGKILL");
|
|
635
655
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
await active.exited;
|
|
656
|
+
await active.exited;
|
|
657
|
+
}
|
|
640
658
|
}
|
|
641
|
-
}
|
|
642
659
|
|
|
643
|
-
|
|
660
|
+
if (child === active) {
|
|
661
|
+
const code = exitCodeOf(active.exitCode, active.signalCode);
|
|
644
662
|
|
|
645
|
-
|
|
663
|
+
reap(active, code);
|
|
664
|
+
|
|
665
|
+
outOfMemory = false;
|
|
666
|
+
|
|
667
|
+
options.onLog("game stopped", {
|
|
668
|
+
code,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
646
671
|
|
|
647
|
-
|
|
672
|
+
runtime = BridgeRuntime.Stopped;
|
|
673
|
+
|
|
674
|
+
options.onStopped?.();
|
|
675
|
+
});
|
|
648
676
|
},
|
|
649
677
|
|
|
650
678
|
async command(input: string, commandOptions: BridgeCommandOptions = {}): Promise<BridgeReply> {
|