@serverkgg/bridge 0.0.15 → 0.0.17
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/context/bridgeContext.ts +7 -1
- package/src/core/bridgeAction.ts +2 -0
- package/src/modules/bridgeModule.ts +21 -4
- package/src/protocol/bridgeCatalog.ts +6 -0
- package/src/protocol/bridgeCrash.ts +83 -0
- package/src/protocol/bridgeFrame.ts +14 -0
- package/src/protocol/bridgeManifest.ts +1 -0
- package/src/protocol/bridgePreview.ts +1 -0
- package/src/protocol/index.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/bridgeEvents.test.ts +411 -9
- package/src/runtime/bridgeEvents.ts +53 -3
- 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 +30 -2
- package/src/runtime/bridgeServer.test.ts +58 -0
- package/src/runtime/bridgeServer.ts +14 -6
- package/src/runtime/bridgeSupervisor.test.ts +289 -0
- package/src/runtime/bridgeSupervisor.ts +166 -9
- package/src/runtime/bridgeZip.ts +73 -6
- package/src/runtime/serve.ts +58 -14
- package/src/wire/panel.ts +1 -0
package/package.json
CHANGED
|
@@ -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>;
|
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,
|
|
@@ -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
3
|
import type { BridgeDetail, BridgeDetailOutcome } from "../panel";
|
|
4
|
-
import type {
|
|
5
|
-
|
|
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,12 +120,14 @@ 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[]>;
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { BridgeEventName } from "./bridgeEvents";
|
|
2
|
+
|
|
3
|
+
export enum BridgeCrashCause {
|
|
4
|
+
OomKill = "oom-kill",
|
|
5
|
+
OutOfMemory = "out-of-memory",
|
|
6
|
+
WrongJavaVersion = "wrong-java",
|
|
7
|
+
PortBusy = "port-busy",
|
|
8
|
+
WorldCorrupt = "world-corrupt",
|
|
9
|
+
MissingDependency = "missing-dependency",
|
|
10
|
+
ModCrashed = "mod-crashed",
|
|
11
|
+
Unknown = "unknown",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export enum BridgeCrashPhase {
|
|
15
|
+
Booting = "booting",
|
|
16
|
+
Running = "running",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BridgeCrashEvidence {
|
|
20
|
+
name: BridgeEventName;
|
|
21
|
+
cause: BridgeCrashCause;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
|
|
25
|
+
{
|
|
26
|
+
name: BridgeEventName.ServerCrashed,
|
|
27
|
+
cause: BridgeCrashCause.OutOfMemory,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: BridgeEventName.WrongJavaVersion,
|
|
31
|
+
cause: BridgeCrashCause.WrongJavaVersion,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: BridgeEventName.PortBindFailed,
|
|
35
|
+
cause: BridgeCrashCause.PortBusy,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: BridgeEventName.WorldCorrupt,
|
|
39
|
+
cause: BridgeCrashCause.WorldCorrupt,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: BridgeEventName.MissingDependency,
|
|
43
|
+
cause: BridgeCrashCause.MissingDependency,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: BridgeEventName.ModCrashed,
|
|
47
|
+
cause: BridgeCrashCause.ModCrashed,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const BRIDGE_CRASH_EVIDENCE_NAMES: ReadonlySet<string> = new Set(BRIDGE_CRASH_EVIDENCE.map((entry) => entry.name));
|
|
52
|
+
|
|
53
|
+
export const isCrashEvidence = (name: string): boolean => {
|
|
54
|
+
return BRIDGE_CRASH_EVIDENCE_NAMES.has(name);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const BRIDGE_CRASH_KEYS = {
|
|
58
|
+
cause: "cause",
|
|
59
|
+
phase: "phase",
|
|
60
|
+
exitCode: "exitCode",
|
|
61
|
+
restarting: "restarting",
|
|
62
|
+
looping: "looping",
|
|
63
|
+
restarts: "restarts",
|
|
64
|
+
uptimeSeconds: "uptimeSeconds",
|
|
65
|
+
lastLine: "lastLine",
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
export const BRIDGE_CRASH_FLAG_YES = "yes";
|
|
69
|
+
|
|
70
|
+
export const BRIDGE_CRASH_FLAG_NO = "no";
|
|
71
|
+
|
|
72
|
+
export const BRIDGE_CRASH_LINE_LIMIT = 256;
|
|
73
|
+
|
|
74
|
+
export interface BridgeCrashRecord {
|
|
75
|
+
exitCode: number;
|
|
76
|
+
outOfMemory: boolean;
|
|
77
|
+
phase: BridgeCrashPhase;
|
|
78
|
+
uptimeSeconds: number;
|
|
79
|
+
restarting: boolean;
|
|
80
|
+
looping: boolean;
|
|
81
|
+
restarts: number;
|
|
82
|
+
lastLine: string | null;
|
|
83
|
+
}
|
|
@@ -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 {
|
|
@@ -76,6 +79,7 @@ export interface BridgeStatus {
|
|
|
76
79
|
exitCode: number | null;
|
|
77
80
|
outOfMemory: boolean;
|
|
78
81
|
restarts: number;
|
|
82
|
+
crashRecords: boolean;
|
|
79
83
|
installError: string | null;
|
|
80
84
|
installErrorText: LocalizedText | null;
|
|
81
85
|
installHold: BridgeInstallHold | null;
|
|
@@ -92,10 +96,20 @@ export interface BridgeAnnounceResult {
|
|
|
92
96
|
delivered: boolean;
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
export interface BridgeBackupSnapshot {
|
|
100
|
+
roots: string[];
|
|
101
|
+
files: Array<{
|
|
102
|
+
path: string;
|
|
103
|
+
size: number;
|
|
104
|
+
}>;
|
|
105
|
+
}
|
|
106
|
+
|
|
95
107
|
export interface BridgeQuiesceResult {
|
|
108
|
+
leaseId?: string;
|
|
96
109
|
supported: boolean;
|
|
97
110
|
quiesced: boolean;
|
|
98
111
|
settleSeconds: number;
|
|
112
|
+
snapshot?: BridgeBackupSnapshot;
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
export interface BridgeDescribeResult {
|
package/src/protocol/index.ts
CHANGED
|
@@ -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
|
+
};
|