@serverkgg/bridge 0.0.8 → 0.0.10
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 -3
- package/src/cli/validate.ts +2 -0
- package/src/context/bridgeContext.ts +17 -0
- package/src/context/index.ts +1 -0
- package/src/core/bridgeDownloadError.ts +10 -0
- package/src/core/bridgeHold.ts +12 -0
- package/src/core/bridgeKind.ts +3 -0
- package/src/core/bridgeProbeError.ts +16 -0
- package/src/core/bridgeSetup.ts +20 -0
- package/src/core/bridgeUserError.test.ts +23 -0
- package/src/core/bridgeUserError.ts +12 -0
- package/src/core/bridgeValue.test.ts +29 -0
- package/src/core/bridgeValue.ts +12 -0
- package/src/core/index.ts +5 -0
- package/src/guides/blocks.ts +8 -0
- package/src/guides/markdown.test.ts +18 -0
- package/src/guides/markdown.ts +7 -1
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +132 -0
- package/src/manifest/manifest.ts +21 -0
- package/src/manifest/presence.ts +56 -0
- package/src/manifest/primitives.ts +6 -0
- package/src/modules/bridge.ts +65 -2
- package/src/modules/bridgeDriver.ts +4 -0
- package/src/modules/bridgeModule.ts +32 -3
- package/src/panel/bridgeDetail.ts +49 -0
- package/src/panel/bridgeLayout.ts +1 -0
- package/src/panel/bridgePanel.ts +30 -0
- package/src/panel/bridgeUploadMode.ts +4 -0
- package/src/panel/index.ts +2 -0
- package/src/probes/a2s.test.ts +71 -0
- package/src/probes/a2s.ts +38 -11
- package/src/protocol/bridgeEgress.ts +8 -1
- package/src/protocol/bridgeEvents.ts +4 -0
- package/src/protocol/bridgeFrame.ts +33 -1
- package/src/protocol/bridgeLimits.ts +12 -0
- package/src/protocol/bridgeManifest.ts +2 -0
- package/src/protocol/bridgeSetup.ts +97 -0
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeActions.ts +7 -0
- package/src/runtime/bridgeConfigReader.ts +12 -8
- package/src/runtime/bridgeContext.ts +28 -2
- package/src/runtime/bridgeDriver.test.ts +748 -3
- package/src/runtime/bridgeDriver.ts +198 -4
- package/src/runtime/bridgeEgress.ts +3 -0
- package/src/runtime/bridgeEvents.test.ts +96 -0
- package/src/runtime/bridgeEvents.ts +11 -6
- package/src/runtime/bridgeFiles.test.ts +625 -15
- package/src/runtime/bridgeFiles.ts +152 -45
- package/src/runtime/bridgeInstall.test.ts +118 -0
- package/src/runtime/bridgeInstall.ts +55 -0
- package/src/runtime/bridgeNet.ts +32 -3
- package/src/runtime/bridgePending.test.ts +148 -0
- package/src/runtime/bridgePending.ts +43 -0
- package/src/runtime/bridgeRegistry.test.ts +172 -1
- package/src/runtime/bridgeRegistry.ts +40 -5
- package/src/runtime/bridgeSecrets.test.ts +121 -0
- package/src/runtime/bridgeSecrets.ts +43 -0
- package/src/runtime/bridgeServer.test.ts +598 -0
- package/src/runtime/bridgeServer.ts +42 -3
- package/src/runtime/bridgeSetup.test.ts +189 -0
- package/src/runtime/bridgeSetup.ts +63 -0
- package/src/runtime/bridgeSignal.test.ts +114 -0
- package/src/runtime/bridgeSignal.ts +31 -0
- package/src/runtime/bridgeSupervisor.test.ts +111 -0
- package/src/runtime/bridgeSupervisor.ts +55 -5
- package/src/runtime/bridgeZip.ts +179 -58
- package/src/runtime/serve.ts +115 -4
- package/src/utils/index.ts +1 -0
- package/src/utils/stripAnsi.ts +12 -0
- package/src/wire/panel.test.ts +259 -0
- package/src/wire/panel.ts +72 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BridgeSetupPromptKind, BridgeSetupStepState } from "../core";
|
|
3
|
+
import type { BridgeSetupRuntime } from "../protocol";
|
|
4
|
+
import { failedSetup, setupSummaryOf, unsupportedSetup } from "./bridgeSetup";
|
|
5
|
+
|
|
6
|
+
const text = (value: string) => ({
|
|
7
|
+
ar: value,
|
|
8
|
+
en: value,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const runtimeWith = (prompt: BridgeSetupRuntime["prompt"]): BridgeSetupRuntime => ({
|
|
12
|
+
steps: [
|
|
13
|
+
{
|
|
14
|
+
id: "sign-in",
|
|
15
|
+
state: BridgeSetupStepState.Active,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "download",
|
|
19
|
+
state: BridgeSetupStepState.Pending,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
prompt,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("the setup summary", () => {
|
|
26
|
+
test("has nothing to summarise while the driver reported no runtime", () => {
|
|
27
|
+
expect(setupSummaryOf(null)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("names the active driver step", () => {
|
|
31
|
+
expect(
|
|
32
|
+
setupSummaryOf(
|
|
33
|
+
runtimeWith({
|
|
34
|
+
kind: BridgeSetupPromptKind.Wait,
|
|
35
|
+
message: text("starting your server"),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
).toEqual({
|
|
39
|
+
step: "sign-in",
|
|
40
|
+
attention: false,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("names no step once every driver step is done", () => {
|
|
45
|
+
expect(
|
|
46
|
+
setupSummaryOf({
|
|
47
|
+
steps: [
|
|
48
|
+
{
|
|
49
|
+
id: "sign-in",
|
|
50
|
+
state: BridgeSetupStepState.Done,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
prompt: null,
|
|
54
|
+
}),
|
|
55
|
+
).toEqual({
|
|
56
|
+
step: null,
|
|
57
|
+
attention: false,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("asks for attention while a link prompt waits on the customer", () => {
|
|
62
|
+
expect(
|
|
63
|
+
setupSummaryOf(
|
|
64
|
+
runtimeWith({
|
|
65
|
+
kind: BridgeSetupPromptKind.Link,
|
|
66
|
+
message: text("sign in with your account"),
|
|
67
|
+
url: "https://hytale.com/device",
|
|
68
|
+
code: "ABCD-EFGH",
|
|
69
|
+
expiresAt: null,
|
|
70
|
+
actions: [],
|
|
71
|
+
}),
|
|
72
|
+
)?.attention,
|
|
73
|
+
).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("asks for attention on an action prompt and on a failed one", () => {
|
|
77
|
+
expect(
|
|
78
|
+
setupSummaryOf(
|
|
79
|
+
runtimeWith({
|
|
80
|
+
kind: BridgeSetupPromptKind.Action,
|
|
81
|
+
message: text("get a new code"),
|
|
82
|
+
actions: [],
|
|
83
|
+
}),
|
|
84
|
+
)?.attention,
|
|
85
|
+
).toBe(true);
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
setupSummaryOf(
|
|
89
|
+
runtimeWith({
|
|
90
|
+
kind: BridgeSetupPromptKind.Failed,
|
|
91
|
+
message: text("the sign-in expired"),
|
|
92
|
+
actions: [],
|
|
93
|
+
}),
|
|
94
|
+
)?.attention,
|
|
95
|
+
).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("waits quietly while a progress prompt runs", () => {
|
|
99
|
+
expect(
|
|
100
|
+
setupSummaryOf(
|
|
101
|
+
runtimeWith({
|
|
102
|
+
kind: BridgeSetupPromptKind.Progress,
|
|
103
|
+
message: text("downloading the game"),
|
|
104
|
+
percent: 10,
|
|
105
|
+
detail: "146.6 MiB / 1.4 GiB",
|
|
106
|
+
}),
|
|
107
|
+
)?.attention,
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("the unsupported sentinel", () => {
|
|
113
|
+
test("answers a driver that declares no setup", () => {
|
|
114
|
+
expect(unsupportedSetup()).toEqual({
|
|
115
|
+
supported: false,
|
|
116
|
+
runtime: null,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("a rejected submit", () => {
|
|
122
|
+
test("turns the user error into a failed prompt keeping the previous actions", () => {
|
|
123
|
+
const result = failedSetup(
|
|
124
|
+
runtimeWith({
|
|
125
|
+
kind: BridgeSetupPromptKind.Link,
|
|
126
|
+
message: text("sign in with your account"),
|
|
127
|
+
url: "https://hytale.com/device",
|
|
128
|
+
code: "ABCD-EFGH",
|
|
129
|
+
expiresAt: null,
|
|
130
|
+
actions: [
|
|
131
|
+
{
|
|
132
|
+
id: "renew",
|
|
133
|
+
label: text("Get a new code"),
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
}),
|
|
137
|
+
text("this code cannot be renewed any more"),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(result).toEqual({
|
|
141
|
+
supported: true,
|
|
142
|
+
runtime: {
|
|
143
|
+
steps: [
|
|
144
|
+
{
|
|
145
|
+
id: "sign-in",
|
|
146
|
+
state: BridgeSetupStepState.Active,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "download",
|
|
150
|
+
state: BridgeSetupStepState.Pending,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
prompt: {
|
|
154
|
+
kind: BridgeSetupPromptKind.Failed,
|
|
155
|
+
message: text("this code cannot be renewed any more"),
|
|
156
|
+
actions: [
|
|
157
|
+
{
|
|
158
|
+
id: "renew",
|
|
159
|
+
label: text("Get a new code"),
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("carries no actions over from a prompt that had none", () => {
|
|
168
|
+
const result = failedSetup(
|
|
169
|
+
runtimeWith({
|
|
170
|
+
kind: BridgeSetupPromptKind.Wait,
|
|
171
|
+
message: text("starting your server"),
|
|
172
|
+
}),
|
|
173
|
+
text("the server never came up"),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(result.runtime?.prompt).toEqual({
|
|
177
|
+
kind: BridgeSetupPromptKind.Failed,
|
|
178
|
+
message: text("the server never came up"),
|
|
179
|
+
actions: [],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("stays empty when the driver never reported a runtime", () => {
|
|
184
|
+
expect(failedSetup(null, text("nothing to fail"))).toEqual({
|
|
185
|
+
supported: true,
|
|
186
|
+
runtime: null,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { BridgeSetupPromptKind, BridgeSetupStepState, type LocalizedText } from "../core";
|
|
2
|
+
import type {
|
|
3
|
+
BridgeSetupAction,
|
|
4
|
+
BridgeSetupPrompt,
|
|
5
|
+
BridgeSetupResult,
|
|
6
|
+
BridgeSetupRuntime,
|
|
7
|
+
BridgeSetupSummary,
|
|
8
|
+
} from "../protocol";
|
|
9
|
+
|
|
10
|
+
const ATTENTION_KINDS: ReadonlySet<BridgeSetupPromptKind> = new Set([
|
|
11
|
+
BridgeSetupPromptKind.Link,
|
|
12
|
+
BridgeSetupPromptKind.Action,
|
|
13
|
+
BridgeSetupPromptKind.Failed,
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const actionsOf = (prompt: BridgeSetupPrompt | null): BridgeSetupAction[] => {
|
|
17
|
+
if (!prompt || prompt.kind === BridgeSetupPromptKind.Wait || prompt.kind === BridgeSetupPromptKind.Progress) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return prompt.actions;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const setupSummaryOf = (runtime: BridgeSetupRuntime | null): BridgeSetupSummary | null => {
|
|
25
|
+
if (!runtime) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const active = runtime.steps.find((step) => step.state === BridgeSetupStepState.Active);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
step: active?.id ?? null,
|
|
33
|
+
attention: runtime.prompt !== null && ATTENTION_KINDS.has(runtime.prompt.kind),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const unsupportedSetup = (): BridgeSetupResult => {
|
|
38
|
+
return {
|
|
39
|
+
supported: false,
|
|
40
|
+
runtime: null,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const failedSetup = (runtime: BridgeSetupRuntime | null, text: LocalizedText): BridgeSetupResult => {
|
|
45
|
+
if (!runtime) {
|
|
46
|
+
return {
|
|
47
|
+
supported: true,
|
|
48
|
+
runtime: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
supported: true,
|
|
54
|
+
runtime: {
|
|
55
|
+
steps: runtime.steps,
|
|
56
|
+
prompt: {
|
|
57
|
+
kind: BridgeSetupPromptKind.Failed,
|
|
58
|
+
message: text,
|
|
59
|
+
actions: actionsOf(runtime.prompt),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { leadsGroup, readProcessGroup, signalGroup } from "./bridgeSignal";
|
|
3
|
+
|
|
4
|
+
const MISSING_PID = 2 ** 22 - 1;
|
|
5
|
+
|
|
6
|
+
describe("readProcessGroup", () => {
|
|
7
|
+
test("reads the process group of this process", async () => {
|
|
8
|
+
const group = await readProcessGroup(process.pid);
|
|
9
|
+
|
|
10
|
+
expect(group).not.toBeNull();
|
|
11
|
+
expect(group).toBeGreaterThan(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("puts a detached child in a group of its own and an attached one in ours", async () => {
|
|
15
|
+
const detached = Bun.spawn(
|
|
16
|
+
[
|
|
17
|
+
"sleep",
|
|
18
|
+
"5",
|
|
19
|
+
],
|
|
20
|
+
{
|
|
21
|
+
detached: true,
|
|
22
|
+
stdout: "ignore",
|
|
23
|
+
stderr: "ignore",
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const attached = Bun.spawn(
|
|
28
|
+
[
|
|
29
|
+
"sleep",
|
|
30
|
+
"5",
|
|
31
|
+
],
|
|
32
|
+
{
|
|
33
|
+
stdout: "ignore",
|
|
34
|
+
stderr: "ignore",
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
expect(await readProcessGroup(detached.pid)).toBe(detached.pid);
|
|
40
|
+
expect(await readProcessGroup(attached.pid)).toBe(await readProcessGroup(process.pid));
|
|
41
|
+
} finally {
|
|
42
|
+
detached.kill("SIGKILL");
|
|
43
|
+
attached.kill("SIGKILL");
|
|
44
|
+
|
|
45
|
+
await Promise.all([
|
|
46
|
+
detached.exited,
|
|
47
|
+
attached.exited,
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("answers null for a pid that does not exist", async () => {
|
|
53
|
+
expect(await readProcessGroup(MISSING_PID)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("leadsGroup", () => {
|
|
58
|
+
test("only a pid equal to its own group leads it", () => {
|
|
59
|
+
expect(leadsGroup(1234, 1234)).toBe(true);
|
|
60
|
+
expect(leadsGroup(1234, 1200)).toBe(false);
|
|
61
|
+
expect(leadsGroup(1234, null)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("signalGroup", () => {
|
|
66
|
+
test("signals every process sharing the leader's group", async () => {
|
|
67
|
+
const leader = Bun.spawn(
|
|
68
|
+
[
|
|
69
|
+
"bash",
|
|
70
|
+
"-c",
|
|
71
|
+
"sleep 30 & echo $!; wait",
|
|
72
|
+
],
|
|
73
|
+
{
|
|
74
|
+
detached: true,
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "ignore",
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const first = await leader.stdout.getReader().read();
|
|
81
|
+
const child = Number(new TextDecoder().decode(first.value).trim());
|
|
82
|
+
|
|
83
|
+
expect(Number.isSafeInteger(child)).toBe(true);
|
|
84
|
+
|
|
85
|
+
expect(signalGroup(leader.pid, await readProcessGroup(leader.pid), "SIGKILL")).toBe(true);
|
|
86
|
+
|
|
87
|
+
await leader.exited;
|
|
88
|
+
await Bun.sleep(200);
|
|
89
|
+
|
|
90
|
+
expect(() => process.kill(child, 0)).toThrow();
|
|
91
|
+
}, 20_000);
|
|
92
|
+
|
|
93
|
+
test("refuses to signal a group the pid does not lead", async () => {
|
|
94
|
+
const attached = Bun.spawn(
|
|
95
|
+
[
|
|
96
|
+
"sleep",
|
|
97
|
+
"5",
|
|
98
|
+
],
|
|
99
|
+
{
|
|
100
|
+
stdout: "ignore",
|
|
101
|
+
stderr: "ignore",
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
expect(signalGroup(attached.pid, await readProcessGroup(attached.pid), "SIGKILL")).toBe(false);
|
|
107
|
+
expect(attached.killed).toBe(false);
|
|
108
|
+
} finally {
|
|
109
|
+
attached.kill("SIGKILL");
|
|
110
|
+
|
|
111
|
+
await attached.exited;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const STAT_PGID_INDEX = 2;
|
|
2
|
+
|
|
3
|
+
export const readProcessGroup = async (pid: number) => {
|
|
4
|
+
try {
|
|
5
|
+
const stat = await Bun.file(`/proc/${pid}/stat`).text();
|
|
6
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
7
|
+
const group = Number(fields.at(STAT_PGID_INDEX));
|
|
8
|
+
|
|
9
|
+
return Number.isSafeInteger(group) && group > 0 ? group : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const leadsGroup = (pid: number, group: number | null) => {
|
|
16
|
+
return group !== null && group === pid;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const signalGroup = (pid: number, group: number | null, signal: NodeJS.Signals) => {
|
|
20
|
+
if (!leadsGroup(pid, group)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
process.kill(-pid, signal);
|
|
26
|
+
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { BridgeRuntime } from "../core";
|
|
4
|
+
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
5
|
+
import { readProcessGroup } from "./bridgeSignal";
|
|
6
|
+
import { createSupervisor } from "./bridgeSupervisor";
|
|
7
|
+
|
|
8
|
+
const CHILD_PATTERN = /child=(?<pid>\d+)/;
|
|
9
|
+
|
|
10
|
+
const WRAPPER = "sleep 300 & echo child=$!; wait";
|
|
11
|
+
|
|
12
|
+
const STUBBORN_WRAPPER = 'trap "" TERM; sleep 300 & echo child=$!; wait';
|
|
13
|
+
|
|
14
|
+
const silent: BridgeLeveledLogger = Object.assign(() => {}, {
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const alive = (pid: number) => {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const waitGone = async (pid: number, timeoutMs: number) => {
|
|
30
|
+
for (let waited = 0; waited < timeoutMs; waited += 50) {
|
|
31
|
+
if (!alive(pid)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await Bun.sleep(50);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return !alive(pid);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const wrapperSupervisor = (script: string, stopTimeoutSeconds: number) => {
|
|
42
|
+
return createSupervisor({
|
|
43
|
+
cwd: tmpdir(),
|
|
44
|
+
uid: process.getuid?.() ?? 0,
|
|
45
|
+
gid: process.getgid?.() ?? 0,
|
|
46
|
+
stopTimeoutSeconds,
|
|
47
|
+
resolveCommand: async () => {
|
|
48
|
+
return [
|
|
49
|
+
"bash",
|
|
50
|
+
"-c",
|
|
51
|
+
script,
|
|
52
|
+
];
|
|
53
|
+
},
|
|
54
|
+
onLog: silent,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const startWrapper = async (script: string, stopTimeoutSeconds: number) => {
|
|
59
|
+
const supervisor = wrapperSupervisor(script, stopTimeoutSeconds);
|
|
60
|
+
const started = supervisor.watch(CHILD_PATTERN, 10_000);
|
|
61
|
+
|
|
62
|
+
await supervisor.start();
|
|
63
|
+
|
|
64
|
+
const match = await started;
|
|
65
|
+
const pid = Number(match?.groups?.pid);
|
|
66
|
+
|
|
67
|
+
expect(match, "the wrapper never reported the pid it spawned").not.toBeNull();
|
|
68
|
+
expect(Number.isSafeInteger(pid)).toBe(true);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
supervisor,
|
|
72
|
+
pid,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("createSupervisor", () => {
|
|
77
|
+
test("runs the game outside the bridge's own process group", async () => {
|
|
78
|
+
const { supervisor, pid } = await startWrapper(WRAPPER, 1);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const gameGroup = await readProcessGroup(pid);
|
|
82
|
+
const bridgeGroup = await readProcessGroup(process.pid);
|
|
83
|
+
|
|
84
|
+
expect(gameGroup, "the game must land in a group of its own or a group kill would hit the bridge").not.toBe(
|
|
85
|
+
bridgeGroup,
|
|
86
|
+
);
|
|
87
|
+
} finally {
|
|
88
|
+
await supervisor.stop();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(await waitGone(pid, 5000)).toBe(true);
|
|
92
|
+
}, 20_000);
|
|
93
|
+
|
|
94
|
+
test("takes the wrapper's child down with it when the stop times out", async () => {
|
|
95
|
+
const { supervisor, pid } = await startWrapper(WRAPPER, 1);
|
|
96
|
+
|
|
97
|
+
await supervisor.stop();
|
|
98
|
+
|
|
99
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Stopped);
|
|
100
|
+
expect(await waitGone(pid, 5000), "the game was orphaned when the wrapper was signalled").toBe(true);
|
|
101
|
+
}, 20_000);
|
|
102
|
+
|
|
103
|
+
test("kills the wrapper's child through the escalation when the wrapper ignores SIGTERM", async () => {
|
|
104
|
+
const { supervisor, pid } = await startWrapper(STUBBORN_WRAPPER, 1);
|
|
105
|
+
|
|
106
|
+
await supervisor.stop();
|
|
107
|
+
|
|
108
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Stopped);
|
|
109
|
+
expect(await waitGone(pid, 5000), "the game outlived the SIGKILL escalation").toBe(true);
|
|
110
|
+
}, 30_000);
|
|
111
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { BridgeCommandOptions, BridgeReply } from "../context";
|
|
2
2
|
import { BridgeRuntime, BridgeStream } from "../core";
|
|
3
|
+
import { stripAnsi } from "../utils";
|
|
3
4
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
4
5
|
import { errorText } from "./bridgeLog";
|
|
5
6
|
import { settleUid, unprivilegedArgv } from "./bridgePrivilege";
|
|
7
|
+
import { readProcessGroup, signalGroup } from "./bridgeSignal";
|
|
6
8
|
|
|
7
9
|
const TAIL_LIMIT = 500;
|
|
8
10
|
|
|
@@ -18,6 +20,8 @@ const RESTART_LIMIT = 5;
|
|
|
18
20
|
|
|
19
21
|
const GAME_OOM_SCORE_ADJ = 500;
|
|
20
22
|
|
|
23
|
+
const SIGKILL_EXIT_CODE = 128 + 9;
|
|
24
|
+
|
|
21
25
|
export interface SupervisorLine {
|
|
22
26
|
stream: BridgeStream;
|
|
23
27
|
line: string;
|
|
@@ -37,6 +41,18 @@ export interface SupervisorOptions {
|
|
|
37
41
|
|
|
38
42
|
type LineWatcher = (entry: SupervisorLine) => void;
|
|
39
43
|
|
|
44
|
+
type SpawnedGame = Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
45
|
+
|
|
46
|
+
const terminate = (spawned: SpawnedGame, group: number | null, signal: NodeJS.Signals) => {
|
|
47
|
+
if (!signalGroup(spawned.pid, group, signal)) {
|
|
48
|
+
spawned.kill(signal);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const sweep = (pid: number, group: number | null) => {
|
|
53
|
+
signalGroup(pid, group, "SIGKILL");
|
|
54
|
+
};
|
|
55
|
+
|
|
40
56
|
const preferOomVictim = async (pid: number) => {
|
|
41
57
|
try {
|
|
42
58
|
await Bun.write(`/proc/${pid}/oom_score_adj`, `${GAME_OOM_SCORE_ADJ}\n`);
|
|
@@ -108,11 +124,13 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
108
124
|
const watchers = new Set<LineWatcher>();
|
|
109
125
|
const restartStamps: number[] = [];
|
|
110
126
|
|
|
111
|
-
let child:
|
|
127
|
+
let child: SpawnedGame | null = null;
|
|
128
|
+
let childGroup: number | null = null;
|
|
112
129
|
let runtime: BridgeRuntime = BridgeRuntime.Stopped;
|
|
113
130
|
let shouldRun = false;
|
|
114
131
|
let breaker = false;
|
|
115
132
|
let exitCode: number | null = null;
|
|
133
|
+
let outOfMemory = false;
|
|
116
134
|
let restarts = 0;
|
|
117
135
|
let queue: Promise<unknown> = Promise.resolve();
|
|
118
136
|
|
|
@@ -180,7 +198,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
180
198
|
}, timeoutMs);
|
|
181
199
|
|
|
182
200
|
const watcher: LineWatcher = (entry) => {
|
|
183
|
-
const match = entry.line.match(pattern);
|
|
201
|
+
const match = stripAnsi(entry.line).match(pattern);
|
|
184
202
|
|
|
185
203
|
if (!match) {
|
|
186
204
|
return;
|
|
@@ -222,6 +240,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
222
240
|
const spawned = Bun.spawn(spawnArgv, {
|
|
223
241
|
...credentials,
|
|
224
242
|
cwd: options.cwd,
|
|
243
|
+
detached: true,
|
|
225
244
|
stderr: "pipe",
|
|
226
245
|
stdin: "pipe",
|
|
227
246
|
stdout: "pipe",
|
|
@@ -230,6 +249,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
230
249
|
},
|
|
231
250
|
});
|
|
232
251
|
|
|
252
|
+
const group = await readProcessGroup(spawned.pid);
|
|
233
253
|
const observed = await settleUid(spawned.pid, options.uid);
|
|
234
254
|
|
|
235
255
|
if (observed !== options.uid) {
|
|
@@ -244,10 +264,12 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
244
264
|
);
|
|
245
265
|
}
|
|
246
266
|
|
|
247
|
-
spawned
|
|
267
|
+
terminate(spawned, group, "SIGKILL");
|
|
248
268
|
|
|
249
269
|
await spawned.exited;
|
|
250
270
|
|
|
271
|
+
sweep(spawned.pid, group);
|
|
272
|
+
|
|
251
273
|
throw new Error(
|
|
252
274
|
`the game process runs as uid ${String(observed)}, expected ${options.uid} — refusing to run it as the bridge`,
|
|
253
275
|
);
|
|
@@ -256,6 +278,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
256
278
|
await preferOomVictim(spawned.pid);
|
|
257
279
|
|
|
258
280
|
child = spawned;
|
|
281
|
+
childGroup = group;
|
|
259
282
|
exitCode = null;
|
|
260
283
|
|
|
261
284
|
void pump(spawned.stdout, BridgeStream.Stdout);
|
|
@@ -263,6 +286,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
263
286
|
|
|
264
287
|
options.onLog("game started", {
|
|
265
288
|
pid: spawned.pid,
|
|
289
|
+
group,
|
|
266
290
|
uid: options.uid,
|
|
267
291
|
});
|
|
268
292
|
|
|
@@ -303,10 +327,20 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
303
327
|
};
|
|
304
328
|
|
|
305
329
|
function handleExit(code: number) {
|
|
330
|
+
const finished = child;
|
|
331
|
+
|
|
306
332
|
child = null;
|
|
307
333
|
exitCode = code;
|
|
308
334
|
|
|
335
|
+
if (finished) {
|
|
336
|
+
sweep(finished.pid, childGroup);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
childGroup = null;
|
|
340
|
+
|
|
309
341
|
if (!shouldRun) {
|
|
342
|
+
outOfMemory = false;
|
|
343
|
+
|
|
310
344
|
runtime = BridgeRuntime.Stopped;
|
|
311
345
|
|
|
312
346
|
options.onLog("game stopped", {
|
|
@@ -316,6 +350,14 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
316
350
|
return;
|
|
317
351
|
}
|
|
318
352
|
|
|
353
|
+
outOfMemory = code === SIGKILL_EXIT_CODE;
|
|
354
|
+
|
|
355
|
+
if (outOfMemory) {
|
|
356
|
+
options.onLog.error("the game was killed for running out of memory", {
|
|
357
|
+
code,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
319
361
|
restarts += 1;
|
|
320
362
|
restartStamps.push(performance.now());
|
|
321
363
|
|
|
@@ -375,6 +417,10 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
375
417
|
return exitCode;
|
|
376
418
|
},
|
|
377
419
|
|
|
420
|
+
get outOfMemory() {
|
|
421
|
+
return outOfMemory;
|
|
422
|
+
},
|
|
423
|
+
|
|
378
424
|
get restarts() {
|
|
379
425
|
return restarts;
|
|
380
426
|
},
|
|
@@ -382,6 +428,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
382
428
|
async start() {
|
|
383
429
|
shouldRun = true;
|
|
384
430
|
breaker = false;
|
|
431
|
+
outOfMemory = false;
|
|
385
432
|
restartStamps.length = 0;
|
|
386
433
|
|
|
387
434
|
if (child) {
|
|
@@ -401,6 +448,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
401
448
|
}
|
|
402
449
|
|
|
403
450
|
const active = child;
|
|
451
|
+
const group = childGroup;
|
|
404
452
|
|
|
405
453
|
runtime = BridgeRuntime.Stopping;
|
|
406
454
|
|
|
@@ -423,7 +471,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
423
471
|
]);
|
|
424
472
|
|
|
425
473
|
if (settled === null) {
|
|
426
|
-
active
|
|
474
|
+
terminate(active, group, "SIGTERM");
|
|
427
475
|
|
|
428
476
|
const afterTerm = await Promise.race([
|
|
429
477
|
active.exited,
|
|
@@ -431,12 +479,14 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
431
479
|
]);
|
|
432
480
|
|
|
433
481
|
if (afterTerm === null) {
|
|
434
|
-
active
|
|
482
|
+
terminate(active, group, "SIGKILL");
|
|
435
483
|
|
|
436
484
|
await active.exited;
|
|
437
485
|
}
|
|
438
486
|
}
|
|
439
487
|
|
|
488
|
+
sweep(active.pid, group);
|
|
489
|
+
|
|
440
490
|
runtime = BridgeRuntime.Stopped;
|
|
441
491
|
},
|
|
442
492
|
|