@tinycloud/operations 0.5.0-beta.0 → 0.5.0-beta.10
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/coverage.json +11 -1
- package/dist/artifacts.cjs +235 -43
- package/dist/artifacts.cjs.map +1 -1
- package/dist/artifacts.d.cts +1 -1
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +236 -43
- package/dist/artifacts.js.map +1 -1
- package/dist/cli-runtime.cjs +1021 -223
- package/dist/cli-runtime.cjs.map +1 -1
- package/dist/cli-runtime.d.cts +1 -1
- package/dist/cli-runtime.d.ts +1 -1
- package/dist/cli-runtime.js +1020 -222
- package/dist/cli-runtime.js.map +1 -1
- package/dist/{contract-DfyrluLB.d.cts → contract-zRklonbR.d.cts} +6 -3
- package/dist/{contract-DfyrluLB.d.ts → contract-zRklonbR.d.ts} +6 -3
- package/dist/delegation-binding.cjs +242 -43
- package/dist/delegation-binding.cjs.map +1 -1
- package/dist/delegation-binding.d.cts +15 -3
- package/dist/delegation-binding.d.ts +15 -3
- package/dist/delegation-binding.js +242 -43
- package/dist/delegation-binding.js.map +1 -1
- package/dist/index.cjs +1049 -224
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1046 -221
- package/dist/index.js.map +1 -1
- package/dist/profile.cjs +13 -4
- package/dist/profile.cjs.map +1 -1
- package/dist/profile.d.cts +14 -2
- package/dist/profile.d.ts +14 -2
- package/dist/profile.js +13 -4
- package/dist/profile.js.map +1 -1
- package/dist/recovery.cjs +67 -0
- package/dist/recovery.cjs.map +1 -0
- package/dist/recovery.d.cts +38 -0
- package/dist/recovery.d.ts +38 -0
- package/dist/recovery.js +39 -0
- package/dist/recovery.js.map +1 -0
- package/dist/secret-request.cjs +271 -0
- package/dist/secret-request.cjs.map +1 -0
- package/dist/secret-request.d.cts +119 -0
- package/dist/secret-request.d.ts +119 -0
- package/dist/secret-request.js +222 -0
- package/dist/secret-request.js.map +1 -0
- package/dist/state.cjs +232 -40
- package/dist/state.cjs.map +1 -1
- package/dist/state.js +233 -40
- package/dist/state.js.map +1 -1
- package/generated/operations.json +1838 -89
- package/package.json +15 -5
package/dist/artifacts.d.cts
CHANGED
|
@@ -2,7 +2,7 @@ import { PermissionEntry } from '@tinycloud/sdk-core';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { O as OperationSpaceResolver } from './authority-ofwbEqmj.cjs';
|
|
4
4
|
export { e as evaluateAuthority } from './authority-ofwbEqmj.cjs';
|
|
5
|
-
import { T as TinyCloudPosture, O as OperationOperatorType } from './contract-
|
|
5
|
+
import { T as TinyCloudPosture, O as OperationOperatorType } from './contract-zRklonbR.cjs';
|
|
6
6
|
|
|
7
7
|
/** The shared wire schema for SDK PermissionEntry values, including signed ReCap caveats. */
|
|
8
8
|
declare const PermissionEntrySchema: z.ZodObject<{
|
package/dist/artifacts.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { PermissionEntry } from '@tinycloud/sdk-core';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { O as OperationSpaceResolver } from './authority-ofwbEqmj.js';
|
|
4
4
|
export { e as evaluateAuthority } from './authority-ofwbEqmj.js';
|
|
5
|
-
import { T as TinyCloudPosture, O as OperationOperatorType } from './contract-
|
|
5
|
+
import { T as TinyCloudPosture, O as OperationOperatorType } from './contract-zRklonbR.js';
|
|
6
6
|
|
|
7
7
|
/** The shared wire schema for SDK PermissionEntry values, including signed ReCap caveats. */
|
|
8
8
|
declare const PermissionEntrySchema: z.ZodObject<{
|
package/dist/artifacts.js
CHANGED
|
@@ -304,10 +304,10 @@ function evaluateOperationAuthority(granted, required, resolveSpace) {
|
|
|
304
304
|
const canonicalRequired = canonicalizeOperationCapabilities(required, resolveSpace);
|
|
305
305
|
const missing = [];
|
|
306
306
|
for (const requested of canonicalRequired) {
|
|
307
|
-
const
|
|
308
|
-
(candidate) => candidate.service === requested.service && operationSpacesEqual(candidate.space, requested.space)
|
|
307
|
+
const sameScope = canonicalGranted.filter(
|
|
308
|
+
(candidate) => candidate.service === requested.service && operationSpacesEqual(candidate.space, requested.space)
|
|
309
309
|
);
|
|
310
|
-
if (!
|
|
310
|
+
if (!isCapabilitySubset([requested], sameScope).subset) missing.push(requested);
|
|
311
311
|
}
|
|
312
312
|
return {
|
|
313
313
|
satisfied: missing.length === 0,
|
|
@@ -381,9 +381,11 @@ function canonicalizeCaveats(caveats) {
|
|
|
381
381
|
// src/state.ts
|
|
382
382
|
import { randomUUID } from "crypto";
|
|
383
383
|
import { AsyncLocalStorage } from "async_hooks";
|
|
384
|
+
import { readFileSync, readlinkSync } from "fs";
|
|
384
385
|
import {
|
|
385
386
|
chmod,
|
|
386
387
|
link,
|
|
388
|
+
lstat,
|
|
387
389
|
mkdir,
|
|
388
390
|
open,
|
|
389
391
|
readFile,
|
|
@@ -395,6 +397,7 @@ import {
|
|
|
395
397
|
unlink,
|
|
396
398
|
writeFile
|
|
397
399
|
} from "fs/promises";
|
|
400
|
+
import { connect, createServer } from "net";
|
|
398
401
|
import { homedir } from "os";
|
|
399
402
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
|
|
400
403
|
var DEFAULT_LOCK_TIMEOUT_MS = 2e3;
|
|
@@ -414,13 +417,171 @@ var TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
|
|
|
414
417
|
var TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
|
|
415
418
|
var TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
|
|
416
419
|
var TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
|
|
420
|
+
var TEST_LOCK_LIVENESS_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_LIVENESS_BARRIER_DIR";
|
|
417
421
|
function processWideContext(key) {
|
|
418
|
-
var
|
|
422
|
+
var _a3;
|
|
419
423
|
const registry = globalThis;
|
|
420
|
-
return registry[
|
|
424
|
+
return registry[_a3 = Symbol.for(key)] ?? (registry[_a3] = new AsyncLocalStorage());
|
|
421
425
|
}
|
|
422
426
|
var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
|
|
423
427
|
var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
|
|
428
|
+
var _a;
|
|
429
|
+
var processInstanceId = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.profileLockInstance.v1")] ?? (globalThis[_a] = randomUUID());
|
|
430
|
+
var cachedProcessOrigin;
|
|
431
|
+
function processOrigin() {
|
|
432
|
+
if (cachedProcessOrigin !== void 0) return cachedProcessOrigin;
|
|
433
|
+
if (process.platform !== "linux") return cachedProcessOrigin = null;
|
|
434
|
+
try {
|
|
435
|
+
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
436
|
+
const pidNamespace = readlinkSync("/proc/self/ns/pid");
|
|
437
|
+
const processStat = readFileSync("/proc/self/stat", "utf8");
|
|
438
|
+
const closingParen = processStat.lastIndexOf(") ");
|
|
439
|
+
const processStartTime = closingParen < 0 ? void 0 : processStat.slice(closingParen + 2).trim().split(/\s+/)[19];
|
|
440
|
+
if (!/^[0-9a-f-]{36}$/.test(bootId) || !/^pid:\[[0-9]+\]$/.test(pidNamespace) || !processStartTime || !/^[0-9]+$/.test(processStartTime)) {
|
|
441
|
+
return cachedProcessOrigin = null;
|
|
442
|
+
}
|
|
443
|
+
return cachedProcessOrigin = { bootId, pidNamespace, processStartTime };
|
|
444
|
+
} catch {
|
|
445
|
+
return cachedProcessOrigin = null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function processOwner(liveness) {
|
|
449
|
+
return { instanceId: processInstanceId, ...processOrigin(), ...liveness ? { liveness: liveness.record } : {} };
|
|
450
|
+
}
|
|
451
|
+
var LIVENESS_SOCKET = /^\.live-([0-9a-f]+)-([0-9a-f]+)-[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}(\.new)?$/;
|
|
452
|
+
var LIVENESS_PROBE_TIMEOUT_MS = 1e3;
|
|
453
|
+
function socketDirectory(name) {
|
|
454
|
+
const match = LIVENESS_SOCKET.exec(name);
|
|
455
|
+
return match ? `${match[1]}-${match[2]}` : null;
|
|
456
|
+
}
|
|
457
|
+
async function pinDirectory(directory) {
|
|
458
|
+
const handle = await open(directory, "r");
|
|
459
|
+
try {
|
|
460
|
+
const { dev, ino } = await handle.stat({ bigint: true });
|
|
461
|
+
return {
|
|
462
|
+
identity: `${dev.toString(16)}-${ino.toString(16)}`,
|
|
463
|
+
path: (name) => `/proc/self/fd/${handle.fd}/${name}`,
|
|
464
|
+
close: () => handle.close()
|
|
465
|
+
};
|
|
466
|
+
} catch (error) {
|
|
467
|
+
await handle.close();
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async function bindLiveness(directory) {
|
|
472
|
+
if (!processOrigin()) return null;
|
|
473
|
+
let pinned;
|
|
474
|
+
try {
|
|
475
|
+
pinned = await pinDirectory(directory);
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const socket = `.live-${pinned.identity}-${randomUUID()}`;
|
|
480
|
+
const server = createServer((connection) => connection.destroy());
|
|
481
|
+
let bound = false;
|
|
482
|
+
try {
|
|
483
|
+
await new Promise((resolve2, reject) => {
|
|
484
|
+
server.once("error", reject);
|
|
485
|
+
server.listen({ path: pinned.path(`${socket}.new`), exclusive: true }, () => {
|
|
486
|
+
server.off("error", reject);
|
|
487
|
+
resolve2();
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
bound = true;
|
|
491
|
+
server.on("error", () => void 0);
|
|
492
|
+
server.unref();
|
|
493
|
+
await rename(pinned.path(`${socket}.new`), pinned.path(socket));
|
|
494
|
+
const stats = await lstat(pinned.path(socket), { bigint: true });
|
|
495
|
+
if (!stats.isSocket()) throw new Error(`${socket} is not a socket`);
|
|
496
|
+
await waitForTestBarrier(TEST_LOCK_LIVENESS_BARRIER_DIR, basename(directory));
|
|
497
|
+
return { record: { socket, dev: String(stats.dev), ino: String(stats.ino) }, path: join(directory, socket), server };
|
|
498
|
+
} catch {
|
|
499
|
+
if (bound) {
|
|
500
|
+
server.close();
|
|
501
|
+
for (const name of [`${socket}.new`, socket]) await unlink(pinned.path(name)).catch(() => void 0);
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
} finally {
|
|
505
|
+
await pinned.close();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function ensureLiveness(acquisition) {
|
|
509
|
+
if (acquisition.held === void 0) acquisition.held = await bindLiveness(acquisition.directory);
|
|
510
|
+
return acquisition.held;
|
|
511
|
+
}
|
|
512
|
+
async function closeLiveness(held) {
|
|
513
|
+
held.server.close();
|
|
514
|
+
await unlink(held.path).catch(() => void 0);
|
|
515
|
+
}
|
|
516
|
+
function parseLiveness(value) {
|
|
517
|
+
if (typeof value !== "object" || value === null) return null;
|
|
518
|
+
const { socket, dev, ino } = value;
|
|
519
|
+
const name = typeof socket === "string" ? LIVENESS_SOCKET.exec(socket) : null;
|
|
520
|
+
return name !== null && name[3] === void 0 && typeof dev === "string" && /^[0-9]+$/.test(dev) && typeof ino === "string" && /^[0-9]+$/.test(ino) ? { socket: name[0], dev, ino } : null;
|
|
521
|
+
}
|
|
522
|
+
async function controlIn(pinned, acquisition) {
|
|
523
|
+
const own = await ensureLiveness(acquisition);
|
|
524
|
+
if (!own || socketDirectory(own.record.socket) !== pinned.identity) return null;
|
|
525
|
+
const stats = await lstat(pinned.path(own.record.socket), { bigint: true }).catch(() => null);
|
|
526
|
+
return stats?.isSocket() && String(stats.dev) === own.record.dev && String(stats.ino) === own.record.ino ? own : null;
|
|
527
|
+
}
|
|
528
|
+
async function tryConnect(path) {
|
|
529
|
+
return new Promise((resolve2) => {
|
|
530
|
+
const probe = connect({ path });
|
|
531
|
+
const settle = (outcome) => {
|
|
532
|
+
clearTimeout(timer);
|
|
533
|
+
probe.destroy();
|
|
534
|
+
resolve2(outcome);
|
|
535
|
+
};
|
|
536
|
+
const timer = setTimeout(() => settle("other"), LIVENESS_PROBE_TIMEOUT_MS);
|
|
537
|
+
probe.once("connect", () => settle("connected"));
|
|
538
|
+
probe.once("error", (error) => settle(isErrno(error, "ECONNREFUSED") ? "refused" : "other"));
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
async function kernelRefuses(pinned, name, control) {
|
|
542
|
+
if (process.versions.bun !== void 0) return false;
|
|
543
|
+
return await tryConnect(pinned.path(name)) === "refused" && await tryConnect(pinned.path(control.record.socket)) === "connected";
|
|
544
|
+
}
|
|
545
|
+
async function probeLiveness(liveness, acquisition) {
|
|
546
|
+
let pinned;
|
|
547
|
+
try {
|
|
548
|
+
pinned = await pinDirectory(acquisition.directory);
|
|
549
|
+
} catch {
|
|
550
|
+
return "unknown";
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
if (socketDirectory(liveness.socket) !== pinned.identity) return "unknown";
|
|
554
|
+
const control = await controlIn(pinned, acquisition);
|
|
555
|
+
if (!control) return "unknown";
|
|
556
|
+
let stats;
|
|
557
|
+
try {
|
|
558
|
+
stats = await lstat(pinned.path(liveness.socket), { bigint: true });
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return isErrno(error, "ENOENT") ? "gone" : "unknown";
|
|
561
|
+
}
|
|
562
|
+
if (!stats.isSocket() || String(stats.dev) !== liveness.dev || String(stats.ino) !== liveness.ino) return "unknown";
|
|
563
|
+
return await kernelRefuses(pinned, liveness.socket, control) ? "refused" : "unknown";
|
|
564
|
+
} finally {
|
|
565
|
+
await pinned.close();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async function sweepLiveness(acquisition) {
|
|
569
|
+
if (process.versions.bun !== void 0 || !acquisition.held) return;
|
|
570
|
+
const pinned = await pinDirectory(acquisition.directory);
|
|
571
|
+
try {
|
|
572
|
+
const control = await controlIn(pinned, acquisition);
|
|
573
|
+
if (!control) return;
|
|
574
|
+
for (const name of await readdir(pinned.path("."))) {
|
|
575
|
+
if (name === control.record.socket || socketDirectory(name) !== pinned.identity) continue;
|
|
576
|
+
const stats = await lstat(pinned.path(name)).catch(() => null);
|
|
577
|
+
if (stats?.isSocket() && await kernelRefuses(pinned, name, control)) {
|
|
578
|
+
await unlink(pinned.path(name)).catch(() => void 0);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} finally {
|
|
582
|
+
await pinned.close();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
424
585
|
var ProfileLockTimeoutError = class extends Error {
|
|
425
586
|
/** `detail` names the cause when it is not simply another process holding the lock. */
|
|
426
587
|
constructor(profile, timeoutMs, detail) {
|
|
@@ -585,7 +746,18 @@ async function acquireProfileLock(profile, options) {
|
|
|
585
746
|
if (path !== directory || !isErrno(error, "ENOENT")) throw error;
|
|
586
747
|
});
|
|
587
748
|
}
|
|
588
|
-
const
|
|
749
|
+
const liveness = { directory: turnDirectory, held: void 0 };
|
|
750
|
+
const closeAcquisitionLiveness = async () => {
|
|
751
|
+
if (liveness.held) await closeLiveness(liveness.held);
|
|
752
|
+
};
|
|
753
|
+
let taken;
|
|
754
|
+
try {
|
|
755
|
+
taken = await acquireTurn(profile, turnDirectory, deadline, liveness);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
await closeAcquisitionLiveness();
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
const { turn, firstWaitedTurn } = taken;
|
|
589
761
|
try {
|
|
590
762
|
if (options.requireProfile && !await exists(profileConfigPath(profile))) {
|
|
591
763
|
await rmdir(directory).catch(() => void 0);
|
|
@@ -595,7 +767,7 @@ async function acquireProfileLock(profile, options) {
|
|
|
595
767
|
const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
|
|
596
768
|
while (true) {
|
|
597
769
|
const token = randomUUID();
|
|
598
|
-
if (await publishProfileLock(profile, lockPath, token, options.requireProfile === true)) {
|
|
770
|
+
if (await publishProfileLock(profile, lockPath, token, options.requireProfile === true, liveness.held ?? null)) {
|
|
599
771
|
return {
|
|
600
772
|
deletedWhileWaiting,
|
|
601
773
|
turnSlot: turn.slot,
|
|
@@ -603,17 +775,19 @@ async function acquireProfileLock(profile, options) {
|
|
|
603
775
|
try {
|
|
604
776
|
await releaseProfileLock(profile, lockPath, token);
|
|
605
777
|
} finally {
|
|
606
|
-
await releaseTurn(profile, turn);
|
|
778
|
+
await releaseTurn(profile, turn, liveness);
|
|
779
|
+
await closeAcquisitionLiveness();
|
|
607
780
|
}
|
|
608
781
|
}
|
|
609
782
|
};
|
|
610
783
|
}
|
|
611
784
|
await signalTestLockContention(profile);
|
|
612
|
-
if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
|
|
785
|
+
if (await recoverStaleLock(profile, lockPath, liveness, staleAfterMs)) continue;
|
|
613
786
|
await waitOrTimeOut(profile, deadline);
|
|
614
787
|
}
|
|
615
788
|
} catch (error) {
|
|
616
|
-
await releaseTurn(profile, turn);
|
|
789
|
+
await releaseTurn(profile, turn, liveness);
|
|
790
|
+
await closeAcquisitionLiveness();
|
|
617
791
|
throw error;
|
|
618
792
|
}
|
|
619
793
|
}
|
|
@@ -627,12 +801,12 @@ async function waitOrTimeOut(profile, deadline, detail) {
|
|
|
627
801
|
await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
|
|
628
802
|
}
|
|
629
803
|
var TURN = /^(?:0|[1-9][0-9]*)$/;
|
|
630
|
-
var
|
|
631
|
-
var relinquishedTurns = globalThis[
|
|
804
|
+
var _a2;
|
|
805
|
+
var relinquishedTurns = globalThis[_a2 = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a2] = /* @__PURE__ */ new Set());
|
|
632
806
|
var MARKER_RETRY_DELAYS_MS = [10, 50, 250];
|
|
633
807
|
var SETTLE_RETRY_MS = 1e3;
|
|
634
808
|
var SETTLE_RETRIES = 300;
|
|
635
|
-
async function acquireTurn(profile, directory, deadline) {
|
|
809
|
+
async function acquireTurn(profile, directory, deadline, liveness) {
|
|
636
810
|
let firstWaitedTurn;
|
|
637
811
|
let problem;
|
|
638
812
|
const damaged = (what) => `The turn lock ${directory} ${what}, so no process can take it. If no tc or MCP process is using profile "${profile}", remove that directory.`;
|
|
@@ -657,13 +831,13 @@ async function acquireTurn(profile, directory, deadline) {
|
|
|
657
831
|
}
|
|
658
832
|
firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
|
|
659
833
|
if (latest.held && latest.done) {
|
|
660
|
-
const turn = await takeTurn(profile, directory, latest);
|
|
834
|
+
const turn = await takeTurn(profile, directory, latest, await ensureLiveness(liveness));
|
|
661
835
|
if (turn !== null) return { turn, firstWaitedTurn };
|
|
662
836
|
await waitOrTimeOut(profile, deadline);
|
|
663
837
|
} else if (latest.done) {
|
|
664
838
|
problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
|
|
665
839
|
await waitOrTimeOut(profile, deadline, problem);
|
|
666
|
-
} else if (latest.pid === process.pid
|
|
840
|
+
} else if (latest.pid === process.pid && relinquishedTurns.has(latest.token) || !await isRecordedProcessAlive(latest, liveness)) {
|
|
667
841
|
await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
|
|
668
842
|
await settleAbandonedTurn(directory, latest);
|
|
669
843
|
relinquishedTurns.delete(latest.token);
|
|
@@ -708,6 +882,7 @@ async function readTurn(directory, slot) {
|
|
|
708
882
|
const done = await exists(join(path, `${owner.token}.done`));
|
|
709
883
|
const held = await exists(join(path, `${owner.token}.held`));
|
|
710
884
|
return {
|
|
885
|
+
...owner,
|
|
711
886
|
slot,
|
|
712
887
|
token: owner.token,
|
|
713
888
|
pid: owner.pid,
|
|
@@ -721,11 +896,11 @@ async function isFinishedTurn(directory, slot, token) {
|
|
|
721
896
|
if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
|
|
722
897
|
return turn !== null && turn.token === token && turn.held && turn.done;
|
|
723
898
|
}
|
|
724
|
-
async function takeTurn(profile, directory, latest) {
|
|
899
|
+
async function takeTurn(profile, directory, latest, liveness) {
|
|
725
900
|
const token = randomUUID();
|
|
726
901
|
const slot = latest.slot + 1;
|
|
727
902
|
const path = join(directory, String(slot));
|
|
728
|
-
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
|
|
903
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token, ...processOwner(liveness) };
|
|
729
904
|
let published = false;
|
|
730
905
|
try {
|
|
731
906
|
published = await publishDirectory(
|
|
@@ -779,7 +954,7 @@ async function markTurn(path, token, kind) {
|
|
|
779
954
|
}
|
|
780
955
|
async function createTurnDirectory(directory) {
|
|
781
956
|
const token = randomUUID();
|
|
782
|
-
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
|
|
957
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null, ...processOwner(null) };
|
|
783
958
|
await publishDirectory(join(dirname(directory), `.stage-${basename(directory)}-${process.pid}-${token}`), directory, {
|
|
784
959
|
"0/owner.json": `${JSON.stringify(owner, null, 2)}
|
|
785
960
|
`,
|
|
@@ -803,31 +978,39 @@ async function publishDirectory(stage, target, files, beforeRename) {
|
|
|
803
978
|
await rm(stage, { recursive: true, force: true }).catch(() => void 0);
|
|
804
979
|
}
|
|
805
980
|
}
|
|
806
|
-
async function releaseTurn(profile, turn) {
|
|
981
|
+
async function releaseTurn(profile, turn, liveness) {
|
|
807
982
|
try {
|
|
808
983
|
await markTurn(join(turn.directory, String(turn.slot)), turn.token, "done");
|
|
809
984
|
} catch {
|
|
810
985
|
settleOwnTurn(turn.directory, turn.slot, turn.token);
|
|
811
986
|
return;
|
|
812
987
|
}
|
|
813
|
-
await collectTurns(profile, turn).catch(() => void 0);
|
|
988
|
+
await collectTurns(profile, turn, liveness).catch(() => void 0);
|
|
814
989
|
}
|
|
815
|
-
function isAbandonedTurnEntry(name) {
|
|
990
|
+
async function isAbandonedTurnEntry(directory, name, liveness) {
|
|
816
991
|
if (name.startsWith(".trash-")) return true;
|
|
817
992
|
const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
|
|
818
|
-
|
|
993
|
+
if (writer === void 0) return false;
|
|
994
|
+
if (Number(writer) !== process.pid) return !await isRecordedProcessAlive({ pid: Number(writer) }, liveness);
|
|
995
|
+
return name.startsWith(".stage-") && await isStagedOwnerGone(join(directory, name, "owner.json"), liveness);
|
|
819
996
|
}
|
|
820
|
-
function isAbandonedTurnDirectoryStage(name) {
|
|
997
|
+
async function isAbandonedTurnDirectoryStage(directory, name, liveness) {
|
|
821
998
|
const writer = /^\.stage-.+-([0-9]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.exec(name)?.[1];
|
|
822
|
-
|
|
999
|
+
if (writer === void 0) return false;
|
|
1000
|
+
if (Number(writer) !== process.pid) return !await isRecordedProcessAlive({ pid: Number(writer) }, liveness);
|
|
1001
|
+
return isStagedOwnerGone(join(directory, name, "0", "owner.json"), liveness);
|
|
823
1002
|
}
|
|
824
|
-
async function
|
|
1003
|
+
async function isStagedOwnerGone(recordPath, liveness) {
|
|
1004
|
+
const owner = await readJson(recordPath).catch(() => null);
|
|
1005
|
+
return typeof owner === "object" && owner !== null && owner.pid === process.pid && !await isRecordedProcessAlive(owner, liveness);
|
|
1006
|
+
}
|
|
1007
|
+
async function collectTurns(profile, turn, liveness) {
|
|
825
1008
|
for (const [directory, abandoned] of [
|
|
826
1009
|
[turn.directory, isAbandonedTurnEntry],
|
|
827
1010
|
[dirname(turn.directory), isAbandonedTurnDirectoryStage]
|
|
828
1011
|
]) {
|
|
829
|
-
for (const name of
|
|
830
|
-
await rm(join(directory, name), { recursive: true, force: true }).catch(() => void 0);
|
|
1012
|
+
for (const name of await readdir(directory)) {
|
|
1013
|
+
if (await abandoned(directory, name, liveness)) await rm(join(directory, name), { recursive: true, force: true }).catch(() => void 0);
|
|
831
1014
|
}
|
|
832
1015
|
}
|
|
833
1016
|
const older = (await readdir(turn.directory)).filter((name) => TURN.test(name)).map(Number).filter((slot) => slot < turn.slot).sort((left, right) => left - right);
|
|
@@ -845,8 +1028,9 @@ async function collectTurns(profile, turn) {
|
|
|
845
1028
|
await rm(trash, { recursive: true, force: true }).catch(() => void 0);
|
|
846
1029
|
}
|
|
847
1030
|
}
|
|
1031
|
+
await sweepLiveness(liveness);
|
|
848
1032
|
}
|
|
849
|
-
async function publishProfileLock(profile, lockPath, token, requireProfile) {
|
|
1033
|
+
async function publishProfileLock(profile, lockPath, token, requireProfile, liveness) {
|
|
850
1034
|
try {
|
|
851
1035
|
await mkdir(lockPath, { mode: PRIVATE_DIR_MODE });
|
|
852
1036
|
} catch (error) {
|
|
@@ -859,7 +1043,7 @@ async function publishProfileLock(profile, lockPath, token, requireProfile) {
|
|
|
859
1043
|
await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
|
|
860
1044
|
const ownerPath = join(lockPath, "owner.json");
|
|
861
1045
|
const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);
|
|
862
|
-
const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
|
|
1046
|
+
const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, ...processOwner(liveness) }, null, 2)}
|
|
863
1047
|
`;
|
|
864
1048
|
try {
|
|
865
1049
|
await writeFile(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
@@ -904,7 +1088,7 @@ async function signalTestLockContention(profile) {
|
|
|
904
1088
|
await writeFile(signalPath, `${profile}
|
|
905
1089
|
`, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
|
|
906
1090
|
}
|
|
907
|
-
async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
1091
|
+
async function recoverOwnerlessLock(profile, lockPath, liveness, staleAfterMs) {
|
|
908
1092
|
let aged;
|
|
909
1093
|
try {
|
|
910
1094
|
const { mtimeMs } = await stat(lockPath);
|
|
@@ -921,7 +1105,7 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
|
921
1105
|
if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
|
|
922
1106
|
for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
|
|
923
1107
|
const claimPath = join(lockPath, name);
|
|
924
|
-
if (await hasLiveRecoveryOwner(claimPath)) {
|
|
1108
|
+
if (await hasLiveRecoveryOwner(claimPath, liveness)) {
|
|
925
1109
|
if (!await exists(join(lockPath, "owner.json"))) await rename(claimPath, join(lockPath, "owner.json"));
|
|
926
1110
|
return false;
|
|
927
1111
|
}
|
|
@@ -937,7 +1121,7 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
|
937
1121
|
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
938
1122
|
return true;
|
|
939
1123
|
}
|
|
940
|
-
async function hasLiveRecoveryOwner(claimPath) {
|
|
1124
|
+
async function hasLiveRecoveryOwner(claimPath, liveness) {
|
|
941
1125
|
let contents;
|
|
942
1126
|
try {
|
|
943
1127
|
contents = await readFile(claimPath, "utf8");
|
|
@@ -951,8 +1135,8 @@ async function hasLiveRecoveryOwner(claimPath) {
|
|
|
951
1135
|
} catch {
|
|
952
1136
|
return false;
|
|
953
1137
|
}
|
|
954
|
-
const
|
|
955
|
-
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 &&
|
|
1138
|
+
const record = typeof owner === "object" && owner !== null && "pid" in owner ? owner : null;
|
|
1139
|
+
return typeof record?.pid === "number" && Number.isInteger(record.pid) && record.pid > 0 && await isRecordedProcessAlive(record, liveness);
|
|
956
1140
|
}
|
|
957
1141
|
function recoveryFenced(since, staleAfterMs) {
|
|
958
1142
|
return performance.now() - since >= staleAfterMs / 2;
|
|
@@ -969,11 +1153,11 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
|
|
|
969
1153
|
if (aged) await rm(path, { force: true }).catch(() => void 0);
|
|
970
1154
|
}
|
|
971
1155
|
}
|
|
972
|
-
async function recoverStaleLock(profile, lockPath, staleAfterMs) {
|
|
1156
|
+
async function recoverStaleLock(profile, lockPath, liveness, staleAfterMs) {
|
|
973
1157
|
const ownerPath = join(lockPath, "owner.json");
|
|
974
1158
|
const observed = await readFile(ownerPath, "utf8").catch(() => null);
|
|
975
|
-
if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
|
|
976
|
-
if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
|
|
1159
|
+
if (observed === null) return recoverOwnerlessLock(profile, lockPath, liveness, staleAfterMs);
|
|
1160
|
+
if (!await isAbandonedOwner(observed, ownerPath, liveness, staleAfterMs)) return false;
|
|
977
1161
|
await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
|
|
978
1162
|
const claimPath = join(lockPath, `.recover-${randomUUID()}.json`);
|
|
979
1163
|
let moved = false;
|
|
@@ -1009,7 +1193,7 @@ async function recoverStaleLock(profile, lockPath, staleAfterMs) {
|
|
|
1009
1193
|
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
1010
1194
|
return true;
|
|
1011
1195
|
}
|
|
1012
|
-
async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
|
|
1196
|
+
async function isAbandonedOwner(observed, ownerPath, liveness, staleAfterMs) {
|
|
1013
1197
|
let owner;
|
|
1014
1198
|
try {
|
|
1015
1199
|
owner = JSON.parse(observed);
|
|
@@ -1017,7 +1201,7 @@ async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
|
|
|
1017
1201
|
owner = null;
|
|
1018
1202
|
}
|
|
1019
1203
|
if (typeof owner === "object" && owner !== null && "pid" in owner) {
|
|
1020
|
-
return isStaleOwner(owner, staleAfterMs);
|
|
1204
|
+
return isStaleOwner(owner, liveness, staleAfterMs);
|
|
1021
1205
|
}
|
|
1022
1206
|
const modifiedAt = await stat(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
|
|
1023
1207
|
return Date.now() - modifiedAt >= staleAfterMs;
|
|
@@ -1039,14 +1223,14 @@ async function waitForTestBarrier(environmentName, profile) {
|
|
|
1039
1223
|
}
|
|
1040
1224
|
}
|
|
1041
1225
|
}
|
|
1042
|
-
function isStaleOwner(owner, staleAfterMs) {
|
|
1226
|
+
async function isStaleOwner(owner, liveness, staleAfterMs) {
|
|
1043
1227
|
const now = Date.now();
|
|
1044
1228
|
const createdAt = typeof owner?.createdAt === "string" ? Date.parse(owner.createdAt) : Number.NaN;
|
|
1045
1229
|
if (!Number.isFinite(createdAt) || now - createdAt < staleAfterMs) return false;
|
|
1046
1230
|
if (typeof owner?.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0) {
|
|
1047
1231
|
return false;
|
|
1048
1232
|
}
|
|
1049
|
-
return !
|
|
1233
|
+
return !await isRecordedProcessAlive(owner, liveness);
|
|
1050
1234
|
}
|
|
1051
1235
|
async function exists(path) {
|
|
1052
1236
|
try {
|
|
@@ -1057,13 +1241,22 @@ async function exists(path) {
|
|
|
1057
1241
|
throw error;
|
|
1058
1242
|
}
|
|
1059
1243
|
}
|
|
1060
|
-
function
|
|
1244
|
+
async function isRecordedProcessAlive(owner, acquisition) {
|
|
1245
|
+
const pid = owner.pid;
|
|
1246
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return true;
|
|
1061
1247
|
try {
|
|
1062
1248
|
process.kill(pid, 0);
|
|
1063
|
-
return true;
|
|
1064
1249
|
} catch (error) {
|
|
1065
1250
|
return !isErrno(error, "ESRCH");
|
|
1066
1251
|
}
|
|
1252
|
+
if (pid !== process.pid || typeof owner.instanceId !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(owner.instanceId) || owner.instanceId === processInstanceId) return true;
|
|
1253
|
+
const origin = processOrigin();
|
|
1254
|
+
if (!origin || owner.bootId !== origin.bootId || typeof owner.pidNamespace !== "string" || !/^pid:\[[0-9]+\]$/.test(owner.pidNamespace)) return true;
|
|
1255
|
+
if (owner.pidNamespace === origin.pidNamespace) {
|
|
1256
|
+
return typeof owner.processStartTime !== "string" || !/^[0-9]+$/.test(owner.processStartTime) || owner.processStartTime === origin.processStartTime;
|
|
1257
|
+
}
|
|
1258
|
+
const liveness = parseLiveness(owner.liveness);
|
|
1259
|
+
return liveness === null || await probeLiveness(liveness, acquisition) === "unknown";
|
|
1067
1260
|
}
|
|
1068
1261
|
function validateProfileName(profile) {
|
|
1069
1262
|
if (!profile || profile === "." || profile === ".." || profile.includes("/") || profile.includes("\\") || profile.includes("\0")) {
|