@serverkgg/bridge 0.0.7 → 0.0.9
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 +4 -3
- package/src/cli/validate.ts +1 -0
- package/src/context/bridgeContext.ts +10 -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 +2 -0
- package/src/core/bridgeUserError.test.ts +23 -0
- package/src/core/bridgeUserError.ts +12 -0
- package/src/core/index.ts +3 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +85 -0
- package/src/manifest/manifest.ts +21 -0
- package/src/manifest/presence.ts +48 -0
- package/src/manifest/primitives.ts +6 -0
- package/src/modules/bridge.ts +45 -2
- package/src/modules/bridgeDriver.ts +2 -0
- package/src/modules/bridgeModule.ts +25 -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/protocol/bridgeEgress.ts +8 -1
- package/src/protocol/bridgeEvents.ts +4 -0
- package/src/protocol/bridgeFrame.ts +28 -1
- package/src/protocol/bridgeLimits.ts +10 -0
- package/src/runtime/bridgeActions.ts +7 -0
- package/src/runtime/bridgeDriver.test.ts +477 -2
- package/src/runtime/bridgeDriver.ts +116 -3
- package/src/runtime/bridgeEgress.ts +3 -0
- package/src/runtime/bridgeEvents.ts +7 -5
- 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/bridgePrivilege.test.ts +77 -0
- package/src/runtime/bridgePrivilege.ts +5 -3
- package/src/runtime/bridgeRegistry.test.ts +134 -0
- package/src/runtime/bridgeRegistry.ts +26 -4
- package/src/runtime/bridgeServer.test.ts +338 -0
- package/src/runtime/bridgeServer.ts +25 -3
- package/src/runtime/bridgeSupervisor.ts +18 -0
- package/src/runtime/bridgeZip.ts +179 -58
- package/src/runtime/serve.ts +64 -4
- package/src/wire/index.ts +2 -0
- package/src/wire/panel.test.ts +451 -0
- package/src/wire/panel.ts +295 -0
- package/src/wire/zEnvKey.ts +7 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { BridgeDesired, BridgeFailureCode, BridgeFailureError, BridgeRuntime, BridgeUserError } from "../core";
|
|
6
|
+
import { BridgeMethod, type BridgePendingResult, type BridgeResponse } from "../protocol";
|
|
7
|
+
import { createServer, type ServerOptions } from "./bridgeServer";
|
|
8
|
+
|
|
9
|
+
const TOKEN = "test-token";
|
|
10
|
+
|
|
11
|
+
const unreachable = (what: string) => {
|
|
12
|
+
return () => {
|
|
13
|
+
throw new Error(`${what} is not part of this test`);
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const baseOptions = (socket: string): ServerOptions => ({
|
|
18
|
+
socket,
|
|
19
|
+
token: TOKEN,
|
|
20
|
+
onStatus: () => ({
|
|
21
|
+
version: "0.0.0",
|
|
22
|
+
runtime: BridgeRuntime.Stopped,
|
|
23
|
+
desired: BridgeDesired.Stopped,
|
|
24
|
+
exitCode: null,
|
|
25
|
+
outOfMemory: false,
|
|
26
|
+
restarts: 0,
|
|
27
|
+
installError: null,
|
|
28
|
+
installHold: null,
|
|
29
|
+
}),
|
|
30
|
+
onManifest: unreachable("manifest"),
|
|
31
|
+
onStart: unreachable("start"),
|
|
32
|
+
onStop: unreachable("stop"),
|
|
33
|
+
onCommand: unreachable("command"),
|
|
34
|
+
onInvoke: unreachable("invoke"),
|
|
35
|
+
onEvents: unreachable("events"),
|
|
36
|
+
onSample: unreachable("sample"),
|
|
37
|
+
onQuiesce: unreachable("quiesce"),
|
|
38
|
+
onRelease: unreachable("release"),
|
|
39
|
+
onDescribe: unreachable("describe"),
|
|
40
|
+
onAnnounce: unreachable("announce"),
|
|
41
|
+
onPendingList: unreachable("pendingList"),
|
|
42
|
+
onPendingAdd: unreachable("pendingAdd"),
|
|
43
|
+
onLog: () => {
|
|
44
|
+
return;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const call = async (socket: string, frame: Record<string, unknown>): Promise<BridgeResponse> => {
|
|
49
|
+
return await new Promise<BridgeResponse>((resolve, reject) => {
|
|
50
|
+
void Bun.connect<{
|
|
51
|
+
buffer: string;
|
|
52
|
+
}>({
|
|
53
|
+
unix: socket,
|
|
54
|
+
socket: {
|
|
55
|
+
open(connection) {
|
|
56
|
+
connection.data = {
|
|
57
|
+
buffer: "",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
connection.write(`${JSON.stringify(frame)}\n`);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
data(connection, chunk) {
|
|
64
|
+
connection.data.buffer += chunk.toString();
|
|
65
|
+
|
|
66
|
+
const [line] = connection.data.buffer.split("\n");
|
|
67
|
+
|
|
68
|
+
if (!line) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
connection.end();
|
|
73
|
+
|
|
74
|
+
resolve(JSON.parse(line) as BridgeResponse);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
error(_connection, error) {
|
|
78
|
+
reject(error);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}).catch(reject);
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
describe("pending dispatch", () => {
|
|
86
|
+
let dir = "";
|
|
87
|
+
let socket = "";
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
dir = await mkdtemp(join(tmpdir(), "bridge-server-"));
|
|
91
|
+
socket = join(dir, "bridge.sock");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await rm(dir, {
|
|
96
|
+
force: true,
|
|
97
|
+
recursive: true,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const listed: BridgePendingResult = {
|
|
102
|
+
supported: true,
|
|
103
|
+
staging: ".serverk-staging/pending-upload",
|
|
104
|
+
extensions: [
|
|
105
|
+
"jar",
|
|
106
|
+
],
|
|
107
|
+
files: [],
|
|
108
|
+
rejected: null,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
test("answers pendingList with what the driver lists", async () => {
|
|
112
|
+
const server = createServer({
|
|
113
|
+
...baseOptions(socket),
|
|
114
|
+
onPendingList: async () => listed,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const response = await call(socket, {
|
|
118
|
+
id: 1,
|
|
119
|
+
token: TOKEN,
|
|
120
|
+
method: BridgeMethod.PendingList,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
server.stop(true);
|
|
124
|
+
|
|
125
|
+
expect(response).toEqual({
|
|
126
|
+
id: 1,
|
|
127
|
+
ok: true,
|
|
128
|
+
result: listed,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("answers pendingList with the unsupported sentinel when the driver declares no pending module", async () => {
|
|
133
|
+
const server = createServer({
|
|
134
|
+
...baseOptions(socket),
|
|
135
|
+
onPendingList: async () => ({
|
|
136
|
+
supported: false,
|
|
137
|
+
staging: "",
|
|
138
|
+
extensions: [],
|
|
139
|
+
files: [],
|
|
140
|
+
rejected: null,
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const response = await call(socket, {
|
|
145
|
+
id: 2,
|
|
146
|
+
token: TOKEN,
|
|
147
|
+
method: BridgeMethod.PendingList,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
server.stop(true);
|
|
151
|
+
|
|
152
|
+
expect(response).toEqual({
|
|
153
|
+
id: 2,
|
|
154
|
+
ok: true,
|
|
155
|
+
result: {
|
|
156
|
+
supported: false,
|
|
157
|
+
staging: "",
|
|
158
|
+
extensions: [],
|
|
159
|
+
files: [],
|
|
160
|
+
rejected: null,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("passes the uploaded path to pendingAdd and answers with the refreshed list", async () => {
|
|
166
|
+
const received: string[] = [];
|
|
167
|
+
|
|
168
|
+
const server = createServer({
|
|
169
|
+
...baseOptions(socket),
|
|
170
|
+
onPendingAdd: async (input) => {
|
|
171
|
+
received.push(input);
|
|
172
|
+
|
|
173
|
+
return listed;
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const response = await call(socket, {
|
|
178
|
+
id: 3,
|
|
179
|
+
token: TOKEN,
|
|
180
|
+
method: BridgeMethod.PendingAdd,
|
|
181
|
+
input: ".serverk-staging/pending-upload/patch.jar",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
server.stop(true);
|
|
185
|
+
|
|
186
|
+
expect(received).toEqual([
|
|
187
|
+
".serverk-staging/pending-upload/patch.jar",
|
|
188
|
+
]);
|
|
189
|
+
expect(response).toEqual({
|
|
190
|
+
id: 3,
|
|
191
|
+
ok: true,
|
|
192
|
+
result: listed,
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("refuses a pendingAdd carrying no path", async () => {
|
|
197
|
+
const server = createServer({
|
|
198
|
+
...baseOptions(socket),
|
|
199
|
+
onPendingAdd: unreachable("pendingAdd"),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const response = await call(socket, {
|
|
203
|
+
id: 4,
|
|
204
|
+
token: TOKEN,
|
|
205
|
+
method: BridgeMethod.PendingAdd,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
server.stop(true);
|
|
209
|
+
|
|
210
|
+
expect(response).toEqual({
|
|
211
|
+
id: 4,
|
|
212
|
+
ok: false,
|
|
213
|
+
error: "pendingAdd needs an input path",
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("answers pendingAdd with the rejection the driver reported beside the refreshed list", async () => {
|
|
218
|
+
const rejected: BridgePendingResult = {
|
|
219
|
+
...listed,
|
|
220
|
+
rejected: {
|
|
221
|
+
expected: [
|
|
222
|
+
"patch.jar",
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const server = createServer({
|
|
228
|
+
...baseOptions(socket),
|
|
229
|
+
onPendingAdd: async () => rejected,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const response = await call(socket, {
|
|
233
|
+
id: 6,
|
|
234
|
+
token: TOKEN,
|
|
235
|
+
method: BridgeMethod.PendingAdd,
|
|
236
|
+
input: ".serverk-staging/pending-upload/wrong.jar",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
server.stop(true);
|
|
240
|
+
|
|
241
|
+
expect(response).toEqual({
|
|
242
|
+
id: 6,
|
|
243
|
+
ok: true,
|
|
244
|
+
result: rejected,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("names an unknown method so an older agent can degrade", async () => {
|
|
249
|
+
const server = createServer(baseOptions(socket));
|
|
250
|
+
|
|
251
|
+
const response = await call(socket, {
|
|
252
|
+
id: 5,
|
|
253
|
+
token: TOKEN,
|
|
254
|
+
method: "pendingRemove",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
server.stop(true);
|
|
258
|
+
|
|
259
|
+
expect(response).toEqual({
|
|
260
|
+
id: 5,
|
|
261
|
+
ok: false,
|
|
262
|
+
error: 'unknown method "pendingRemove"',
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("error serialisation", () => {
|
|
268
|
+
let dir = "";
|
|
269
|
+
let socket = "";
|
|
270
|
+
|
|
271
|
+
beforeEach(async () => {
|
|
272
|
+
dir = await mkdtemp(join(tmpdir(), "bridge-server-"));
|
|
273
|
+
socket = join(dir, "bridge.sock");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
afterEach(async () => {
|
|
277
|
+
await rm(dir, {
|
|
278
|
+
force: true,
|
|
279
|
+
recursive: true,
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const thrown = async (error: unknown) => {
|
|
284
|
+
const server = createServer({
|
|
285
|
+
...baseOptions(socket),
|
|
286
|
+
onPendingAdd: async () => {
|
|
287
|
+
throw error;
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const response = await call(socket, {
|
|
292
|
+
id: 1,
|
|
293
|
+
token: TOKEN,
|
|
294
|
+
method: BridgeMethod.PendingAdd,
|
|
295
|
+
input: ".serverk-staging/pending-upload/patch.jar",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
server.stop(true);
|
|
299
|
+
|
|
300
|
+
return response;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
test("carries the localized text of a user error beside the message", async () => {
|
|
304
|
+
expect(
|
|
305
|
+
await thrown(
|
|
306
|
+
new BridgeUserError({
|
|
307
|
+
ar: "هذا مو الملف اللي ننتظره",
|
|
308
|
+
en: "this is not a file we are waiting for",
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
).toEqual({
|
|
312
|
+
id: 1,
|
|
313
|
+
ok: false,
|
|
314
|
+
error: "this is not a file we are waiting for",
|
|
315
|
+
text: {
|
|
316
|
+
ar: "هذا مو الملف اللي ننتظره",
|
|
317
|
+
en: "this is not a file we are waiting for",
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("carries the failure code of a failure error and no text", async () => {
|
|
323
|
+
expect(await thrown(new BridgeFailureError(BridgeFailureCode.CatalogRestricted, "the author blocked it"))).toEqual({
|
|
324
|
+
id: 1,
|
|
325
|
+
ok: false,
|
|
326
|
+
error: "the author blocked it",
|
|
327
|
+
failure: BridgeFailureCode.CatalogRestricted,
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("carries the message alone for an ordinary error", async () => {
|
|
332
|
+
expect(await thrown(new Error("the file could not be read"))).toEqual({
|
|
333
|
+
id: 1,
|
|
334
|
+
ok: false,
|
|
335
|
+
error: "the file could not be read",
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { BridgeFailureError, BridgeSecretError, type BridgeValues } from "../core";
|
|
2
|
+
import { BridgeFailureError, BridgeSecretError, BridgeUserError, type BridgeValues } from "../core";
|
|
3
3
|
import {
|
|
4
4
|
BRIDGE_FRAME_LIMIT,
|
|
5
5
|
type BridgeAnnounceResult,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type BridgeEventsResult,
|
|
9
9
|
type BridgeManifest,
|
|
10
10
|
BridgeMethod,
|
|
11
|
+
type BridgePendingResult,
|
|
11
12
|
type BridgeQuiesceResult,
|
|
12
13
|
type BridgeResponse,
|
|
13
14
|
type BridgeSampleResult,
|
|
@@ -30,6 +31,8 @@ export interface ServerOptions {
|
|
|
30
31
|
onRelease: () => Promise<void>;
|
|
31
32
|
onDescribe: () => Promise<BridgeDescribeResult>;
|
|
32
33
|
onAnnounce: (message: string) => Promise<BridgeAnnounceResult>;
|
|
34
|
+
onPendingList: () => Promise<BridgePendingResult>;
|
|
35
|
+
onPendingAdd: (input: string) => Promise<BridgePendingResult>;
|
|
33
36
|
onLog: BridgeLogger;
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -47,7 +50,7 @@ interface IncomingFrame {
|
|
|
47
50
|
message?: unknown;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
const
|
|
53
|
+
const asText = (value: unknown): string | null => {
|
|
51
54
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
52
55
|
};
|
|
53
56
|
|
|
@@ -136,7 +139,7 @@ export const createServer = (options: ServerOptions) => {
|
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
case BridgeMethod.Announce: {
|
|
139
|
-
const message =
|
|
142
|
+
const message = asText(request.message);
|
|
140
143
|
|
|
141
144
|
if (!message) {
|
|
142
145
|
throw new Error("announce needs a message");
|
|
@@ -145,6 +148,20 @@ export const createServer = (options: ServerOptions) => {
|
|
|
145
148
|
return await options.onAnnounce(message);
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
case BridgeMethod.PendingList: {
|
|
152
|
+
return await options.onPendingList();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case BridgeMethod.PendingAdd: {
|
|
156
|
+
const input = asText(request.input);
|
|
157
|
+
|
|
158
|
+
if (!input) {
|
|
159
|
+
throw new Error("pendingAdd needs an input path");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return await options.onPendingAdd(input);
|
|
163
|
+
}
|
|
164
|
+
|
|
148
165
|
default: {
|
|
149
166
|
throw new Error(`unknown method "${method}"`);
|
|
150
167
|
}
|
|
@@ -249,6 +266,11 @@ export const createServer = (options: ServerOptions) => {
|
|
|
249
266
|
failure: error.code,
|
|
250
267
|
}
|
|
251
268
|
: {}),
|
|
269
|
+
...(error instanceof BridgeUserError
|
|
270
|
+
? {
|
|
271
|
+
text: error.text,
|
|
272
|
+
}
|
|
273
|
+
: {}),
|
|
252
274
|
}),
|
|
253
275
|
);
|
|
254
276
|
});
|
|
@@ -18,6 +18,8 @@ const RESTART_LIMIT = 5;
|
|
|
18
18
|
|
|
19
19
|
const GAME_OOM_SCORE_ADJ = 500;
|
|
20
20
|
|
|
21
|
+
const SIGKILL_EXIT_CODE = 128 + 9;
|
|
22
|
+
|
|
21
23
|
export interface SupervisorLine {
|
|
22
24
|
stream: BridgeStream;
|
|
23
25
|
line: string;
|
|
@@ -113,6 +115,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
113
115
|
let shouldRun = false;
|
|
114
116
|
let breaker = false;
|
|
115
117
|
let exitCode: number | null = null;
|
|
118
|
+
let outOfMemory = false;
|
|
116
119
|
let restarts = 0;
|
|
117
120
|
let queue: Promise<unknown> = Promise.resolve();
|
|
118
121
|
|
|
@@ -307,6 +310,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
307
310
|
exitCode = code;
|
|
308
311
|
|
|
309
312
|
if (!shouldRun) {
|
|
313
|
+
outOfMemory = false;
|
|
314
|
+
|
|
310
315
|
runtime = BridgeRuntime.Stopped;
|
|
311
316
|
|
|
312
317
|
options.onLog("game stopped", {
|
|
@@ -316,6 +321,14 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
316
321
|
return;
|
|
317
322
|
}
|
|
318
323
|
|
|
324
|
+
outOfMemory = code === SIGKILL_EXIT_CODE;
|
|
325
|
+
|
|
326
|
+
if (outOfMemory) {
|
|
327
|
+
options.onLog.error("the game was killed for running out of memory", {
|
|
328
|
+
code,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
319
332
|
restarts += 1;
|
|
320
333
|
restartStamps.push(performance.now());
|
|
321
334
|
|
|
@@ -375,6 +388,10 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
375
388
|
return exitCode;
|
|
376
389
|
},
|
|
377
390
|
|
|
391
|
+
get outOfMemory() {
|
|
392
|
+
return outOfMemory;
|
|
393
|
+
},
|
|
394
|
+
|
|
378
395
|
get restarts() {
|
|
379
396
|
return restarts;
|
|
380
397
|
},
|
|
@@ -382,6 +399,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
382
399
|
async start() {
|
|
383
400
|
shouldRun = true;
|
|
384
401
|
breaker = false;
|
|
402
|
+
outOfMemory = false;
|
|
385
403
|
restartStamps.length = 0;
|
|
386
404
|
|
|
387
405
|
if (child) {
|