@narumitw/pi-sync 0.20.0 → 0.25.0
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 +5 -1
- package/src/config.ts +1 -49
- package/src/lock.ts +301 -0
- package/src/s3-client.ts +45 -8
- package/src/sync.ts +20 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,9 +28,13 @@
|
|
|
28
28
|
"format": "biome check --write .",
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"proper-lockfile": "^4.1.2"
|
|
33
|
+
},
|
|
31
34
|
"devDependencies": {
|
|
32
35
|
"@biomejs/biome": "2.5.3",
|
|
33
36
|
"@earendil-works/pi-coding-agent": "0.80.10",
|
|
37
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
34
38
|
"typescript": "7.0.2"
|
|
35
39
|
},
|
|
36
40
|
"repository": {
|
package/src/config.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import type { Dirent } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
import { isDeniedPath, safeName } from "./paths.js";
|
|
8
|
-
import type {
|
|
7
|
+
import type { PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
|
|
9
8
|
|
|
10
9
|
const VERSION = 1;
|
|
11
10
|
const DEFAULT_PROFILE = "default";
|
|
12
11
|
const DEFAULT_PREFIX = "pi-sync";
|
|
13
12
|
const DEFAULT_REGION = "auto";
|
|
14
|
-
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
15
13
|
const TOP_LEVEL_FILES = new Set(["settings.json", "keybindings.json", "models.json", "AGENTS.md", "APPEND_SYSTEM.md"]);
|
|
16
14
|
const TOP_LEVEL_FILE_NAMES = new Set([...TOP_LEVEL_FILES].map((name) => name.toLowerCase()));
|
|
17
15
|
const TOP_LEVEL_DIRS = new Set(["skills", "prompts", "themes", "extensions"]);
|
|
@@ -42,37 +40,6 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
|
|
|
42
40
|
: undefined;
|
|
43
41
|
}
|
|
44
42
|
|
|
45
|
-
export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
|
|
46
|
-
await ensureStateDir();
|
|
47
|
-
const lock: LockFile = {
|
|
48
|
-
id: randomUUID(),
|
|
49
|
-
pid: process.pid,
|
|
50
|
-
command,
|
|
51
|
-
startedAt: new Date().toISOString(),
|
|
52
|
-
};
|
|
53
|
-
let handle: fs.FileHandle | undefined;
|
|
54
|
-
try {
|
|
55
|
-
handle = await fs.open(lockPath(), "wx");
|
|
56
|
-
await handle.writeFile(JSON.stringify(lock, null, "\t"));
|
|
57
|
-
await handle.close();
|
|
58
|
-
handle = undefined;
|
|
59
|
-
return await fn();
|
|
60
|
-
} catch (error) {
|
|
61
|
-
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
62
|
-
const current = await readLock();
|
|
63
|
-
if (current && isStaleLock(current)) {
|
|
64
|
-
throw new Error(`pi-sync lock is stale (pid ${current.pid}). Run /pisync unlock --stale, then retry.`);
|
|
65
|
-
}
|
|
66
|
-
throw new Error(`pi-sync is already running${current ? ` (${current.command}, pid ${current.pid}, started ${current.startedAt})` : ""}.`);
|
|
67
|
-
}
|
|
68
|
-
throw error;
|
|
69
|
-
} finally {
|
|
70
|
-
await handle?.close();
|
|
71
|
-
const current = await readLock();
|
|
72
|
-
if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
43
|
export async function loadConfigInternal(): Promise<SyncConfig> {
|
|
77
44
|
const partial = await loadPartialConfig();
|
|
78
45
|
const endpoint = partial.endpoint;
|
|
@@ -204,21 +171,6 @@ export async function ensureStateDir() {
|
|
|
204
171
|
await fs.mkdir(stateDir(), { recursive: true });
|
|
205
172
|
}
|
|
206
173
|
|
|
207
|
-
export async function readLock() {
|
|
208
|
-
return readJsonIfExists<LockFile>(lockPath());
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function isStaleLock(lock: LockFile) {
|
|
212
|
-
if (!Number.isInteger(lock.pid) || lock.pid <= 0) return true;
|
|
213
|
-
try {
|
|
214
|
-
process.kill(lock.pid, 0);
|
|
215
|
-
return false;
|
|
216
|
-
} catch (error) {
|
|
217
|
-
if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
|
|
218
|
-
return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
174
|
async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
|
|
223
175
|
try {
|
|
224
176
|
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
realpath,
|
|
6
|
+
realpathSync,
|
|
7
|
+
rmdir,
|
|
8
|
+
rmdirSync,
|
|
9
|
+
stat,
|
|
10
|
+
statSync,
|
|
11
|
+
utimes,
|
|
12
|
+
utimesSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import lockfile from "proper-lockfile";
|
|
17
|
+
import { ensureStateDir, lockPath } from "./config.js";
|
|
18
|
+
import type { CommandOptions, LockFile } from "./types.js";
|
|
19
|
+
|
|
20
|
+
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
21
|
+
const GUARD_STALE_MS = 30_000;
|
|
22
|
+
const GUARD_UPDATE_MS = 10_000;
|
|
23
|
+
const MAX_PROCESS_ID = 2_147_483_647;
|
|
24
|
+
|
|
25
|
+
type LockfileFsAdapter = {
|
|
26
|
+
mkdir: typeof mkdir;
|
|
27
|
+
mkdirSync: typeof mkdirSync;
|
|
28
|
+
realpath: typeof realpath;
|
|
29
|
+
realpathSync: typeof realpathSync;
|
|
30
|
+
rmdir: typeof rmdir;
|
|
31
|
+
rmdirSync: typeof rmdirSync;
|
|
32
|
+
stat: typeof stat;
|
|
33
|
+
statSync: typeof statSync;
|
|
34
|
+
utimes: typeof utimes;
|
|
35
|
+
utimesSync: typeof utimesSync;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const LOCKFILE_FS_ADAPTER: LockfileFsAdapter = {
|
|
39
|
+
mkdir,
|
|
40
|
+
mkdirSync,
|
|
41
|
+
realpath,
|
|
42
|
+
realpathSync,
|
|
43
|
+
rmdir,
|
|
44
|
+
rmdirSync,
|
|
45
|
+
stat,
|
|
46
|
+
statSync,
|
|
47
|
+
utimes,
|
|
48
|
+
utimesSync,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
interface Guard {
|
|
52
|
+
release: () => Promise<void>;
|
|
53
|
+
throwIfCompromised: () => void;
|
|
54
|
+
isCompromised: () => boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type LockInspection =
|
|
58
|
+
| { status: "missing" }
|
|
59
|
+
| { status: "unreadable" }
|
|
60
|
+
| { status: "valid"; lock: LockFile };
|
|
61
|
+
|
|
62
|
+
export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
|
|
63
|
+
await ensureStateDir();
|
|
64
|
+
const lock: LockFile = {
|
|
65
|
+
id: randomUUID(),
|
|
66
|
+
pid: process.pid,
|
|
67
|
+
command,
|
|
68
|
+
startedAt: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
let guard: Guard | undefined;
|
|
71
|
+
let result: T | undefined;
|
|
72
|
+
let failed = false;
|
|
73
|
+
let failure: unknown;
|
|
74
|
+
try {
|
|
75
|
+
try {
|
|
76
|
+
guard = await acquireGuard();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!isLockHeldError(error)) throw error;
|
|
79
|
+
throw await describeHeldLock();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const inspection = await inspectLock();
|
|
83
|
+
if (inspection.status === "valid" && isStaleLock(inspection.lock)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`pi-sync lock is stale (pid ${inspection.lock.pid}). Run /pisync unlock --stale, then retry.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (inspection.status === "valid") {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`pi-sync is already running (${inspection.lock.command}, pid ${inspection.lock.pid}, started ${inspection.lock.startedAt}).`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (inspection.status === "unreadable") {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"pi-sync lock metadata is unreadable. Run /pisync unlock --stale after verifying no sync is running.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await fs.writeFile(lockPath(), JSON.stringify(lock, null, "\t"), { flag: "wx" });
|
|
100
|
+
guard.throwIfCompromised();
|
|
101
|
+
result = await fn();
|
|
102
|
+
guard.throwIfCompromised();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
failed = true;
|
|
105
|
+
failure = error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const current = await readLock();
|
|
110
|
+
if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!failed) {
|
|
113
|
+
failed = true;
|
|
114
|
+
failure = error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (guard) {
|
|
118
|
+
const releaseError = await releaseGuard(guard);
|
|
119
|
+
if (releaseError && !failed) {
|
|
120
|
+
failed = true;
|
|
121
|
+
failure = releaseError;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (failed) throw failure;
|
|
125
|
+
return result as T;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function inspectLock(): Promise<LockInspection> {
|
|
129
|
+
try {
|
|
130
|
+
const text = await fs.readFile(lockPath(), "utf8");
|
|
131
|
+
if (text.trim().length === 0) return { status: "unreadable" };
|
|
132
|
+
const parsed = JSON.parse(text) as unknown;
|
|
133
|
+
return isLockFile(parsed) ? { status: "valid", lock: parsed } : { status: "unreadable" };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" };
|
|
136
|
+
if (error instanceof SyntaxError) return { status: "unreadable" };
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function readLock(): Promise<LockFile | undefined> {
|
|
142
|
+
const inspection = await inspectLock();
|
|
143
|
+
return inspection.status === "valid" ? inspection.lock : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function lockFileExists(): Promise<boolean> {
|
|
147
|
+
try {
|
|
148
|
+
await fs.stat(lockPath());
|
|
149
|
+
return true;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isLockGuardHeld() {
|
|
157
|
+
return lockfile.check(lockPath(), {
|
|
158
|
+
fs: LOCKFILE_FS_ADAPTER,
|
|
159
|
+
lockfilePath: `${lockPath()}.guard`,
|
|
160
|
+
realpath: false,
|
|
161
|
+
stale: GUARD_STALE_MS,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function isStaleLock(lock: LockFile) {
|
|
166
|
+
try {
|
|
167
|
+
process.kill(lock.pid, 0);
|
|
168
|
+
return false;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
|
|
171
|
+
return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
176
|
+
await ensureStateDir();
|
|
177
|
+
let guard: Guard;
|
|
178
|
+
try {
|
|
179
|
+
guard = await acquireGuard();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!isLockHeldError(error)) throw error;
|
|
182
|
+
ctx.ui.notify((await describeHeldLock()).message, "warning");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let failed = false;
|
|
187
|
+
let failure: unknown;
|
|
188
|
+
try {
|
|
189
|
+
await unlockGuarded(ctx, options);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
failed = true;
|
|
192
|
+
failure = error;
|
|
193
|
+
}
|
|
194
|
+
const releaseError = await releaseGuard(guard);
|
|
195
|
+
if (releaseError && !failed) {
|
|
196
|
+
failed = true;
|
|
197
|
+
failure = releaseError;
|
|
198
|
+
}
|
|
199
|
+
if (failed) throw failure;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
203
|
+
let inspection = await inspectLock();
|
|
204
|
+
if (inspection.status === "missing") {
|
|
205
|
+
ctx.ui.notify("No pi-sync lock is present.", "info");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (inspection.status === "unreadable") {
|
|
209
|
+
if (!options.stale) {
|
|
210
|
+
ctx.ui.notify(
|
|
211
|
+
"Pi-sync lock metadata is unreadable. Use /pisync unlock --stale only after verifying no sync is running.",
|
|
212
|
+
"warning",
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
inspection = await inspectLock();
|
|
217
|
+
if (inspection.status === "unreadable") {
|
|
218
|
+
// Legacy writers expose an empty file before writing owner metadata, so no
|
|
219
|
+
// automatic test can prove this file is abandoned. The explicit --stale
|
|
220
|
+
// flag is the user's confirmation that no legacy sync is still running.
|
|
221
|
+
await fs.rm(lockPath(), { force: true });
|
|
222
|
+
ctx.ui.notify("Removed unreadable pi-sync lock.", "info");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (inspection.status === "missing") {
|
|
226
|
+
ctx.ui.notify("No pi-sync lock is present.", "info");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!isStaleLock(inspection.lock)) {
|
|
231
|
+
ctx.ui.notify("Lock owner is still live; refusing to remove it.", "warning");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await fs.rm(lockPath(), { force: true });
|
|
235
|
+
ctx.ui.notify("Removed stale pi-sync lock.", "info");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function releaseGuard(guard: Guard) {
|
|
239
|
+
try {
|
|
240
|
+
await guard.release();
|
|
241
|
+
return undefined;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return guard.isCompromised() ? undefined : error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function acquireGuard(): Promise<Guard> {
|
|
248
|
+
let compromisedError: Error | undefined;
|
|
249
|
+
const release = await lockfile.lock(lockPath(), {
|
|
250
|
+
fs: LOCKFILE_FS_ADAPTER,
|
|
251
|
+
lockfilePath: `${lockPath()}.guard`,
|
|
252
|
+
realpath: false,
|
|
253
|
+
stale: GUARD_STALE_MS,
|
|
254
|
+
update: GUARD_UPDATE_MS,
|
|
255
|
+
onCompromised: (error) => {
|
|
256
|
+
compromisedError = error;
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
return {
|
|
260
|
+
release,
|
|
261
|
+
throwIfCompromised: () => {
|
|
262
|
+
if (compromisedError) throw compromisedError;
|
|
263
|
+
},
|
|
264
|
+
isCompromised: () => compromisedError !== undefined,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function describeHeldLock() {
|
|
269
|
+
const current = await readLock();
|
|
270
|
+
if (current && isStaleLock(current)) {
|
|
271
|
+
return new Error("pi-sync lock owner exited; retry shortly while the lock guard expires.");
|
|
272
|
+
}
|
|
273
|
+
if (current) {
|
|
274
|
+
return new Error(
|
|
275
|
+
`Pi-sync is currently running (${current.command}, pid ${current.pid}, started ${current.startedAt}).`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return new Error(
|
|
279
|
+
"Pi-sync is currently running (lock metadata is unreadable or still being written).",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isLockHeldError(error: unknown) {
|
|
284
|
+
return (error as NodeJS.ErrnoException).code === "ELOCKED";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isLockFile(value: unknown): value is LockFile {
|
|
288
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
289
|
+
const lock = value as Partial<LockFile>;
|
|
290
|
+
return (
|
|
291
|
+
typeof lock.id === "string" &&
|
|
292
|
+
lock.id.length > 0 &&
|
|
293
|
+
Number.isInteger(lock.pid) &&
|
|
294
|
+
(lock.pid ?? 0) > 0 &&
|
|
295
|
+
(lock.pid ?? 0) <= MAX_PROCESS_ID &&
|
|
296
|
+
typeof lock.command === "string" &&
|
|
297
|
+
lock.command.length > 0 &&
|
|
298
|
+
typeof lock.startedAt === "string" &&
|
|
299
|
+
Number.isFinite(Date.parse(lock.startedAt))
|
|
300
|
+
);
|
|
301
|
+
}
|
package/src/s3-client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
3
|
import { encodeKey, posixJoin } from "./paths.js";
|
|
3
4
|
import type { LatestPointer, RemoteObject, Snapshot, SyncConfig } from "./types.js";
|
|
4
5
|
|
|
@@ -34,17 +35,53 @@ export class S3Client {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
async getJson<T>(key: string): Promise<RemoteObject<T>> {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const maxAttempts = 3;
|
|
39
|
+
let lastError: unknown;
|
|
40
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
41
|
+
const object = await this.request("GET", key);
|
|
42
|
+
if (object.status === 404) return { missing: true };
|
|
43
|
+
if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
|
|
44
|
+
const body = await object.text();
|
|
45
|
+
// R2 can intermittently return an empty 200 body (read-after-write
|
|
46
|
+
// inconsistency on a long-lived keep-alive connection), which makes
|
|
47
|
+
// response.json() throw "JSON Parse error: Unexpected EOF" under Bun.
|
|
48
|
+
// Retry so the transient blip is absorbed instead of surfacing as a
|
|
49
|
+
// "pi-sync auto sync skipped" warning on every session start.
|
|
50
|
+
if (body.length > 0) {
|
|
51
|
+
return { value: JSON.parse(body) as T, etag: normalizeEtag(object.headers.get("etag")), missing: false };
|
|
52
|
+
}
|
|
53
|
+
lastError = new Error(`S3 GET returned an empty body for ${key}`);
|
|
54
|
+
if (attempt < maxAttempts) {
|
|
55
|
+
await sleep(250 * attempt);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw lastError;
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
async getBuffer(key: string): Promise<RemoteObject<Buffer>> {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
const maxAttempts = 3;
|
|
63
|
+
let lastError: unknown;
|
|
64
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
65
|
+
const object = await this.request("GET", key);
|
|
66
|
+
if (object.status === 404) return { missing: true };
|
|
67
|
+
if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
|
|
68
|
+
const buffer = Buffer.from(await object.arrayBuffer());
|
|
69
|
+
// R2 can intermittently return an empty 200 body on a long-lived
|
|
70
|
+
// keep-alive connection (same root cause as the getJson retry
|
|
71
|
+
// above). getBuffer is only used for snapshot .json.gz payloads,
|
|
72
|
+
// which are always non-empty, so an empty body is a transient
|
|
73
|
+
// blip, not a legitimate response. Retry so the checksum guard
|
|
74
|
+
// downstream doesn't surface it as "Remote snapshot checksum
|
|
75
|
+
// mismatch".
|
|
76
|
+
if (buffer.length > 0) {
|
|
77
|
+
return { value: buffer, etag: normalizeEtag(object.headers.get("etag")), missing: false };
|
|
78
|
+
}
|
|
79
|
+
lastError = new Error(`S3 GET returned an empty body for ${key}`);
|
|
80
|
+
if (attempt < maxAttempts) {
|
|
81
|
+
await sleep(250 * attempt);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw lastError;
|
|
48
85
|
}
|
|
49
86
|
|
|
50
87
|
async putJson(key: string, value: unknown) {
|
package/src/sync.ts
CHANGED
|
@@ -12,12 +12,9 @@ import {
|
|
|
12
12
|
ensureStateDir,
|
|
13
13
|
extraFilePathsByLower,
|
|
14
14
|
isMissingConfigError,
|
|
15
|
-
isStaleLock,
|
|
16
15
|
loadPartialConfig,
|
|
17
16
|
localConfigPath,
|
|
18
|
-
lockPath,
|
|
19
17
|
normalizeExtraFiles,
|
|
20
|
-
readLock,
|
|
21
18
|
stateDir,
|
|
22
19
|
writeJson,
|
|
23
20
|
configuredSessionDir,
|
|
@@ -29,9 +26,9 @@ import {
|
|
|
29
26
|
sessionDirForApply,
|
|
30
27
|
sessionTokenWarnings,
|
|
31
28
|
syncSessionsWarnings,
|
|
32
|
-
withLock,
|
|
33
29
|
writeState,
|
|
34
30
|
} from "./config.js";
|
|
31
|
+
import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
|
|
35
32
|
import { encodeKey, posixJoin, safeJoin, safeName, toPosix } from "./paths.js";
|
|
36
33
|
import {
|
|
37
34
|
historyKey,
|
|
@@ -346,8 +343,25 @@ async function doctor(ctx: ExtensionCommandContext) {
|
|
|
346
343
|
messages.push(`secret scan: ok (${local.files.length} files checked)`);
|
|
347
344
|
}
|
|
348
345
|
|
|
349
|
-
const lock = await
|
|
350
|
-
|
|
346
|
+
const lock = await inspectLock();
|
|
347
|
+
if (lock.status === "valid" && isStaleLock(lock.lock)) {
|
|
348
|
+
level = "warning";
|
|
349
|
+
messages.push(
|
|
350
|
+
`lock: stale (pid ${lock.lock.pid}); run /pisync unlock after verifying no sync is running`,
|
|
351
|
+
);
|
|
352
|
+
} else if (lock.status === "valid") {
|
|
353
|
+
messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
|
|
354
|
+
} else if (lock.status === "unreadable") {
|
|
355
|
+
level = "warning";
|
|
356
|
+
messages.push(
|
|
357
|
+
"lock: unreadable; use /pisync unlock --stale only after verifying no sync is running",
|
|
358
|
+
);
|
|
359
|
+
} else if (await isLockGuardHeld()) {
|
|
360
|
+
level = "warning";
|
|
361
|
+
messages.push("lock: guard active while metadata is missing or still being initialized");
|
|
362
|
+
} else {
|
|
363
|
+
messages.push("lock: free");
|
|
364
|
+
}
|
|
351
365
|
ctx.ui.notify(messages.join("\n"), level);
|
|
352
366
|
}
|
|
353
367
|
|
|
@@ -619,20 +633,6 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
619
633
|
await maybeReload(ctx);
|
|
620
634
|
}
|
|
621
635
|
|
|
622
|
-
async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
623
|
-
const lock = await readLock();
|
|
624
|
-
if (!lock) {
|
|
625
|
-
ctx.ui.notify("No pi-sync lock is present.", "info");
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
if (!options.stale && !isStaleLock(lock)) {
|
|
629
|
-
ctx.ui.notify("Lock is not stale. Use /pisync unlock --stale only after verifying no sync is running.", "warning");
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
632
|
-
await fs.rm(lockPath(), { force: true });
|
|
633
|
-
ctx.ui.notify("Removed stale pi-sync lock.", "info");
|
|
634
|
-
}
|
|
635
|
-
|
|
636
636
|
function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
637
637
|
const getSessionFile = ctx.sessionManager.getSessionFile;
|
|
638
638
|
if (typeof getSessionFile !== "function") return new Set<string>();
|