@bermudi/pi-delegate 0.1.4 → 0.1.6
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/workspace.ts +400 -164
package/package.json
CHANGED
package/workspace.ts
CHANGED
|
@@ -3,7 +3,9 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { scheduleDeadline } from "./timer.ts";
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const SCRATCH_CONTAINER_NAME = ".pi-delegate-scratch";
|
|
7
|
+
const SCRATCH_LEASE_PREFIX = "lease-";
|
|
8
|
+
const SCRATCH_LEGACY_PREFIX = ".pi-delegate-scratch-";
|
|
7
9
|
const SCRATCH_TREE_NAME = "project";
|
|
8
10
|
const SCRATCH_OWNER_NAME = ".owner";
|
|
9
11
|
const COPY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -36,6 +38,8 @@ export class ScratchSetupError extends Error {}
|
|
|
36
38
|
|
|
37
39
|
export class ScratchDeadlineError extends ScratchSetupError {}
|
|
38
40
|
|
|
41
|
+
class ScratchLeaseIdentityError extends Error {}
|
|
42
|
+
|
|
39
43
|
class CommandError extends Error {
|
|
40
44
|
constructor(
|
|
41
45
|
message: string,
|
|
@@ -195,167 +199,381 @@ function isProcessAlive(pid: number): boolean {
|
|
|
195
199
|
}
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean {
|
|
203
|
+
// dev+ino alone can alias after an unlink+mkdir reuses the same inode
|
|
204
|
+
// (observed on ext4 in CI: project replaced in the sweep race test
|
|
205
|
+
// reused the previous ino). Birthtime distinguishes a recreated entry
|
|
206
|
+
// and is stable across the chmod 0500→0700 transitions that update
|
|
207
|
+
// ctime. Where birthtime is unavailable (0) we fall back to dev+ino.
|
|
208
|
+
if (left.dev !== right.dev || left.ino !== right.ino) return false;
|
|
209
|
+
if (left.birthtimeMs !== 0 || right.birthtimeMs !== 0) {
|
|
210
|
+
return left.birthtimeMs === right.birthtimeMs;
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseOwnerPid(content: string): number | undefined {
|
|
216
|
+
const value = content.trim();
|
|
217
|
+
if (!/^[1-9][0-9]*$/.test(value)) return undefined;
|
|
218
|
+
const pid = Number(value);
|
|
219
|
+
return Number.isSafeInteger(pid) ? pid : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type LeaseDeletionExpectations =
|
|
223
|
+
| {
|
|
224
|
+
hasProject: true;
|
|
225
|
+
lease: fs.Stats;
|
|
226
|
+
project: fs.Stats;
|
|
227
|
+
owner: fs.Stats;
|
|
228
|
+
}
|
|
229
|
+
| {
|
|
230
|
+
hasProject: false;
|
|
231
|
+
lease: fs.Stats;
|
|
232
|
+
owner: fs.Stats;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
async function deleteLeaseContentsAndRmdir(
|
|
236
|
+
parentHandle: fs.promises.FileHandle,
|
|
237
|
+
leaseName: string,
|
|
238
|
+
leaseHandle: fs.promises.FileHandle,
|
|
239
|
+
expectations: LeaseDeletionExpectations,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const leasePath = path.join(`/proc/self/fd/${parentHandle.fd}`, leaseName);
|
|
242
|
+
const openLeaseStat = await leaseHandle.stat();
|
|
243
|
+
const currentLeaseStat = await fs.promises.lstat(leasePath);
|
|
244
|
+
if (
|
|
245
|
+
!openLeaseStat.isDirectory() ||
|
|
246
|
+
!sameFileIdentity(openLeaseStat, expectations.lease) ||
|
|
247
|
+
!sameFileIdentity(currentLeaseStat, openLeaseStat)
|
|
248
|
+
) {
|
|
249
|
+
throw new ScratchLeaseIdentityError(
|
|
250
|
+
"Scratch lease was moved or replaced; refusing cleanup.",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await leaseHandle.chmod(0o700);
|
|
255
|
+
const ownerPath = path.join(
|
|
256
|
+
`/proc/self/fd/${leaseHandle.fd}`,
|
|
257
|
+
SCRATCH_OWNER_NAME,
|
|
258
|
+
);
|
|
259
|
+
const initialOwnerStat = await fs.promises.lstat(ownerPath);
|
|
260
|
+
if (!sameFileIdentity(initialOwnerStat, expectations.owner)) {
|
|
261
|
+
throw new ScratchLeaseIdentityError(
|
|
262
|
+
"Scratch owner marker was replaced; refusing cleanup.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (expectations.hasProject) {
|
|
267
|
+
const projectPath = path.join(
|
|
268
|
+
`/proc/self/fd/${leaseHandle.fd}`,
|
|
269
|
+
SCRATCH_TREE_NAME,
|
|
270
|
+
);
|
|
271
|
+
const projectHandle = await fs.promises.open(
|
|
272
|
+
projectPath,
|
|
273
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
274
|
+
);
|
|
275
|
+
try {
|
|
276
|
+
const openProjectStat = await projectHandle.stat();
|
|
277
|
+
const currentProjectStat = await fs.promises.lstat(projectPath);
|
|
278
|
+
if (
|
|
279
|
+
!openProjectStat.isDirectory() ||
|
|
280
|
+
!sameFileIdentity(openProjectStat, expectations.project) ||
|
|
281
|
+
!sameFileIdentity(currentProjectStat, openProjectStat)
|
|
282
|
+
) {
|
|
283
|
+
throw new ScratchLeaseIdentityError(
|
|
284
|
+
"Scratch project was moved or replaced; refusing cleanup.",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Remove children through the opened project directory, not the project
|
|
289
|
+
// pathname. This means a replacement at `project` is never recursively
|
|
290
|
+
// traversed. The final rmdir is still a pathname operation; the identity
|
|
291
|
+
// is checked again immediately beforehand, so this is fail-closed for
|
|
292
|
+
// the deterministic replacement races we can observe, not an atomic
|
|
293
|
+
// guarantee against a cooperating same-user process.
|
|
294
|
+
for (const name of await fs.promises.readdir(
|
|
295
|
+
`/proc/self/fd/${projectHandle.fd}`,
|
|
296
|
+
)) {
|
|
297
|
+
await fs.promises.rm(
|
|
298
|
+
path.join(`/proc/self/fd/${projectHandle.fd}`, name),
|
|
299
|
+
{ recursive: true, force: false },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const finalProjectStat = await fs.promises.lstat(projectPath);
|
|
303
|
+
if (!sameFileIdentity(finalProjectStat, openProjectStat)) {
|
|
304
|
+
throw new ScratchLeaseIdentityError(
|
|
305
|
+
"Scratch project was moved or replaced; refusing cleanup.",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
await fs.promises.rmdir(projectPath);
|
|
309
|
+
} finally {
|
|
310
|
+
await projectHandle.close();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const currentOwnerStat = await fs.promises.lstat(ownerPath);
|
|
315
|
+
if (!sameFileIdentity(currentOwnerStat, expectations.owner)) {
|
|
316
|
+
throw new ScratchLeaseIdentityError(
|
|
317
|
+
"Scratch owner marker was replaced; refusing cleanup.",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
await fs.promises.rm(ownerPath, { force: false });
|
|
321
|
+
|
|
322
|
+
const finalLeaseStat = await fs.promises.lstat(leasePath);
|
|
323
|
+
if (!sameFileIdentity(finalLeaseStat, openLeaseStat)) {
|
|
324
|
+
throw new ScratchLeaseIdentityError(
|
|
325
|
+
"Scratch lease was moved or replaced; refusing cleanup.",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
await fs.promises.rmdir(leasePath);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function ensureScratchContainer(
|
|
332
|
+
containerDir: string,
|
|
333
|
+
uid: number | undefined,
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
try {
|
|
336
|
+
await fs.promises.mkdir(containerDir, { mode: 0o700 });
|
|
337
|
+
await fs.promises.chmod(containerDir, 0o700);
|
|
338
|
+
return;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (!(
|
|
341
|
+
error instanceof Error &&
|
|
342
|
+
"code" in error &&
|
|
343
|
+
error.code === "EEXIST"
|
|
344
|
+
)) {
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const stat = await fs.promises.lstat(containerDir);
|
|
350
|
+
if (!stat.isDirectory() || (uid !== undefined && stat.uid !== uid)) {
|
|
351
|
+
throw new ScratchSetupError(
|
|
352
|
+
`Scratch container directory '${containerDir}' is not a directory owned by the current user.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if ((stat.mode & 0o7777) !== 0o700) {
|
|
356
|
+
await fs.promises.chmod(containerDir, 0o700);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
interface SweepOptions {
|
|
361
|
+
prefix?: string;
|
|
362
|
+
onLeaseOpened?: (leaseName: string, leaseFd: number) => Promise<void> | void;
|
|
363
|
+
onLeaseValidated?: (leaseName: string) => Promise<void> | void;
|
|
364
|
+
}
|
|
365
|
+
|
|
198
366
|
/** Remove leases left behind by a process that is no longer running.
|
|
199
367
|
*
|
|
200
368
|
* The owner marker distinguishes our leases from unrelated prefix-matching
|
|
201
|
-
* directories. Live owners are never touched.
|
|
202
|
-
*
|
|
203
|
-
*
|
|
369
|
+
* directories. Live owners are never touched. Descriptors make the scan
|
|
370
|
+
* independent of a renamed parent, while pathname identity checks ensure that
|
|
371
|
+
* a lease renamed or replaced after it was opened is left alone. These checks
|
|
372
|
+
* are snapshots rather than an atomic cross-process locking primitive.
|
|
204
373
|
*/
|
|
205
|
-
async function sweepStaleScratchLeases(
|
|
374
|
+
async function sweepStaleScratchLeases(
|
|
375
|
+
container: string,
|
|
376
|
+
options: SweepOptions = {},
|
|
377
|
+
): Promise<void> {
|
|
206
378
|
const uid = process.getuid?.();
|
|
207
379
|
if (uid === undefined) return;
|
|
208
380
|
|
|
209
|
-
let
|
|
381
|
+
let parentHandle: fs.promises.FileHandle;
|
|
210
382
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
383
|
+
parentHandle = await fs.promises.open(
|
|
384
|
+
container,
|
|
385
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
386
|
+
);
|
|
387
|
+
} catch {
|
|
214
388
|
return;
|
|
215
389
|
}
|
|
216
390
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
391
|
+
try {
|
|
392
|
+
const parentStat = await parentHandle.stat();
|
|
393
|
+
if (!parentStat.isDirectory() || parentStat.uid !== uid) return;
|
|
394
|
+
|
|
395
|
+
let entries: fs.Dirent[];
|
|
222
396
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
const ownerPath = path.join(leaseRoot, SCRATCH_OWNER_NAME);
|
|
233
|
-
const ownerStat = await fs.promises.lstat(ownerPath);
|
|
234
|
-
if (
|
|
235
|
-
!ownerStat.isFile() ||
|
|
236
|
-
ownerStat.uid !== uid ||
|
|
237
|
-
ownerStat.mode & 0o077
|
|
238
|
-
) {
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
const pid = Number.parseInt(
|
|
242
|
-
(await fs.promises.readFile(ownerPath, "utf8")).trim(),
|
|
243
|
-
10,
|
|
244
|
-
);
|
|
245
|
-
if (!Number.isSafeInteger(pid) || isProcessAlive(pid)) {
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
397
|
+
entries = await fs.promises.readdir(`/proc/self/fd/${parentHandle.fd}`, {
|
|
398
|
+
withFileTypes: true,
|
|
399
|
+
});
|
|
400
|
+
} catch (error) {
|
|
401
|
+
console.error("[delegate] scratch lease sweep failed", error);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
248
404
|
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
(name) => name !== SCRATCH_OWNER_NAME && name !== SCRATCH_TREE_NAME,
|
|
253
|
-
)
|
|
254
|
-
) {
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
let projectStat: fs.Stats | undefined;
|
|
258
|
-
try {
|
|
259
|
-
projectStat = await fs.promises.lstat(projectRoot);
|
|
260
|
-
if (!projectStat.isDirectory()) continue;
|
|
261
|
-
} catch (error) {
|
|
262
|
-
if (!(
|
|
263
|
-
error instanceof Error &&
|
|
264
|
-
"code" in error &&
|
|
265
|
-
error.code === "ENOENT"
|
|
266
|
-
)) {
|
|
267
|
-
throw error;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
405
|
+
for (const entry of entries) {
|
|
406
|
+
if (!entry.isDirectory()) continue;
|
|
407
|
+
if (options.prefix && !entry.name.startsWith(options.prefix)) continue;
|
|
270
408
|
|
|
271
|
-
|
|
272
|
-
// same identity checks as normal cleanup against the directory found by
|
|
273
|
-
// the initial scan, rather than trusting a pathname that may be replaced.
|
|
274
|
-
const parentHandle = await fs.promises.open(
|
|
275
|
-
parent,
|
|
276
|
-
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
277
|
-
);
|
|
278
|
-
let leaseHandle: Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
279
|
-
let projectHandle:
|
|
280
|
-
Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
409
|
+
let leaseHandle: fs.promises.FileHandle | undefined;
|
|
281
410
|
try {
|
|
411
|
+
const leasePath = path.join(
|
|
412
|
+
`/proc/self/fd/${parentHandle.fd}`,
|
|
413
|
+
entry.name,
|
|
414
|
+
);
|
|
282
415
|
leaseHandle = await fs.promises.open(
|
|
283
|
-
|
|
416
|
+
leasePath,
|
|
284
417
|
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
285
418
|
);
|
|
286
|
-
const
|
|
287
|
-
const
|
|
419
|
+
const openedLeaseStat = await leaseHandle.stat();
|
|
420
|
+
const scannedLeaseStat = await fs.promises.lstat(leasePath);
|
|
288
421
|
if (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
openLeaseStat.ino !== leaseStat.ino
|
|
422
|
+
!openedLeaseStat.isDirectory() ||
|
|
423
|
+
openedLeaseStat.uid !== uid ||
|
|
424
|
+
!sameFileIdentity(scannedLeaseStat, openedLeaseStat)
|
|
293
425
|
) {
|
|
294
426
|
continue;
|
|
295
427
|
}
|
|
296
|
-
|
|
428
|
+
|
|
429
|
+
// Snapshot the identities before the test hook / concurrent work. If
|
|
430
|
+
// either pathname changes, the opened descriptor is not used for
|
|
431
|
+
// deletion. In particular, a rename must not turn this into cleanup of
|
|
432
|
+
// a lease that merely moved elsewhere.
|
|
433
|
+
const ownerPath = path.join(
|
|
297
434
|
`/proc/self/fd/${leaseHandle.fd}`,
|
|
298
435
|
SCRATCH_OWNER_NAME,
|
|
299
436
|
);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
437
|
+
let scannedOwnerStat: fs.Stats;
|
|
438
|
+
try {
|
|
439
|
+
scannedOwnerStat = await fs.promises.lstat(ownerPath);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (
|
|
442
|
+
error instanceof Error &&
|
|
443
|
+
"code" in error &&
|
|
444
|
+
error.code === "ENOENT"
|
|
445
|
+
) {
|
|
446
|
+
// Leases from versions without an owner marker, or partial leases
|
|
447
|
+
// from a crash between mkdtemp and the marker write: reclaim only
|
|
448
|
+
// when empty. Anything else may be an unrelated directory. The
|
|
449
|
+
// identity re-check keeps the rmdir anchored to the scanned lease.
|
|
450
|
+
const contents = await fs.promises.readdir(
|
|
451
|
+
`/proc/self/fd/${leaseHandle.fd}`,
|
|
452
|
+
);
|
|
453
|
+
if (contents.length === 0) {
|
|
454
|
+
const currentLeaseStat = await fs.promises.lstat(leasePath);
|
|
455
|
+
if (sameFileIdentity(currentLeaseStat, scannedLeaseStat)) {
|
|
456
|
+
await fs.promises.rmdir(leasePath);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
let scannedProjectStat: fs.Stats | undefined;
|
|
464
|
+
try {
|
|
465
|
+
scannedProjectStat = await fs.promises.lstat(
|
|
466
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
467
|
+
);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (!(
|
|
470
|
+
error instanceof Error &&
|
|
471
|
+
"code" in error &&
|
|
472
|
+
error.code === "ENOENT"
|
|
473
|
+
)) {
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (options.onLeaseOpened) {
|
|
479
|
+
await options.onLeaseOpened(entry.name, leaseHandle.fd);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const currentLeaseStat = await fs.promises.lstat(leasePath);
|
|
483
|
+
if (!sameFileIdentity(currentLeaseStat, scannedLeaseStat)) continue;
|
|
484
|
+
const currentOwnerStat = await fs.promises.lstat(ownerPath);
|
|
485
|
+
if (!sameFileIdentity(currentOwnerStat, scannedOwnerStat)) continue;
|
|
486
|
+
if (scannedProjectStat) {
|
|
487
|
+
const currentProjectStat = await fs.promises.lstat(
|
|
488
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
489
|
+
);
|
|
490
|
+
if (!sameFileIdentity(currentProjectStat, scannedProjectStat)) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
305
495
|
if (
|
|
306
496
|
!currentOwnerStat.isFile() ||
|
|
307
497
|
currentOwnerStat.uid !== uid ||
|
|
308
|
-
currentOwnerStat.
|
|
309
|
-
currentOwnerStat.ino !== ownerStat.ino ||
|
|
310
|
-
!Number.isSafeInteger(currentPid) ||
|
|
311
|
-
isProcessAlive(currentPid)
|
|
498
|
+
(currentOwnerStat.mode & 0o077) !== 0
|
|
312
499
|
) {
|
|
313
500
|
continue;
|
|
314
501
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
502
|
+
|
|
503
|
+
const ownerContent = await fs.promises.readFile(ownerPath, "utf8");
|
|
504
|
+
const pid = parseOwnerPid(ownerContent);
|
|
505
|
+
if (pid === undefined || isProcessAlive(pid)) continue;
|
|
506
|
+
|
|
507
|
+
const contents = await fs.promises.readdir(
|
|
508
|
+
`/proc/self/fd/${leaseHandle.fd}`,
|
|
509
|
+
);
|
|
510
|
+
if (
|
|
511
|
+
contents.some(
|
|
512
|
+
(name) => name !== SCRATCH_OWNER_NAME && name !== SCRATCH_TREE_NAME,
|
|
513
|
+
)
|
|
514
|
+
) {
|
|
515
|
+
continue;
|
|
327
516
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
517
|
+
|
|
518
|
+
const hasProject = contents.includes(SCRATCH_TREE_NAME);
|
|
519
|
+
if (
|
|
520
|
+
hasProject &&
|
|
521
|
+
(!scannedProjectStat ||
|
|
522
|
+
!scannedProjectStat.isDirectory() ||
|
|
523
|
+
scannedProjectStat.isSymbolicLink())
|
|
524
|
+
) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (options.onLeaseValidated) {
|
|
529
|
+
await options.onLeaseValidated(entry.name);
|
|
530
|
+
}
|
|
531
|
+
if (hasProject) {
|
|
532
|
+
if (!scannedProjectStat) continue;
|
|
533
|
+
await deleteLeaseContentsAndRmdir(
|
|
534
|
+
parentHandle,
|
|
535
|
+
entry.name,
|
|
536
|
+
leaseHandle,
|
|
537
|
+
{
|
|
538
|
+
hasProject: true,
|
|
539
|
+
lease: scannedLeaseStat,
|
|
540
|
+
project: scannedProjectStat,
|
|
541
|
+
owner: scannedOwnerStat,
|
|
542
|
+
},
|
|
333
543
|
);
|
|
544
|
+
} else {
|
|
545
|
+
await deleteLeaseContentsAndRmdir(
|
|
546
|
+
parentHandle,
|
|
547
|
+
entry.name,
|
|
548
|
+
leaseHandle,
|
|
549
|
+
{
|
|
550
|
+
hasProject: false,
|
|
551
|
+
lease: scannedLeaseStat,
|
|
552
|
+
owner: scannedOwnerStat,
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (error instanceof ScratchLeaseIdentityError) continue;
|
|
558
|
+
if (
|
|
559
|
+
error instanceof Error &&
|
|
560
|
+
"code" in error &&
|
|
561
|
+
(error.code === "ENOENT" ||
|
|
562
|
+
error.code === "ENOTDIR" ||
|
|
563
|
+
error.code === "ENOTEMPTY")
|
|
564
|
+
) {
|
|
565
|
+
continue;
|
|
334
566
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
567
|
+
console.error(
|
|
568
|
+
`[delegate] failed to sweep stale scratch lease '${entry.name}'`,
|
|
569
|
+
error,
|
|
338
570
|
);
|
|
339
571
|
} finally {
|
|
340
|
-
await projectHandle?.close();
|
|
341
572
|
await leaseHandle?.close();
|
|
342
|
-
await parentHandle.close();
|
|
343
|
-
}
|
|
344
|
-
} catch (error) {
|
|
345
|
-
if (
|
|
346
|
-
error instanceof Error &&
|
|
347
|
-
"code" in error &&
|
|
348
|
-
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
349
|
-
) {
|
|
350
|
-
continue;
|
|
351
573
|
}
|
|
352
|
-
// A concurrent creator/remover can legitimately win this race. Other
|
|
353
|
-
// failures are still reported, but must not block a new scratch task.
|
|
354
|
-
console.error(
|
|
355
|
-
`[delegate] failed to sweep stale scratch lease '${leaseRoot}'`,
|
|
356
|
-
error,
|
|
357
|
-
);
|
|
358
574
|
}
|
|
575
|
+
} finally {
|
|
576
|
+
await parentHandle.close();
|
|
359
577
|
}
|
|
360
578
|
}
|
|
361
579
|
|
|
@@ -392,10 +610,12 @@ export async function createScratchWorkspace(
|
|
|
392
610
|
|
|
393
611
|
let sourceCwd: string;
|
|
394
612
|
let sourceRoot: string;
|
|
613
|
+
let containerDir: string;
|
|
395
614
|
let leaseRoot: string | undefined;
|
|
396
615
|
let scratchRoot: string | undefined;
|
|
397
616
|
let copiedLeaseStat: fs.Stats | undefined;
|
|
398
617
|
let copiedRootStat: fs.Stats | undefined;
|
|
618
|
+
let copiedOwnerStat: fs.Stats | undefined;
|
|
399
619
|
try {
|
|
400
620
|
if (signal?.aborted) controller.abort(signal.reason);
|
|
401
621
|
throwIfSetupCancelled(controller.signal, signal);
|
|
@@ -409,9 +629,18 @@ export async function createScratchWorkspace(
|
|
|
409
629
|
);
|
|
410
630
|
}
|
|
411
631
|
|
|
412
|
-
|
|
632
|
+
containerDir = path.join(path.dirname(sourceRoot), SCRATCH_CONTAINER_NAME);
|
|
633
|
+
const uid = process.getuid?.();
|
|
634
|
+
await ensureScratchContainer(containerDir, uid);
|
|
635
|
+
if (uid !== undefined) {
|
|
636
|
+
await sweepStaleScratchLeases(containerDir);
|
|
637
|
+
await sweepStaleScratchLeases(path.dirname(sourceRoot), {
|
|
638
|
+
prefix: SCRATCH_LEGACY_PREFIX,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
413
642
|
leaseRoot = await fs.promises.mkdtemp(
|
|
414
|
-
path.join(
|
|
643
|
+
path.join(containerDir, SCRATCH_LEASE_PREFIX),
|
|
415
644
|
);
|
|
416
645
|
await fs.promises.chmod(leaseRoot, 0o700);
|
|
417
646
|
await fs.promises.writeFile(
|
|
@@ -494,6 +723,9 @@ export async function createScratchWorkspace(
|
|
|
494
723
|
// fails, the catch below restores permissions and removes the partial copy.
|
|
495
724
|
copiedLeaseStat = await fs.promises.lstat(leaseRoot);
|
|
496
725
|
copiedRootStat = await fs.promises.lstat(scratchRoot);
|
|
726
|
+
copiedOwnerStat = await fs.promises.lstat(
|
|
727
|
+
path.join(leaseRoot, SCRATCH_OWNER_NAME),
|
|
728
|
+
);
|
|
497
729
|
} catch (error) {
|
|
498
730
|
if (leaseRoot) {
|
|
499
731
|
try {
|
|
@@ -532,6 +764,7 @@ export async function createScratchWorkspace(
|
|
|
532
764
|
const completedRoot = scratchRoot!;
|
|
533
765
|
const completedLeaseStat = copiedLeaseStat!;
|
|
534
766
|
const completedRootStat = copiedRootStat!;
|
|
767
|
+
const completedOwnerStat = copiedOwnerStat!;
|
|
535
768
|
const relativeCwd = path.relative(sourceRoot!, sourceCwd!);
|
|
536
769
|
let cleaned = false;
|
|
537
770
|
const resolveReportedPath = async (candidate: string): Promise<string> => {
|
|
@@ -580,8 +813,8 @@ export async function createScratchWorkspace(
|
|
|
580
813
|
if (cleaned) return;
|
|
581
814
|
try {
|
|
582
815
|
if (
|
|
583
|
-
path.dirname(completedLeaseRoot) !==
|
|
584
|
-
!path.basename(completedLeaseRoot).startsWith(
|
|
816
|
+
path.dirname(completedLeaseRoot) !== containerDir ||
|
|
817
|
+
!path.basename(completedLeaseRoot).startsWith(SCRATCH_LEASE_PREFIX) ||
|
|
585
818
|
path.dirname(completedRoot) !== completedLeaseRoot ||
|
|
586
819
|
path.basename(completedRoot) !== SCRATCH_TREE_NAME
|
|
587
820
|
) {
|
|
@@ -593,7 +826,7 @@ export async function createScratchWorkspace(
|
|
|
593
826
|
// descriptor identifies the checked directory even if its pathname is
|
|
594
827
|
// renamed or replaced while cleanup is running.
|
|
595
828
|
const parentHandle = await fs.promises.open(
|
|
596
|
-
|
|
829
|
+
containerDir,
|
|
597
830
|
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
598
831
|
);
|
|
599
832
|
let leaseHandle:
|
|
@@ -601,59 +834,51 @@ export async function createScratchWorkspace(
|
|
|
601
834
|
let rootHandle:
|
|
602
835
|
Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
603
836
|
try {
|
|
837
|
+
const leaseName = path.basename(completedLeaseRoot);
|
|
604
838
|
leaseHandle = await fs.promises.open(
|
|
605
|
-
path.join(
|
|
606
|
-
`/proc/self/fd/${parentHandle.fd}`,
|
|
607
|
-
path.basename(completedLeaseRoot),
|
|
608
|
-
),
|
|
839
|
+
path.join(`/proc/self/fd/${parentHandle.fd}`, leaseName),
|
|
609
840
|
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
610
841
|
);
|
|
611
842
|
rootHandle = await fs.promises.open(
|
|
612
843
|
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
613
844
|
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
614
845
|
);
|
|
615
|
-
const currentLeaseStat = await fs.promises.lstat(completedLeaseRoot);
|
|
616
|
-
const currentRootStat = await fs.promises.lstat(completedRoot);
|
|
617
846
|
const openLeaseStat = await leaseHandle.stat();
|
|
618
847
|
const openRootStat = await rootHandle.stat();
|
|
848
|
+
const currentLeaseStat = await fs.promises.lstat(
|
|
849
|
+
path.join(`/proc/self/fd/${parentHandle.fd}`, leaseName),
|
|
850
|
+
);
|
|
851
|
+
const currentRootStat = await fs.promises.lstat(
|
|
852
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
853
|
+
);
|
|
619
854
|
if (
|
|
620
|
-
!currentLeaseStat.isDirectory() ||
|
|
621
|
-
currentLeaseStat.dev !== completedLeaseStat.dev ||
|
|
622
|
-
currentLeaseStat.ino !== completedLeaseStat.ino ||
|
|
623
|
-
!currentRootStat.isDirectory() ||
|
|
624
|
-
currentRootStat.dev !== completedRootStat.dev ||
|
|
625
|
-
currentRootStat.ino !== completedRootStat.ino ||
|
|
626
855
|
!openLeaseStat.isDirectory() ||
|
|
627
|
-
openLeaseStat
|
|
628
|
-
|
|
856
|
+
!sameFileIdentity(openLeaseStat, completedLeaseStat) ||
|
|
857
|
+
!sameFileIdentity(currentLeaseStat, completedLeaseStat) ||
|
|
629
858
|
!openRootStat.isDirectory() ||
|
|
630
|
-
openRootStat
|
|
631
|
-
|
|
859
|
+
!sameFileIdentity(openRootStat, completedRootStat) ||
|
|
860
|
+
!sameFileIdentity(currentRootStat, completedRootStat)
|
|
632
861
|
) {
|
|
633
862
|
throw new Error(
|
|
634
863
|
"Scratch workspace root was moved or replaced; refusing to report cleanup success.",
|
|
635
864
|
);
|
|
636
865
|
}
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
path.join(
|
|
654
|
-
`/proc/self/fd/${parentHandle.fd}`,
|
|
655
|
-
path.basename(completedLeaseRoot),
|
|
656
|
-
),
|
|
866
|
+
|
|
867
|
+
// The identity checks are snapshots. The primitive repeats them and
|
|
868
|
+
// removes project children through its opened descriptor, so a
|
|
869
|
+
// replacement observed before removal is preserved. This is not an
|
|
870
|
+
// atomic guarantee against a cooperating process changing the path
|
|
871
|
+
// after the final check.
|
|
872
|
+
await deleteLeaseContentsAndRmdir(
|
|
873
|
+
parentHandle,
|
|
874
|
+
leaseName,
|
|
875
|
+
leaseHandle,
|
|
876
|
+
{
|
|
877
|
+
hasProject: true,
|
|
878
|
+
lease: completedLeaseStat,
|
|
879
|
+
project: completedRootStat,
|
|
880
|
+
owner: completedOwnerStat,
|
|
881
|
+
},
|
|
657
882
|
);
|
|
658
883
|
cleaned = true;
|
|
659
884
|
} finally {
|
|
@@ -670,3 +895,14 @@ export async function createScratchWorkspace(
|
|
|
670
895
|
},
|
|
671
896
|
};
|
|
672
897
|
}
|
|
898
|
+
|
|
899
|
+
export const _testHooks = {
|
|
900
|
+
sweepStaleScratchLeases,
|
|
901
|
+
ensureScratchContainer,
|
|
902
|
+
deleteLeaseContentsAndRmdir,
|
|
903
|
+
SCRATCH_CONTAINER_NAME,
|
|
904
|
+
SCRATCH_LEASE_PREFIX,
|
|
905
|
+
SCRATCH_LEGACY_PREFIX,
|
|
906
|
+
SCRATCH_TREE_NAME,
|
|
907
|
+
SCRATCH_OWNER_NAME,
|
|
908
|
+
};
|