@serverkgg/bridge 0.0.16 → 0.0.18
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 +2 -2
- package/src/codecs/index.ts +1 -0
- package/src/codecs/javaProperties.test.ts +67 -0
- package/src/codecs/javaProperties.ts +99 -0
- package/src/context/bridgeContext.ts +8 -1
- package/src/core/bridgeAction.ts +2 -0
- package/src/modules/bridge.ts +2 -0
- package/src/modules/bridgeModule.ts +23 -6
- package/src/panel/bridgeCollection.ts +5 -0
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCatalog.ts +6 -0
- package/src/protocol/bridgeFrame.ts +13 -0
- package/src/protocol/bridgeManifest.ts +1 -0
- package/src/protocol/bridgePreview.ts +1 -0
- package/src/runtime/bridgeActions.ts +10 -0
- package/src/runtime/bridgeBackupLease.test.ts +148 -0
- package/src/runtime/bridgeBackupLease.ts +74 -0
- package/src/runtime/bridgeCodec.ts +12 -0
- package/src/runtime/bridgeFiles.test.ts +330 -4
- package/src/runtime/bridgeFiles.ts +112 -18
- package/src/runtime/bridgeRegistry.test.ts +61 -0
- package/src/runtime/bridgeRegistry.ts +31 -5
- package/src/runtime/bridgeServer.test.ts +57 -0
- package/src/runtime/bridgeServer.ts +14 -6
- package/src/runtime/bridgeZip.ts +73 -6
- package/src/runtime/serve.ts +48 -14
- package/src/wire/panel.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverkgg/bridge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
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",
|
|
@@ -39,6 +39,6 @@
|
|
|
39
39
|
"test": "bun test --parallel"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"zod": "4.
|
|
42
|
+
"zod": "4.6.4"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/codecs/index.ts
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { escapeJavaProperty, mergeJavaProperties, parseJavaProperties, unescapeJavaProperty } from "./javaProperties";
|
|
3
|
+
|
|
4
|
+
const ARABIC = "سيرفرك";
|
|
5
|
+
|
|
6
|
+
describe("parseJavaProperties", () => {
|
|
7
|
+
test("decodes the unicode escapes the java server writes", () => {
|
|
8
|
+
expect(parseJavaProperties("motd=\\u0633\\u064A\\u0631\\u0641\\u0631\\u0643\n")).toEqual({
|
|
9
|
+
motd: ARABIC,
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("decodes backslash escapes in values", () => {
|
|
14
|
+
expect(parseJavaProperties("resource-pack=https\\://example.com/pack.zip\nmotd=a\\tb\\\\c\n")).toEqual({
|
|
15
|
+
"resource-pack": "https://example.com/pack.zip",
|
|
16
|
+
motd: "a\tb\\c",
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("repairs a value the server stored as latin-1 bytes of utf-8", () => {
|
|
21
|
+
expect(parseJavaProperties("motd=ddss \\u00D9\\u008A\\u00D8\\u00AA\n")).toEqual({
|
|
22
|
+
motd: "ddss يت",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("keeps plain latin-1 text that is not utf-8", () => {
|
|
27
|
+
expect(unescapeJavaProperty("caf\\u00E9")).toBe("café");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("keeps comments and plain values", () => {
|
|
31
|
+
expect(parseJavaProperties("#Minecraft server properties\nmax-players=20\n")).toEqual({
|
|
32
|
+
"max-players": "20",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("mergeJavaProperties", () => {
|
|
38
|
+
test("writes non ascii text as unicode escapes", () => {
|
|
39
|
+
expect(
|
|
40
|
+
mergeJavaProperties("motd=A Minecraft Server\nmax-players=20\n", {
|
|
41
|
+
motd: ARABIC,
|
|
42
|
+
}),
|
|
43
|
+
).toBe("motd=\\u0633\\u064A\\u0631\\u0641\\u0631\\u0643\nmax-players=20\n");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("escapes the characters java treats as separators", () => {
|
|
47
|
+
expect(escapeJavaProperty("https://a.b#c=d!e")).toBe("https\\://a.b\\#c\\=d\\!e");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("escapes a leading space and control characters", () => {
|
|
51
|
+
expect(escapeJavaProperty(" a\tb\\c")).toBe("\\ a\\tb\\\\c");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("round trips astral characters", () => {
|
|
55
|
+
const value = "hi 🎮";
|
|
56
|
+
|
|
57
|
+
expect(unescapeJavaProperty(escapeJavaProperty(value))).toBe(value);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("appends a key the file does not have yet", () => {
|
|
61
|
+
expect(
|
|
62
|
+
mergeJavaProperties("", {
|
|
63
|
+
motd: "hello",
|
|
64
|
+
}),
|
|
65
|
+
).toBe("motd=hello\n");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mergeProperties, parseProperties } from "./properties";
|
|
2
|
+
|
|
3
|
+
const UNICODE_ESCAPE = /\\u([0-9A-Fa-f]{4})/g;
|
|
4
|
+
|
|
5
|
+
const SIMPLE_ESCAPE = /\\(.)/g;
|
|
6
|
+
|
|
7
|
+
const SIMPLE_ESCAPES: Record<string, string> = {
|
|
8
|
+
t: "\t",
|
|
9
|
+
n: "\n",
|
|
10
|
+
r: "\r",
|
|
11
|
+
f: "\f",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ESCAPED_CHARACTERS: Record<string, string> = {
|
|
15
|
+
"\\": "\\\\",
|
|
16
|
+
"\t": "\\t",
|
|
17
|
+
"\n": "\\n",
|
|
18
|
+
"\r": "\\r",
|
|
19
|
+
"\f": "\\f",
|
|
20
|
+
"=": "\\=",
|
|
21
|
+
":": "\\:",
|
|
22
|
+
"#": "\\#",
|
|
23
|
+
"!": "\\!",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const PRINTABLE_MIN = 0x20;
|
|
27
|
+
|
|
28
|
+
const PRINTABLE_MAX = 0x7e;
|
|
29
|
+
|
|
30
|
+
const LATIN1_MAX = 0xff;
|
|
31
|
+
|
|
32
|
+
const HEX_DIGITS = 4;
|
|
33
|
+
|
|
34
|
+
const latin1Decoder = new TextDecoder("utf-8", {
|
|
35
|
+
fatal: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const repairLatin1 = (text: string) => {
|
|
39
|
+
const codes = Array.from(text, (character) => character.charCodeAt(0));
|
|
40
|
+
|
|
41
|
+
if (codes.every((code) => code <= PRINTABLE_MAX) || codes.some((code) => code > LATIN1_MAX)) {
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return latin1Decoder.decode(Uint8Array.from(codes));
|
|
47
|
+
} catch {
|
|
48
|
+
return text;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const unescapeJavaProperty = (text: string) => {
|
|
53
|
+
const unescaped = text
|
|
54
|
+
.replace(UNICODE_ESCAPE, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
55
|
+
.replace(SIMPLE_ESCAPE, (_, character: string) => SIMPLE_ESCAPES[character] ?? character);
|
|
56
|
+
|
|
57
|
+
return repairLatin1(unescaped);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const escapeJavaProperty = (text: string) => {
|
|
61
|
+
let escaped = "";
|
|
62
|
+
|
|
63
|
+
for (const character of text) {
|
|
64
|
+
const code = character.codePointAt(0) ?? 0;
|
|
65
|
+
const known = ESCAPED_CHARACTERS[character];
|
|
66
|
+
|
|
67
|
+
if (known !== undefined) {
|
|
68
|
+
escaped += known;
|
|
69
|
+
} else if (code < PRINTABLE_MIN || code > PRINTABLE_MAX) {
|
|
70
|
+
for (let index = 0; index < character.length; index += 1) {
|
|
71
|
+
escaped += `\\u${character.charCodeAt(index).toString(16).toUpperCase().padStart(HEX_DIGITS, "0")}`;
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
escaped += character;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return escaped.startsWith(" ") ? `\\${escaped}` : escaped;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const parseJavaProperties = (contents: string) => {
|
|
82
|
+
const values: Record<string, string> = {};
|
|
83
|
+
|
|
84
|
+
for (const [key, value] of Object.entries(parseProperties(contents))) {
|
|
85
|
+
values[unescapeJavaProperty(key)] = unescapeJavaProperty(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return values;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const mergeJavaProperties = (contents: string, values: Record<string, string>) => {
|
|
92
|
+
const escaped: Record<string, string> = {};
|
|
93
|
+
|
|
94
|
+
for (const [key, value] of Object.entries(values)) {
|
|
95
|
+
escaped[escapeJavaProperty(key)] = escapeJavaProperty(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return mergeProperties(contents, escaped);
|
|
99
|
+
};
|
|
@@ -27,6 +27,8 @@ export interface BridgeDownloadOptions {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface BridgeExtractOptions {
|
|
30
|
+
maxBytes?: number;
|
|
31
|
+
maxEntryBytes?: number;
|
|
30
32
|
extensions?: string[];
|
|
31
33
|
tree?: boolean;
|
|
32
34
|
select?: string;
|
|
@@ -34,10 +36,14 @@ export interface BridgeExtractOptions {
|
|
|
34
36
|
exclude?: string[];
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
export interface BridgeListOptions {
|
|
40
|
+
directory?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
export interface BridgeFiles {
|
|
38
44
|
read(path: string): Promise<string>;
|
|
39
45
|
write(path: string, content: string): Promise<void>;
|
|
40
|
-
list(glob: string): Promise<BridgeFileEntry[]>;
|
|
46
|
+
list(glob: string, options?: BridgeListOptions): Promise<BridgeFileEntry[]>;
|
|
41
47
|
remove(path: string): Promise<void>;
|
|
42
48
|
move(from: string, to: string): Promise<void>;
|
|
43
49
|
ensure(...paths: string[]): Promise<void>;
|
|
@@ -60,6 +66,7 @@ export interface BridgeCodec {
|
|
|
60
66
|
|
|
61
67
|
export interface BridgeCodecs {
|
|
62
68
|
ini: BridgeCodec;
|
|
69
|
+
javaProperties: BridgeCodec;
|
|
63
70
|
json: BridgeCodec;
|
|
64
71
|
properties: BridgeCodec;
|
|
65
72
|
sourceCfg: BridgeCodec;
|
package/src/core/bridgeAction.ts
CHANGED
|
@@ -7,6 +7,7 @@ export enum BridgeAction {
|
|
|
7
7
|
Quiesce = "quiesce",
|
|
8
8
|
Read = "read",
|
|
9
9
|
Release = "release",
|
|
10
|
+
Releases = "releases",
|
|
10
11
|
Remove = "remove",
|
|
11
12
|
Sample = "sample",
|
|
12
13
|
Search = "search",
|
|
@@ -15,6 +16,7 @@ export enum BridgeAction {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export const BRIDGE_READ_ACTIONS: readonly string[] = [
|
|
19
|
+
BridgeAction.Releases,
|
|
18
20
|
BridgeAction.Installed,
|
|
19
21
|
BridgeAction.List,
|
|
20
22
|
BridgeAction.Preview,
|
package/src/modules/bridge.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
} from "../core";
|
|
12
12
|
import { BridgeUserError } from "../core";
|
|
13
13
|
import type {
|
|
14
|
+
BridgeAddOutcome,
|
|
14
15
|
BridgeDetail,
|
|
15
16
|
BridgeDetailBadge,
|
|
16
17
|
BridgeDetailLink,
|
|
@@ -72,6 +73,7 @@ import type {
|
|
|
72
73
|
|
|
73
74
|
export namespace Bridge {
|
|
74
75
|
export type Actions = BridgeActionsModule;
|
|
76
|
+
export type AddOutcome = BridgeAddOutcome;
|
|
75
77
|
export type Announce = BridgeAnnounceModule;
|
|
76
78
|
export type Backup = BridgeBackupModule;
|
|
77
79
|
export type Catalog = BridgeCatalogModule;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { BridgeContext, BridgeReply } from "../context";
|
|
2
2
|
import type { BridgeInstallOutcome, BridgeKind, BridgeOption, BridgeRow, BridgeStream, BridgeValues } from "../core";
|
|
3
|
-
import type { BridgeDetail, BridgeDetailOutcome } from "../panel";
|
|
4
|
-
import type {
|
|
5
|
-
|
|
3
|
+
import type { BridgeAddOutcome, BridgeDetail, BridgeDetailOutcome } from "../panel";
|
|
4
|
+
import type {
|
|
5
|
+
BridgeCatalogEntry,
|
|
6
|
+
BridgeCatalogPage,
|
|
7
|
+
BridgeCatalogQuery,
|
|
8
|
+
BridgeCatalogRelease,
|
|
9
|
+
} from "../protocol/bridgeCatalog";
|
|
10
|
+
import type { BridgeBackupSnapshot, BridgeDescribeResult, BridgePendingFile } from "../protocol/bridgeFrame";
|
|
6
11
|
import type { BridgePreview } from "../protocol/bridgePreview";
|
|
7
12
|
import type { BridgeSetupStep } from "../protocol/bridgeSetup";
|
|
8
13
|
import type { BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
|
|
@@ -32,7 +37,12 @@ export interface BridgeAnnounceModule {
|
|
|
32
37
|
export interface BridgeBackupModule {
|
|
33
38
|
kind: BridgeKind.Backup;
|
|
34
39
|
settleSeconds?: number;
|
|
35
|
-
quiesce?(context: BridgeContext): Promise<
|
|
40
|
+
quiesce?(context: BridgeContext): Promise<
|
|
41
|
+
| undefined
|
|
42
|
+
| {
|
|
43
|
+
snapshot?: BridgeBackupSnapshot;
|
|
44
|
+
}
|
|
45
|
+
>;
|
|
36
46
|
release?(context: BridgeContext): Promise<void>;
|
|
37
47
|
}
|
|
38
48
|
|
|
@@ -95,6 +105,7 @@ export interface BridgePreviewModule {
|
|
|
95
105
|
|
|
96
106
|
export interface BridgeSettingsModule {
|
|
97
107
|
kind: BridgeKind.Settings;
|
|
108
|
+
protectedActions?: string[];
|
|
98
109
|
read(context: BridgeContext): Promise<BridgeValues>;
|
|
99
110
|
write?(context: BridgeContext, values: BridgeValues): Promise<void>;
|
|
100
111
|
}
|
|
@@ -109,16 +120,18 @@ export type BridgeServerAction = (context: BridgeContext, args: BridgeValues) =>
|
|
|
109
120
|
|
|
110
121
|
export interface BridgeActionsModule {
|
|
111
122
|
kind: BridgeKind.Actions;
|
|
123
|
+
protectedActions?: string[];
|
|
112
124
|
requiresRunning?: boolean;
|
|
113
125
|
actions: Record<string, BridgeServerAction>;
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
export interface BridgeCollectionModule<Row extends BridgeRow = BridgeRow> {
|
|
117
129
|
kind: BridgeKind.Collection;
|
|
130
|
+
protectedActions?: string[];
|
|
118
131
|
requiresRunning?: boolean;
|
|
119
132
|
refreshSeconds?: number;
|
|
120
133
|
list(context: BridgeContext): Promise<Row[]>;
|
|
121
|
-
add?(context: BridgeContext, input: string): Promise<
|
|
134
|
+
add?(context: BridgeContext, input: string): Promise<BridgeAddOutcome | undefined>;
|
|
122
135
|
actions?: Record<string, BridgeRowAction<Row>>;
|
|
123
136
|
}
|
|
124
137
|
|
|
@@ -126,6 +139,7 @@ export type BridgeDetailAction = (context: BridgeContext, args: BridgeValues) =>
|
|
|
126
139
|
|
|
127
140
|
export interface BridgeDetailModule {
|
|
128
141
|
kind: BridgeKind.Detail;
|
|
142
|
+
protectedActions?: string[];
|
|
129
143
|
requiresRunning?: boolean;
|
|
130
144
|
refreshSeconds?: number;
|
|
131
145
|
read(context: BridgeContext): Promise<BridgeDetail | null>;
|
|
@@ -134,10 +148,13 @@ export interface BridgeDetailModule {
|
|
|
134
148
|
|
|
135
149
|
export interface BridgeCatalogModule {
|
|
136
150
|
kind: BridgeKind.Catalog;
|
|
151
|
+
protectedActions?: string[];
|
|
137
152
|
pageSize?: number;
|
|
138
153
|
search(context: BridgeContext, query: BridgeCatalogQuery): Promise<BridgeCatalogPage>;
|
|
139
154
|
installed(context: BridgeContext): Promise<BridgeCatalogEntry[]>;
|
|
140
|
-
|
|
155
|
+
releases?(context: BridgeContext, id: string): Promise<BridgeCatalogRelease[]>;
|
|
156
|
+
preview?(context: BridgeContext, id: string, releaseId?: string): Promise<BridgePreview>;
|
|
157
|
+
install(context: BridgeContext, id: string, releaseId?: string): Promise<BridgeCatalogEntry>;
|
|
141
158
|
remove(context: BridgeContext, id: string): Promise<void>;
|
|
142
159
|
toggle?(context: BridgeContext, id: string, enabled: boolean): Promise<void>;
|
|
143
160
|
}
|
package/src/panel/index.ts
CHANGED
|
@@ -44,6 +44,9 @@ export interface BridgeRequest {
|
|
|
44
44
|
args?: BridgeValues;
|
|
45
45
|
cursor?: number;
|
|
46
46
|
message?: string;
|
|
47
|
+
supportsSnapshot?: boolean;
|
|
48
|
+
leaseId?: string;
|
|
49
|
+
recoveryOperationId?: string;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
export interface BridgeCommandResult {
|
|
@@ -93,10 +96,20 @@ export interface BridgeAnnounceResult {
|
|
|
93
96
|
delivered: boolean;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
export interface BridgeBackupSnapshot {
|
|
100
|
+
roots: string[];
|
|
101
|
+
files: Array<{
|
|
102
|
+
path: string;
|
|
103
|
+
size: number;
|
|
104
|
+
}>;
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
export interface BridgeQuiesceResult {
|
|
108
|
+
leaseId?: string;
|
|
97
109
|
supported: boolean;
|
|
98
110
|
quiesced: boolean;
|
|
99
111
|
settleSeconds: number;
|
|
112
|
+
snapshot?: BridgeBackupSnapshot;
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
export interface BridgeDescribeResult {
|
|
@@ -51,6 +51,16 @@ export const actionsOf = (module: BridgePanelModule): string[] => {
|
|
|
51
51
|
|
|
52
52
|
case BridgeKind.Catalog: {
|
|
53
53
|
return [
|
|
54
|
+
...(module.releases
|
|
55
|
+
? [
|
|
56
|
+
BridgeAction.Releases,
|
|
57
|
+
]
|
|
58
|
+
: []),
|
|
59
|
+
...(module.preview
|
|
60
|
+
? [
|
|
61
|
+
BridgeAction.Preview,
|
|
62
|
+
]
|
|
63
|
+
: []),
|
|
54
64
|
BridgeAction.Install,
|
|
55
65
|
BridgeAction.Installed,
|
|
56
66
|
BridgeAction.Remove,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createBackupLease } from "./bridgeBackupLease";
|
|
3
|
+
|
|
4
|
+
describe("backup save ownership", () => {
|
|
5
|
+
test("keeps a long backup frozen while renewed and releases when its owner disappears", async () => {
|
|
6
|
+
let now = 0;
|
|
7
|
+
let releases = 0;
|
|
8
|
+
const lease = createBackupLease(
|
|
9
|
+
async () => {
|
|
10
|
+
releases++;
|
|
11
|
+
},
|
|
12
|
+
() => now,
|
|
13
|
+
120,
|
|
14
|
+
);
|
|
15
|
+
const id = lease.begin();
|
|
16
|
+
for (let index = 0; index < 10; index++) {
|
|
17
|
+
now += 100;
|
|
18
|
+
lease.renew(id);
|
|
19
|
+
await lease.expire();
|
|
20
|
+
}
|
|
21
|
+
expect(releases).toBe(0);
|
|
22
|
+
now += 121;
|
|
23
|
+
expect(() => lease.renew(id)).toThrow("expired");
|
|
24
|
+
await lease.expire();
|
|
25
|
+
expect(releases).toBe(1);
|
|
26
|
+
expect(lease.active).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
test("refuses overlapping holds and retains ownership after a failed resume", async () => {
|
|
29
|
+
let fail = true;
|
|
30
|
+
const lease = createBackupLease(async () => {
|
|
31
|
+
if (fail) {
|
|
32
|
+
throw new Error("resume failed");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const owner = lease.begin();
|
|
36
|
+
expect(() => lease.begin()).toThrow("already owns");
|
|
37
|
+
expect(() => lease.renew("another-owner")).toThrow();
|
|
38
|
+
await expect(lease.finish(owner)).rejects.toThrow("resume failed");
|
|
39
|
+
expect(lease.active).toBe(true);
|
|
40
|
+
fail = false;
|
|
41
|
+
await lease.finish(owner);
|
|
42
|
+
expect(lease.active).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("reserves ownership while the quiesce operation is still awaiting confirmation", async () => {
|
|
47
|
+
const prepared = Promise.withResolvers<void>();
|
|
48
|
+
const lease = createBackupLease(async () => {});
|
|
49
|
+
const owner = lease.begin();
|
|
50
|
+
const operation = lease.prepare(owner, () => prepared.promise);
|
|
51
|
+
expect(() => lease.begin()).toThrow("already owns");
|
|
52
|
+
expect(() => lease.renew(owner)).toThrow("not ready");
|
|
53
|
+
await expect(lease.finish("competitor")).rejects.toThrow("owner");
|
|
54
|
+
await expect(lease.finish()).rejects.toThrow("owner");
|
|
55
|
+
prepared.resolve();
|
|
56
|
+
await operation;
|
|
57
|
+
expect(lease.renew(owner)).toBe(owner);
|
|
58
|
+
await lease.finish(owner);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("expiration waits for in-flight preparation before resuming saves", async () => {
|
|
62
|
+
let now = 0;
|
|
63
|
+
let releases = 0;
|
|
64
|
+
const prepared = Promise.withResolvers<void>();
|
|
65
|
+
const lease = createBackupLease(
|
|
66
|
+
async () => {
|
|
67
|
+
releases++;
|
|
68
|
+
},
|
|
69
|
+
() => now,
|
|
70
|
+
10,
|
|
71
|
+
);
|
|
72
|
+
const owner = lease.begin();
|
|
73
|
+
const operation = lease.prepare(owner, () => prepared.promise);
|
|
74
|
+
now = 11;
|
|
75
|
+
const expired = lease.expire();
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
expect(releases).toBe(0);
|
|
78
|
+
expect(() => lease.begin()).toThrow();
|
|
79
|
+
prepared.resolve();
|
|
80
|
+
await operation;
|
|
81
|
+
await expired;
|
|
82
|
+
expect(releases).toBe(1);
|
|
83
|
+
expect(lease.active).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("late releases and tokenless callers cannot release a newer owner", async () => {
|
|
87
|
+
let releases = 0;
|
|
88
|
+
const lease = createBackupLease(async () => {
|
|
89
|
+
releases++;
|
|
90
|
+
});
|
|
91
|
+
const first = lease.begin();
|
|
92
|
+
await lease.finish(first);
|
|
93
|
+
const second = lease.begin();
|
|
94
|
+
await expect(lease.finish(first)).rejects.toThrow("owner");
|
|
95
|
+
await expect(lease.finish()).rejects.toThrow("owner");
|
|
96
|
+
expect(releases).toBe(1);
|
|
97
|
+
expect(lease.active).toBe(true);
|
|
98
|
+
await lease.finish(second);
|
|
99
|
+
expect(releases).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("legacy release is accepted only for a legacy hold", async () => {
|
|
103
|
+
let releases = 0;
|
|
104
|
+
const lease = createBackupLease(async () => {
|
|
105
|
+
releases++;
|
|
106
|
+
});
|
|
107
|
+
lease.begin(true);
|
|
108
|
+
await lease.finish();
|
|
109
|
+
expect(releases).toBe(1);
|
|
110
|
+
await lease.finish();
|
|
111
|
+
expect(releases).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("failed quiesce cleanup keeps the owner until resume succeeds", async () => {
|
|
115
|
+
let releases = 0;
|
|
116
|
+
const lease = createBackupLease(async () => {
|
|
117
|
+
releases++;
|
|
118
|
+
});
|
|
119
|
+
const owner = lease.begin();
|
|
120
|
+
await expect(
|
|
121
|
+
lease.prepare(owner, async () => {
|
|
122
|
+
throw new Error("quiesce failed");
|
|
123
|
+
}),
|
|
124
|
+
).rejects.toThrow();
|
|
125
|
+
await lease.finish(owner);
|
|
126
|
+
expect(releases).toBe(1);
|
|
127
|
+
expect(lease.active).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("legacy owners retain the historical hold until explicit release without renewal", async () => {
|
|
131
|
+
let now = 0;
|
|
132
|
+
let releases = 0;
|
|
133
|
+
const lease = createBackupLease(
|
|
134
|
+
async () => {
|
|
135
|
+
releases++;
|
|
136
|
+
},
|
|
137
|
+
() => now,
|
|
138
|
+
120,
|
|
139
|
+
);
|
|
140
|
+
lease.begin(true);
|
|
141
|
+
now = 120_000;
|
|
142
|
+
await lease.expire();
|
|
143
|
+
expect(lease.active).toBe(true);
|
|
144
|
+
expect(releases).toBe(0);
|
|
145
|
+
await lease.finish();
|
|
146
|
+
expect(releases).toBe(1);
|
|
147
|
+
expect(lease.active).toBe(false);
|
|
148
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export const createBackupLease = (
|
|
2
|
+
release: () => Promise<void>,
|
|
3
|
+
now: () => number = () => performance.now(),
|
|
4
|
+
ttlMs = 120_000,
|
|
5
|
+
) => {
|
|
6
|
+
let id: string | null = null;
|
|
7
|
+
let legacy = false;
|
|
8
|
+
let deadline = 0;
|
|
9
|
+
let preparing: Promise<unknown> | null = null;
|
|
10
|
+
let releasing: Promise<void> | null = null;
|
|
11
|
+
const finish = async (token?: string) => {
|
|
12
|
+
if (id === null) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (token !== id && !(token === undefined && legacy)) {
|
|
16
|
+
throw new Error("only the backup save lease owner can release it");
|
|
17
|
+
}
|
|
18
|
+
if (releasing) {
|
|
19
|
+
return await releasing;
|
|
20
|
+
}
|
|
21
|
+
releasing = Promise.resolve()
|
|
22
|
+
.then(async () => {
|
|
23
|
+
await preparing?.catch(() => undefined);
|
|
24
|
+
await release();
|
|
25
|
+
id = null;
|
|
26
|
+
legacy = false;
|
|
27
|
+
})
|
|
28
|
+
.finally(() => {
|
|
29
|
+
releasing = null;
|
|
30
|
+
});
|
|
31
|
+
await releasing;
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
get active() {
|
|
35
|
+
return id !== null;
|
|
36
|
+
},
|
|
37
|
+
begin(legacyOwner = false) {
|
|
38
|
+
if (id !== null) {
|
|
39
|
+
throw new Error("a backup already owns the save hold");
|
|
40
|
+
}
|
|
41
|
+
id = crypto.randomUUID();
|
|
42
|
+
legacy = legacyOwner;
|
|
43
|
+
deadline = now() + ttlMs;
|
|
44
|
+
return id;
|
|
45
|
+
},
|
|
46
|
+
async prepare<Result>(token: string, run: () => Promise<Result>): Promise<Result> {
|
|
47
|
+
if (id !== token || releasing || preparing) {
|
|
48
|
+
throw new Error("the backup save lease is not available for preparation");
|
|
49
|
+
}
|
|
50
|
+
const operation = Promise.resolve().then(run);
|
|
51
|
+
preparing = operation;
|
|
52
|
+
try {
|
|
53
|
+
return await operation;
|
|
54
|
+
} finally {
|
|
55
|
+
if (preparing === operation) {
|
|
56
|
+
preparing = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
renew(token: string) {
|
|
61
|
+
if (id !== token || now() >= deadline || releasing || preparing) {
|
|
62
|
+
throw new Error("the backup save lease expired or is not ready");
|
|
63
|
+
}
|
|
64
|
+
deadline = now() + ttlMs;
|
|
65
|
+
return token;
|
|
66
|
+
},
|
|
67
|
+
finish,
|
|
68
|
+
async expire() {
|
|
69
|
+
if (id !== null && !legacy && now() >= deadline) {
|
|
70
|
+
await finish(id);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
mergeIni,
|
|
3
|
+
mergeJavaProperties,
|
|
3
4
|
mergeJson,
|
|
4
5
|
mergeProperties,
|
|
5
6
|
mergeSourceCfg,
|
|
6
7
|
mergeUeIni,
|
|
7
8
|
mergeXmlProperties,
|
|
8
9
|
parseIni,
|
|
10
|
+
parseJavaProperties,
|
|
9
11
|
parseJson,
|
|
10
12
|
parseProperties,
|
|
11
13
|
parseSourceCfg,
|
|
@@ -61,6 +63,15 @@ export const createBridgeCodecs = (files: BridgeFiles): BridgeCodecs => {
|
|
|
61
63
|
},
|
|
62
64
|
};
|
|
63
65
|
|
|
66
|
+
const javaProperties: BridgeCodec = {
|
|
67
|
+
async read(path) {
|
|
68
|
+
return widen(parseJavaProperties(await readOr(files, path)));
|
|
69
|
+
},
|
|
70
|
+
async merge(path, values) {
|
|
71
|
+
await files.write(path, mergeJavaProperties(await readOr(files, path), toStrings(values)));
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
64
75
|
const json: BridgeCodec = {
|
|
65
76
|
async read(path) {
|
|
66
77
|
return parseJson(await readOr(files, path));
|
|
@@ -112,6 +123,7 @@ export const createBridgeCodecs = (files: BridgeFiles): BridgeCodecs => {
|
|
|
112
123
|
|
|
113
124
|
return {
|
|
114
125
|
ini,
|
|
126
|
+
javaProperties,
|
|
115
127
|
json,
|
|
116
128
|
properties,
|
|
117
129
|
sourceCfg,
|