@serverkgg/bridge 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/validate.test.ts +194 -2
- package/src/cli/validate.ts +83 -1
- package/src/guides/variables.ts +1 -0
- package/src/panel/bridgePanel.ts +11 -0
- package/src/panel/bridgePlace.ts +4 -0
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCrash.ts +83 -0
- package/src/protocol/bridgeFrame.ts +1 -0
- package/src/protocol/index.ts +1 -0
- package/src/rcon/rconAccess.test.ts +20 -2
- package/src/rcon/rconAccess.ts +9 -1
- package/src/runtime/bridgeDriver.test.ts +145 -0
- package/src/runtime/bridgeDriver.ts +44 -2
- package/src/runtime/bridgeEvents.test.ts +411 -9
- package/src/runtime/bridgeEvents.ts +53 -3
- package/src/runtime/bridgeRegistry.test.ts +96 -0
- package/src/runtime/bridgeRegistry.ts +33 -1
- package/src/runtime/bridgeServer.test.ts +1 -0
- package/src/runtime/bridgeSupervisor.test.ts +289 -0
- package/src/runtime/bridgeSupervisor.ts +166 -9
- package/src/runtime/serve.ts +10 -0
- package/src/steam/avatars.test.ts +306 -0
- package/src/steam/avatars.ts +107 -0
- package/src/steam/index.ts +1 -0
- package/src/wire/panel.test.ts +79 -0
- package/src/wire/panel.ts +11 -0
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
type BridgeField,
|
|
10
10
|
BridgeFormTarget,
|
|
11
11
|
BridgeLayout,
|
|
12
|
+
BridgePlace,
|
|
12
13
|
type BridgeSectionAction,
|
|
13
14
|
type BridgeSectionUpload,
|
|
15
|
+
type BridgeTableSection,
|
|
14
16
|
BridgeUploadMode,
|
|
15
17
|
type BridgeVisibleWhen,
|
|
16
18
|
} from "../panel";
|
|
@@ -1636,3 +1638,146 @@ describe("field patterns", () => {
|
|
|
1636
1638
|
).toThrow("is a secret control with options — a secret is typed, never chosen from a list");
|
|
1637
1639
|
});
|
|
1638
1640
|
});
|
|
1641
|
+
|
|
1642
|
+
describe("section placement", () => {
|
|
1643
|
+
const anyVisibleWhen: BridgeVisibleWhen = {
|
|
1644
|
+
variable: "MODPACK",
|
|
1645
|
+
empty: true,
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
const driverWithRoster = (section: Partial<BridgeTableSection>): BridgeDriver => ({
|
|
1649
|
+
install: {
|
|
1650
|
+
kind: BridgeKind.Install,
|
|
1651
|
+
run: async () => {
|
|
1652
|
+
return;
|
|
1653
|
+
},
|
|
1654
|
+
},
|
|
1655
|
+
lifecycle: {
|
|
1656
|
+
kind: BridgeKind.Lifecycle,
|
|
1657
|
+
command: async () => [
|
|
1658
|
+
"run",
|
|
1659
|
+
],
|
|
1660
|
+
},
|
|
1661
|
+
modules: {
|
|
1662
|
+
players: {
|
|
1663
|
+
kind: BridgeKind.Collection,
|
|
1664
|
+
list: async () => [],
|
|
1665
|
+
actions: {
|
|
1666
|
+
ban: async () => {
|
|
1667
|
+
return;
|
|
1668
|
+
},
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
},
|
|
1672
|
+
panel: {
|
|
1673
|
+
tabs: [
|
|
1674
|
+
{
|
|
1675
|
+
id: "roster",
|
|
1676
|
+
title: text("Roster"),
|
|
1677
|
+
sections: [
|
|
1678
|
+
{
|
|
1679
|
+
layout: BridgeLayout.Table,
|
|
1680
|
+
id: "online",
|
|
1681
|
+
module: "players",
|
|
1682
|
+
columns: [
|
|
1683
|
+
{
|
|
1684
|
+
key: "name",
|
|
1685
|
+
label: text("Name"),
|
|
1686
|
+
},
|
|
1687
|
+
],
|
|
1688
|
+
...section,
|
|
1689
|
+
},
|
|
1690
|
+
],
|
|
1691
|
+
},
|
|
1692
|
+
],
|
|
1693
|
+
},
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
const placedForm = (place: BridgePlace): BridgeDriver => {
|
|
1697
|
+
const driver = driverWith(anyVisibleWhen);
|
|
1698
|
+
|
|
1699
|
+
Object.assign(formSectionOf(driver), {
|
|
1700
|
+
place,
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
return driver;
|
|
1704
|
+
};
|
|
1705
|
+
|
|
1706
|
+
test("accepts a table placed on the players page", () => {
|
|
1707
|
+
expect(() =>
|
|
1708
|
+
validateDriver(
|
|
1709
|
+
driverWithRoster({
|
|
1710
|
+
place: BridgePlace.Players,
|
|
1711
|
+
}),
|
|
1712
|
+
),
|
|
1713
|
+
).not.toThrow();
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
test("rejects a form placed on the players page", () => {
|
|
1717
|
+
expect(() => validateDriver(placedForm(BridgePlace.Players))).toThrow(
|
|
1718
|
+
'tab "version" section "version" places a form section on players, which that page cannot render',
|
|
1719
|
+
);
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
test("rejects a form placed on the overview page", () => {
|
|
1723
|
+
expect(() => validateDriver(placedForm(BridgePlace.Overview))).toThrow(
|
|
1724
|
+
"places a form section on overview, which that page cannot render",
|
|
1725
|
+
);
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
test("rejects a table placed on the overview page", () => {
|
|
1729
|
+
expect(() =>
|
|
1730
|
+
validateDriver(
|
|
1731
|
+
driverWithRoster({
|
|
1732
|
+
place: BridgePlace.Overview,
|
|
1733
|
+
}),
|
|
1734
|
+
),
|
|
1735
|
+
).toThrow('tab "roster" section "online" places a table section on overview, which that page cannot render');
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
test("accepts an offline row action on a table", () => {
|
|
1739
|
+
expect(() =>
|
|
1740
|
+
validateDriver(
|
|
1741
|
+
driverWithRoster({
|
|
1742
|
+
place: BridgePlace.Players,
|
|
1743
|
+
actions: [
|
|
1744
|
+
{
|
|
1745
|
+
id: "ban",
|
|
1746
|
+
label: text("Ban"),
|
|
1747
|
+
offline: true,
|
|
1748
|
+
},
|
|
1749
|
+
],
|
|
1750
|
+
}),
|
|
1751
|
+
),
|
|
1752
|
+
).not.toThrow();
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
test("rejects an offline action on an actions section", () => {
|
|
1756
|
+
expect(() =>
|
|
1757
|
+
validateDriver(
|
|
1758
|
+
driverWithActions([
|
|
1759
|
+
{
|
|
1760
|
+
id: "say",
|
|
1761
|
+
label: text("Say"),
|
|
1762
|
+
offline: true,
|
|
1763
|
+
},
|
|
1764
|
+
]),
|
|
1765
|
+
),
|
|
1766
|
+
).toThrow(
|
|
1767
|
+
'tab "gameplay" section "broadcast" action "say" is offline-only, which only a table or cards row action can be',
|
|
1768
|
+
);
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
test("rejects a form help that is missing a locale", () => {
|
|
1772
|
+
const driver = driverWith(anyVisibleWhen);
|
|
1773
|
+
|
|
1774
|
+
formSectionOf(driver).help = {
|
|
1775
|
+
ar: "",
|
|
1776
|
+
en: "The version the server runs",
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
expect(() => validateDriver(driver)).toThrow(
|
|
1780
|
+
'tab "version" section "version" help needs both arabic and english text',
|
|
1781
|
+
);
|
|
1782
|
+
});
|
|
1783
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type BridgeField,
|
|
14
14
|
BridgeFormTarget,
|
|
15
15
|
BridgeLayout,
|
|
16
|
+
BridgePlace,
|
|
16
17
|
type BridgeSection,
|
|
17
18
|
type BridgeSectionAction,
|
|
18
19
|
type BridgeTab,
|
|
@@ -333,12 +334,53 @@ const validateActions = (modules: Record<string, BridgePanelModule>, actions: Br
|
|
|
333
334
|
}
|
|
334
335
|
};
|
|
335
336
|
|
|
337
|
+
const PLACE_LAYOUTS: Record<BridgePlace, BridgeLayout[]> = {
|
|
338
|
+
[BridgePlace.Overview]: [
|
|
339
|
+
BridgeLayout.Actions,
|
|
340
|
+
BridgeLayout.Detail,
|
|
341
|
+
],
|
|
342
|
+
[BridgePlace.Players]: [
|
|
343
|
+
BridgeLayout.Actions,
|
|
344
|
+
BridgeLayout.Cards,
|
|
345
|
+
BridgeLayout.Table,
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const validatePlace = (section: BridgeSection, where: string) => {
|
|
350
|
+
const place = "place" in section ? section.place : undefined;
|
|
351
|
+
|
|
352
|
+
if (place === undefined) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!(PLACE_LAYOUTS[place] ?? []).includes(section.layout)) {
|
|
357
|
+
fail(`${where} places a ${section.layout} section on ${place}, which that page cannot render`);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const validateOffline = (section: BridgeSection, where: string) => {
|
|
362
|
+
if (section.layout === BridgeLayout.Table || section.layout === BridgeLayout.Cards) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const actions = "actions" in section ? (section.actions ?? []) : [];
|
|
367
|
+
|
|
368
|
+
for (const action of actions) {
|
|
369
|
+
if (action.offline !== undefined) {
|
|
370
|
+
fail(`${where} action "${action.id}" is offline-only, which only a table or cards row action can be`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
336
375
|
const validateSection = (modules: Record<string, BridgePanelModule>, section: BridgeSection, where: string) => {
|
|
337
376
|
validateVisibleWhen(section.visibleWhen, where);
|
|
338
377
|
validateText(section.title, "title", where);
|
|
378
|
+
validateText(section.help, "help", where);
|
|
379
|
+
validatePlace(section, where);
|
|
380
|
+
validateOffline(section, where);
|
|
339
381
|
|
|
340
|
-
if (section.layout !== BridgeLayout.Form) {
|
|
341
|
-
validateText(section.
|
|
382
|
+
if (section.layout !== BridgeLayout.Form && section.layout !== BridgeLayout.Actions) {
|
|
383
|
+
validateText(section.empty, "empty text", where);
|
|
342
384
|
}
|
|
343
385
|
|
|
344
386
|
switch (section.layout) {
|
|
@@ -4,8 +4,8 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { BridgeKind } from "../core";
|
|
6
6
|
import type { BridgeEventsModule } from "../modules";
|
|
7
|
-
import { BridgeEventName } from "../protocol";
|
|
8
|
-
import { createEventRing } from "./bridgeEvents";
|
|
7
|
+
import { BridgeCrashPhase, type BridgeCrashRecord, BridgeEventName } from "../protocol";
|
|
8
|
+
import { type BridgeEventRing, createEventRing } from "./bridgeEvents";
|
|
9
9
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
10
10
|
import { createSupervisor } from "./bridgeSupervisor";
|
|
11
11
|
|
|
@@ -13,6 +13,10 @@ const RESET = "\u001B[m";
|
|
|
13
13
|
|
|
14
14
|
const PLAYER_JOINED = /^\[World\|(?<world>[^\]]+)\] Player '(?<player>[^']+)' joined world (?<target>\S+)$/;
|
|
15
15
|
|
|
16
|
+
const MOD_CRASHED = /^Exception in mod (?<mod>\S+) from (?<jar>\S+)$/;
|
|
17
|
+
|
|
18
|
+
const READY_LINE = /^ready$/;
|
|
19
|
+
|
|
16
20
|
const silent: BridgeLeveledLogger = Object.assign(() => {}, {
|
|
17
21
|
warn: () => {},
|
|
18
22
|
error: () => {},
|
|
@@ -25,16 +29,18 @@ const events: BridgeEventsModule = {
|
|
|
25
29
|
match: PLAYER_JOINED,
|
|
26
30
|
emit: BridgeEventName.PlayerJoined,
|
|
27
31
|
},
|
|
32
|
+
{
|
|
33
|
+
match: MOD_CRASHED,
|
|
34
|
+
emit: BridgeEventName.ModCrashed,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
emits: [
|
|
38
|
+
BridgeEventName.WorldCorrupt,
|
|
28
39
|
],
|
|
29
40
|
};
|
|
30
41
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const path = join(dir, "line.txt");
|
|
34
|
-
|
|
35
|
-
await Bun.write(path, `${line}\n`);
|
|
36
|
-
|
|
37
|
-
const supervisor = createSupervisor({
|
|
42
|
+
const lineSupervisor = (dir: string, path: string) => {
|
|
43
|
+
return createSupervisor({
|
|
38
44
|
cwd: dir,
|
|
39
45
|
uid: process.getuid?.() ?? 0,
|
|
40
46
|
gid: process.getgid?.() ?? 0,
|
|
@@ -47,7 +53,15 @@ const emittedFor = async (line: string) => {
|
|
|
47
53
|
path,
|
|
48
54
|
],
|
|
49
55
|
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const emittedFor = async (line: string) => {
|
|
59
|
+
const dir = await mkdtemp(join(tmpdir(), "bridge-events-"));
|
|
60
|
+
const path = join(dir, "line.txt");
|
|
50
61
|
|
|
62
|
+
await Bun.write(path, `${line}\n`);
|
|
63
|
+
|
|
64
|
+
const supervisor = lineSupervisor(dir, path);
|
|
51
65
|
const ring = createEventRing(events, supervisor);
|
|
52
66
|
|
|
53
67
|
await supervisor.start();
|
|
@@ -71,6 +85,72 @@ const emittedFor = async (line: string) => {
|
|
|
71
85
|
}
|
|
72
86
|
};
|
|
73
87
|
|
|
88
|
+
const crashAfter = async (line: string, record: BridgeCrashRecord) => {
|
|
89
|
+
const dir = await mkdtemp(join(tmpdir(), "bridge-events-"));
|
|
90
|
+
const path = join(dir, "line.txt");
|
|
91
|
+
|
|
92
|
+
await Bun.write(path, `${line}\n`);
|
|
93
|
+
|
|
94
|
+
const supervisor = lineSupervisor(dir, path);
|
|
95
|
+
const ring = createEventRing(events, supervisor);
|
|
96
|
+
|
|
97
|
+
await supervisor.start();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
for (let waited = 0; waited < 5000; waited += 50) {
|
|
101
|
+
if (supervisor.tail(50).includes(line)) {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await Bun.sleep(50);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const held = ring.drain(0).events;
|
|
109
|
+
|
|
110
|
+
ring.crash(record);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
held,
|
|
114
|
+
events: ring.drain(0).events,
|
|
115
|
+
};
|
|
116
|
+
} finally {
|
|
117
|
+
await supervisor.stop();
|
|
118
|
+
await rm(dir, {
|
|
119
|
+
force: true,
|
|
120
|
+
recursive: true,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const idleRing = () => {
|
|
126
|
+
const supervisor = createSupervisor({
|
|
127
|
+
cwd: tmpdir(),
|
|
128
|
+
uid: process.getuid?.() ?? 0,
|
|
129
|
+
gid: process.getgid?.() ?? 0,
|
|
130
|
+
stopTimeoutSeconds: 1,
|
|
131
|
+
onLog: silent,
|
|
132
|
+
resolveCommand: async () => [
|
|
133
|
+
"true",
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return createEventRing(events, supervisor);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const crashRecord = (overrides: Partial<BridgeCrashRecord> = {}): BridgeCrashRecord => {
|
|
141
|
+
return {
|
|
142
|
+
exitCode: 1,
|
|
143
|
+
outOfMemory: false,
|
|
144
|
+
phase: BridgeCrashPhase.Running,
|
|
145
|
+
uptimeSeconds: 12,
|
|
146
|
+
restarting: false,
|
|
147
|
+
looping: false,
|
|
148
|
+
restarts: 0,
|
|
149
|
+
lastLine: null,
|
|
150
|
+
...overrides,
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
74
154
|
const joined = {
|
|
75
155
|
seq: 1,
|
|
76
156
|
event: BridgeEventName.PlayerJoined,
|
|
@@ -93,4 +173,326 @@ describe("matching a game line against the driver patterns", () => {
|
|
|
93
173
|
joined,
|
|
94
174
|
]);
|
|
95
175
|
});
|
|
176
|
+
|
|
177
|
+
test("holds a matched diagnostic line as evidence and spends it on the crash record", async () => {
|
|
178
|
+
const { held, events: drained } = await crashAfter(
|
|
179
|
+
"Exception in mod journeymap from journeymap-5.9.jar",
|
|
180
|
+
crashRecord({
|
|
181
|
+
exitCode: 1,
|
|
182
|
+
phase: BridgeCrashPhase.Booting,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(held, "the diagnostic line reached the ring as a standalone event").toEqual([]);
|
|
187
|
+
expect(drained).toEqual([
|
|
188
|
+
{
|
|
189
|
+
seq: 1,
|
|
190
|
+
event: BridgeEventName.ServerCrashed,
|
|
191
|
+
payload: {
|
|
192
|
+
mod: "journeymap",
|
|
193
|
+
jar: "journeymap-5.9.jar",
|
|
194
|
+
cause: "mod-crashed",
|
|
195
|
+
phase: "booting",
|
|
196
|
+
exitCode: "1",
|
|
197
|
+
restarting: "no",
|
|
198
|
+
looping: "no",
|
|
199
|
+
restarts: "0",
|
|
200
|
+
uptimeSeconds: "12",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
}, 20_000);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("the crash record the ring builds", () => {
|
|
208
|
+
test("holds evidence out of the ring and lets every other event drain", () => {
|
|
209
|
+
const ring = idleRing();
|
|
210
|
+
|
|
211
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
212
|
+
jar: "broken.jar",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
ring.emit(BridgeEventName.TickLagging, {
|
|
216
|
+
ms: 900,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(ring.drain(0).events.map((entry) => entry.event)).toEqual([
|
|
220
|
+
BridgeEventName.TickLagging,
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("prefers the oom kill over any evidence it holds", () => {
|
|
225
|
+
const ring = idleRing();
|
|
226
|
+
|
|
227
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
228
|
+
jar: "broken.jar",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
ring.crash(
|
|
232
|
+
crashRecord({
|
|
233
|
+
exitCode: 137,
|
|
234
|
+
outOfMemory: true,
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(ring.drain(0).events.at(0)?.payload).toEqual({
|
|
239
|
+
cause: "oom-kill",
|
|
240
|
+
phase: "running",
|
|
241
|
+
exitCode: "137",
|
|
242
|
+
restarting: "no",
|
|
243
|
+
looping: "no",
|
|
244
|
+
restarts: "0",
|
|
245
|
+
uptimeSeconds: "12",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("follows the evidence precedence when more than one name is held", () => {
|
|
250
|
+
const ring = idleRing();
|
|
251
|
+
|
|
252
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
253
|
+
jar: "broken.jar",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
ring.emit(BridgeEventName.WrongJavaVersion, {
|
|
257
|
+
detail: "class file version 65",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
ring.crash(crashRecord());
|
|
261
|
+
|
|
262
|
+
expect(ring.drain(0).events.at(0)?.payload).toEqual({
|
|
263
|
+
detail: "class file version 65",
|
|
264
|
+
cause: "wrong-java",
|
|
265
|
+
phase: "running",
|
|
266
|
+
exitCode: "1",
|
|
267
|
+
restarting: "no",
|
|
268
|
+
looping: "no",
|
|
269
|
+
restarts: "0",
|
|
270
|
+
uptimeSeconds: "12",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("lets the out of memory log pattern outrank the java version", () => {
|
|
275
|
+
const ring = idleRing();
|
|
276
|
+
|
|
277
|
+
ring.emit(BridgeEventName.WrongJavaVersion, {
|
|
278
|
+
detail: "class file version 65",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
ring.emit(BridgeEventName.ServerCrashed, {
|
|
282
|
+
error: "java.lang.OutOfMemoryError",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
ring.crash(crashRecord());
|
|
286
|
+
|
|
287
|
+
expect(ring.drain(0).events.at(0)?.payload).toMatchObject({
|
|
288
|
+
cause: "out-of-memory",
|
|
289
|
+
error: "java.lang.OutOfMemoryError",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("never lets the evidence overwrite the record keys", () => {
|
|
294
|
+
const ring = idleRing();
|
|
295
|
+
|
|
296
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
297
|
+
cause: "not this one",
|
|
298
|
+
phase: "not this one either",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
ring.crash(
|
|
302
|
+
crashRecord({
|
|
303
|
+
phase: BridgeCrashPhase.Booting,
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
expect(ring.drain(0).events.at(0)?.payload).toMatchObject({
|
|
308
|
+
cause: "mod-crashed",
|
|
309
|
+
phase: "booting",
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("clears the evidence once it is spent", () => {
|
|
314
|
+
const ring = idleRing();
|
|
315
|
+
|
|
316
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
317
|
+
jar: "broken.jar",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
ring.crash(crashRecord());
|
|
321
|
+
|
|
322
|
+
const first = ring.drain(0);
|
|
323
|
+
|
|
324
|
+
ring.crash(crashRecord());
|
|
325
|
+
|
|
326
|
+
expect(first.events.at(0)?.payload).toMatchObject({
|
|
327
|
+
cause: "mod-crashed",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(ring.drain(first.cursor).events.at(0)?.payload).toMatchObject({
|
|
331
|
+
cause: "unknown",
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("carries the last line only when nothing explains the crash", () => {
|
|
336
|
+
const ring = idleRing();
|
|
337
|
+
|
|
338
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
339
|
+
jar: "broken.jar",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
ring.crash(
|
|
343
|
+
crashRecord({
|
|
344
|
+
lastLine: "explained, so this line is noise",
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const explained = ring.drain(0);
|
|
349
|
+
|
|
350
|
+
ring.crash(
|
|
351
|
+
crashRecord({
|
|
352
|
+
lastLine: "Segmentation fault",
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(explained.events.at(0)?.payload).not.toHaveProperty("lastLine");
|
|
357
|
+
expect(ring.drain(explained.cursor).events.at(0)?.payload).toMatchObject({
|
|
358
|
+
cause: "unknown",
|
|
359
|
+
lastLine: "Segmentation fault",
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("keeps listing the evidence names the driver declares", () => {
|
|
364
|
+
expect(idleRing().names()).toEqual([
|
|
365
|
+
BridgeEventName.PlayerJoined,
|
|
366
|
+
BridgeEventName.ModCrashed,
|
|
367
|
+
BridgeEventName.WorldCorrupt,
|
|
368
|
+
]);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const EVIDENCE_LINE = "Exception in mod journeymap from journeymap-5.9.jar";
|
|
373
|
+
|
|
374
|
+
const EVIDENCE_BEFORE_READY = `echo "${EVIDENCE_LINE}"; sleep 0.3; echo ready; sleep 0.5; exit 4`;
|
|
375
|
+
|
|
376
|
+
const EVIDENCE_AFTER_READY = `echo ready; sleep 0.3; echo "${EVIDENCE_LINE}"; sleep 0.5; exit 4`;
|
|
377
|
+
|
|
378
|
+
const EVIDENCE_THEN_IDLE = `echo "${EVIDENCE_LINE}"; sleep 300`;
|
|
379
|
+
|
|
380
|
+
const wired = async (script: string, ready?: RegExp) => {
|
|
381
|
+
const dir = await mkdtemp(join(tmpdir(), "bridge-events-"));
|
|
382
|
+
|
|
383
|
+
const supervisor = createSupervisor({
|
|
384
|
+
cwd: dir,
|
|
385
|
+
uid: process.getuid?.() ?? 0,
|
|
386
|
+
gid: process.getgid?.() ?? 0,
|
|
387
|
+
stopTimeoutSeconds: 1,
|
|
388
|
+
ready,
|
|
389
|
+
onLog: silent,
|
|
390
|
+
resolveCommand: async () => [
|
|
391
|
+
"bash",
|
|
392
|
+
"-c",
|
|
393
|
+
script,
|
|
394
|
+
],
|
|
395
|
+
onStable() {
|
|
396
|
+
ring.discard();
|
|
397
|
+
},
|
|
398
|
+
onStopped() {
|
|
399
|
+
ring.discard();
|
|
400
|
+
},
|
|
401
|
+
onExit(record) {
|
|
402
|
+
ring.crash(record);
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const ring = createEventRing(events, supervisor);
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
supervisor,
|
|
410
|
+
ring,
|
|
411
|
+
async cleanup() {
|
|
412
|
+
await supervisor.stop();
|
|
413
|
+
await rm(dir, {
|
|
414
|
+
force: true,
|
|
415
|
+
recursive: true,
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const crashedEvent = async (ring: BridgeEventRing) => {
|
|
422
|
+
for (let waited = 0; waited < 10_000; waited += 50) {
|
|
423
|
+
const found = ring.drain(0).events.find((entry) => entry.event === BridgeEventName.ServerCrashed);
|
|
424
|
+
|
|
425
|
+
if (found) {
|
|
426
|
+
return found;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await Bun.sleep(50);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return null;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
describe("the evidence a run may spend", () => {
|
|
436
|
+
test("spends nothing printed before the run became stable on a running crash", async () => {
|
|
437
|
+
const wiring = await wired(EVIDENCE_BEFORE_READY, READY_LINE);
|
|
438
|
+
|
|
439
|
+
await wiring.supervisor.start();
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const crashed = await crashedEvent(wiring.ring);
|
|
443
|
+
|
|
444
|
+
expect(crashed?.payload).toMatchObject({
|
|
445
|
+
cause: "unknown",
|
|
446
|
+
phase: "running",
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(crashed?.payload).not.toHaveProperty("jar");
|
|
450
|
+
} finally {
|
|
451
|
+
await wiring.cleanup();
|
|
452
|
+
}
|
|
453
|
+
}, 20_000);
|
|
454
|
+
|
|
455
|
+
test("spends what the run printed after it became stable", async () => {
|
|
456
|
+
const wiring = await wired(EVIDENCE_AFTER_READY, READY_LINE);
|
|
457
|
+
|
|
458
|
+
await wiring.supervisor.start();
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const crashed = await crashedEvent(wiring.ring);
|
|
462
|
+
|
|
463
|
+
expect(crashed?.payload).toMatchObject({
|
|
464
|
+
cause: "mod-crashed",
|
|
465
|
+
phase: "running",
|
|
466
|
+
jar: "journeymap-5.9.jar",
|
|
467
|
+
});
|
|
468
|
+
} finally {
|
|
469
|
+
await wiring.cleanup();
|
|
470
|
+
}
|
|
471
|
+
}, 20_000);
|
|
472
|
+
|
|
473
|
+
test("drops what it holds when the stop was requested", async () => {
|
|
474
|
+
const wiring = await wired(EVIDENCE_THEN_IDLE);
|
|
475
|
+
|
|
476
|
+
await wiring.supervisor.start();
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
for (let waited = 0; waited < 5000; waited += 50) {
|
|
480
|
+
if (wiring.supervisor.tail(50).includes(EVIDENCE_LINE)) {
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
await Bun.sleep(50);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
await wiring.supervisor.stop();
|
|
488
|
+
|
|
489
|
+
wiring.ring.crash(crashRecord());
|
|
490
|
+
|
|
491
|
+
expect(wiring.ring.drain(0).events.at(0)?.payload).toMatchObject({
|
|
492
|
+
cause: "unknown",
|
|
493
|
+
});
|
|
494
|
+
} finally {
|
|
495
|
+
await wiring.cleanup();
|
|
496
|
+
}
|
|
497
|
+
}, 20_000);
|
|
96
498
|
});
|