@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -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 expect(files.list("**/*.jar")).rejects.toThrow("directory depth limit");
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("refuses partial deep listings and unsafe listing roots", async () => {
987
- await files.write(
988
- `${Array.from(
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("/")}/file.txt`,
994
- "data",
1040
+ ).join("/")}/obfuscated.json`,
1041
+ "{}",
995
1042
  );
996
- await expect(files.list("**/*")).rejects.toThrow("directory depth limit");
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
- throw new BridgePathError("invalid", "file listing exceeded its directory depth limit");
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
- await walk(resolved.target, prefix, 0, entries, pattern, directoryPatterns, {
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
- let queue: Promise<unknown> = Promise.resolve();
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
- if (!shouldRun || breaker || child !== null) {
422
- return;
423
- }
438
+ await transition(async () => {
439
+ if (!shouldRun || breaker || child !== null) {
440
+ return;
441
+ }
424
442
 
425
- try {
426
- await launch();
427
- } catch (error) {
428
- runtime = BridgeRuntime.Crashed;
443
+ try {
444
+ await launch();
445
+ } catch (error) {
446
+ runtime = BridgeRuntime.Crashed;
429
447
 
430
- options.onLog.error("game failed to restart", {
431
- error: errorText(error),
432
- });
433
- }
448
+ options.onLog.error("game failed to restart", {
449
+ error: errorText(error),
450
+ });
451
+ }
452
+ });
434
453
  };
435
454
 
436
- async function handleExit(code: number, pumps: readonly Promise<unknown>[]) {
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
- if (finished) {
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
- shouldRun = true;
582
- breaker = false;
583
- outOfMemory = false;
584
- restarts = 0;
585
- restartStamps.length = 0;
586
-
587
- if (child) {
588
- return;
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
- await launch();
604
+ await launch();
605
+ });
592
606
  },
593
607
 
594
608
  async stop() {
595
609
  shouldRun = false;
596
610
 
597
- if (!child) {
598
- runtime = BridgeRuntime.Stopped;
611
+ await transition(async () => {
612
+ shouldRun = false;
599
613
 
600
- options.onStopped?.();
614
+ if (!child) {
615
+ runtime = BridgeRuntime.Stopped;
601
616
 
602
- return;
603
- }
617
+ options.onStopped?.();
618
+
619
+ return;
620
+ }
604
621
 
605
- const active = child;
606
- const group = childGroup;
622
+ const active = child;
623
+ const group = childGroup;
607
624
 
608
- runtime = BridgeRuntime.Stopping;
625
+ runtime = BridgeRuntime.Stopping;
609
626
 
610
- if (options.gracefulStop) {
611
- const graceful = options.gracefulStop().catch((error: unknown) => {
612
- options.onLog.warn("graceful stop failed", {
613
- error: errorText(error),
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
- await Promise.race([
618
- graceful,
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
- const settled = await Promise.race([
624
- active.exited,
625
- Bun.sleep(options.stopTimeoutSeconds * 1000).then(() => null),
626
- ]);
645
+ if (settled === null) {
646
+ terminate(active, group, "SIGTERM");
627
647
 
628
- if (settled === null) {
629
- terminate(active, group, "SIGTERM");
648
+ const afterTerm = await Promise.race([
649
+ active.exited,
650
+ Bun.sleep(KILL_GRACE_MS).then(() => null),
651
+ ]);
630
652
 
631
- const afterTerm = await Promise.race([
632
- active.exited,
633
- Bun.sleep(KILL_GRACE_MS).then(() => null),
634
- ]);
653
+ if (afterTerm === null) {
654
+ terminate(active, group, "SIGKILL");
635
655
 
636
- if (afterTerm === null) {
637
- terminate(active, group, "SIGKILL");
638
-
639
- await active.exited;
656
+ await active.exited;
657
+ }
640
658
  }
641
- }
642
659
 
643
- sweep(active.pid, group);
660
+ if (child === active) {
661
+ const code = exitCodeOf(active.exitCode, active.signalCode);
644
662
 
645
- runtime = BridgeRuntime.Stopped;
663
+ reap(active, code);
664
+
665
+ outOfMemory = false;
666
+
667
+ options.onLog("game stopped", {
668
+ code,
669
+ });
670
+ }
646
671
 
647
- options.onStopped?.();
672
+ runtime = BridgeRuntime.Stopped;
673
+
674
+ options.onStopped?.();
675
+ });
648
676
  },
649
677
 
650
678
  async command(input: string, commandOptions: BridgeCommandOptions = {}): Promise<BridgeReply> {