sealkeep 0.9.0 → 0.11.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/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +223 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +316 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5897 -290
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ByteStream } from "./byte-stream.js";
|
|
2
|
+
import { type DaemonProcessIdentityLookup, type DaemonWorkerLease } from "./daemon-lease.js";
|
|
3
|
+
type Clock = {
|
|
4
|
+
now?: () => number;
|
|
5
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export type BackgroundBandwidthReservationOptions = {
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
burstBytes?: number;
|
|
10
|
+
/** A busy governor fails closed after this bound; it never steals a stale-looking lock. */
|
|
11
|
+
lockWaitMs?: number;
|
|
12
|
+
/** Test/embedding seam. Production uses the kernel-backed daemon process generation. */
|
|
13
|
+
processIdentityLookup?: DaemonProcessIdentityLookup;
|
|
14
|
+
identityLookupTimeoutMs?: number;
|
|
15
|
+
} & Clock;
|
|
16
|
+
export type BackgroundBandwidthStartupRecoveryOptions = {
|
|
17
|
+
/** In-lifetime supervised cleanup must touch only the just-exited child. */
|
|
18
|
+
expectedOwner?: {
|
|
19
|
+
pid: number;
|
|
20
|
+
processIdentity: string;
|
|
21
|
+
};
|
|
22
|
+
/** Test/embedding seam. Production uses the kernel-backed daemon process generation. */
|
|
23
|
+
processIdentityLookup?: DaemonProcessIdentityLookup;
|
|
24
|
+
identityLookupTimeoutMs?: number;
|
|
25
|
+
/** Adversarial ordering seam: invoked after stale ownership is proved, before the exact final check. */
|
|
26
|
+
beforeFinalLockCheck?: () => void | Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
/** Cross-process reservation shared by the daemon and its index child. */
|
|
29
|
+
export declare function reserveBackgroundBytes(dataDir: string, bytes: number, bytesPerSecond: number, options?: BackgroundBandwidthReservationOptions): Promise<number>;
|
|
30
|
+
/**
|
|
31
|
+
* Recovers a crash-left bandwidth mutex before a daemon starts any lane.
|
|
32
|
+
*
|
|
33
|
+
* This is deliberately unavailable to ordinary reservations. The daemon's
|
|
34
|
+
* process-generation lease supplies the mutual exclusion that a POSIX pathname
|
|
35
|
+
* alone cannot: once stale ownership is proved, no legitimate worker may
|
|
36
|
+
* recreate the fixed lock name between the final inode/content check and
|
|
37
|
+
* unlink. Unknown, live, malformed, or concurrently replaced locks fail closed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function recoverBackgroundBandwidthLockAtDaemonStartup(dataDir: string, daemonLease: Pick<DaemonWorkerLease, "assertOwned">, options?: BackgroundBandwidthStartupRecoveryOptions): Promise<"absent" | "recovered" | "not-owned">;
|
|
40
|
+
/** One aggregate background transfer lane for a vault, even across processes. */
|
|
41
|
+
export declare function paceBackgroundByteStream(dataDir: string, chunks: ByteStream, bytesPerSecond: number, options?: {
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
burstBytes?: number;
|
|
44
|
+
onProgress?: (bytes: number) => void;
|
|
45
|
+
} & Clock): ByteStream;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ARCHIVE_IO_CHUNK_BYTES, paceByteStream } from "./byte-stream.js";
|
|
6
|
+
import { daemonProcessIdentity, classifyDaemonProcessGeneration, } from "./daemon-lease.js";
|
|
7
|
+
const DEFAULT_LOCK_WAIT_MS = 2_000;
|
|
8
|
+
const DEFAULT_IDENTITY_LOOKUP_TIMEOUT_MS = 1_000;
|
|
9
|
+
function errorCode(error) {
|
|
10
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
11
|
+
? String(error.code)
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
function lockFingerprint(details) {
|
|
15
|
+
return {
|
|
16
|
+
dev: String(details.dev),
|
|
17
|
+
ino: String(details.ino),
|
|
18
|
+
bytes: String(details.size),
|
|
19
|
+
links: String(details.nlink),
|
|
20
|
+
mtimeNs: "mtimeNs" in details ? String(details.mtimeNs) : String(details.mtimeMs),
|
|
21
|
+
ctimeNs: "ctimeNs" in details ? String(details.ctimeNs) : String(details.ctimeMs),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function sameLock(left, right) {
|
|
25
|
+
return left.raw === right.raw
|
|
26
|
+
&& left.fingerprint.dev === right.fingerprint.dev
|
|
27
|
+
&& left.fingerprint.ino === right.fingerprint.ino
|
|
28
|
+
&& left.fingerprint.bytes === right.fingerprint.bytes
|
|
29
|
+
&& left.fingerprint.links === right.fingerprint.links
|
|
30
|
+
&& left.fingerprint.mtimeNs === right.fingerprint.mtimeNs
|
|
31
|
+
&& left.fingerprint.ctimeNs === right.fingerprint.ctimeNs;
|
|
32
|
+
}
|
|
33
|
+
function parseLockOwner(raw) {
|
|
34
|
+
try {
|
|
35
|
+
const value = JSON.parse(raw);
|
|
36
|
+
if (value.version !== 1
|
|
37
|
+
|| typeof value.token !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(value.token)
|
|
38
|
+
|| !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0
|
|
39
|
+
|| typeof value.processIdentity !== "string" || value.processIdentity.length < 1 || value.processIdentity.length > 512
|
|
40
|
+
|| !Number.isFinite(value.createdAtMs))
|
|
41
|
+
return null;
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function observeBandwidthLock(path) {
|
|
49
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
50
|
+
let handle;
|
|
51
|
+
try {
|
|
52
|
+
handle = await open(path, fsConstants.O_RDONLY | noFollow);
|
|
53
|
+
const [details, pathDetails, raw] = await Promise.all([
|
|
54
|
+
handle.stat({ bigint: true }),
|
|
55
|
+
lstat(path, { bigint: true }),
|
|
56
|
+
handle.readFile("utf8"),
|
|
57
|
+
]);
|
|
58
|
+
if (!details.isFile() || !pathDetails.isFile()
|
|
59
|
+
|| details.dev !== pathDetails.dev || details.ino !== pathDetails.ino) {
|
|
60
|
+
throw new Error("Background bandwidth lock is not one exact regular file");
|
|
61
|
+
}
|
|
62
|
+
const owner = parseLockOwner(raw);
|
|
63
|
+
if (!owner)
|
|
64
|
+
throw new Error("Background bandwidth lock is malformed; refusing unsafe automatic recovery");
|
|
65
|
+
return { raw, owner, fingerprint: lockFingerprint(details) };
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (errorCode(error) === "ENOENT")
|
|
69
|
+
return null;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await handle?.close().catch(() => undefined);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function boundedIdentityLookup(lookup, pid, timeoutMs) {
|
|
77
|
+
let timer;
|
|
78
|
+
const deadline = new Promise((resolve) => {
|
|
79
|
+
timer = setTimeout(() => resolve({ state: "unknown" }), Math.max(1, timeoutMs));
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
const result = await Promise.race([Promise.resolve().then(() => lookup(pid)), deadline]);
|
|
83
|
+
if (result.state === "dead" || result.state === "unknown")
|
|
84
|
+
return result;
|
|
85
|
+
if (result.state === "identified" && typeof result.identity === "string"
|
|
86
|
+
&& result.identity.length > 0 && result.identity.length <= 512)
|
|
87
|
+
return result;
|
|
88
|
+
return { state: "unknown" };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { state: "unknown" };
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
if (timer)
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function publishBandwidthLock(runtime, lockPath, owner) {
|
|
99
|
+
const temporary = join(runtime, `.background-bandwidth.${owner.token}.${randomUUID()}.pending`);
|
|
100
|
+
const raw = `${JSON.stringify(owner)}\n`;
|
|
101
|
+
let handle;
|
|
102
|
+
try {
|
|
103
|
+
handle = await open(temporary, "wx", 0o600);
|
|
104
|
+
await handle.writeFile(raw, "utf8");
|
|
105
|
+
await handle.sync();
|
|
106
|
+
await handle.close();
|
|
107
|
+
handle = undefined;
|
|
108
|
+
try {
|
|
109
|
+
// The public lock name appears only after complete, fsynced owner data
|
|
110
|
+
// exists. A crash during publication therefore leaves an ignorable temp,
|
|
111
|
+
// never a malformed permanent lock.
|
|
112
|
+
await link(temporary, lockPath);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (errorCode(error) === "EEXIST")
|
|
116
|
+
return null;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
// Drop the publication name before fingerprinting ownership. Removing a
|
|
120
|
+
// hard link changes nlink/ctime; recording the two-link intermediate state
|
|
121
|
+
// would make the owner's later exact release reject its own lock.
|
|
122
|
+
await rm(temporary, { force: true });
|
|
123
|
+
const observed = await observeBandwidthLock(lockPath);
|
|
124
|
+
if (!observed || observed.raw !== raw) {
|
|
125
|
+
throw new Error("Background bandwidth lock publication could not be verified");
|
|
126
|
+
}
|
|
127
|
+
return observed;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await handle?.close().catch(() => undefined);
|
|
131
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function releaseBandwidthLock(path, owned) {
|
|
135
|
+
const current = await observeBandwidthLock(path);
|
|
136
|
+
if (!current || !sameLock(owned, current)) {
|
|
137
|
+
throw new Error("Background bandwidth lock ownership changed before release");
|
|
138
|
+
}
|
|
139
|
+
// Per-reservation code never removes or replaces an existing lock. Therefore
|
|
140
|
+
// legitimate contenders cannot change this name between the exact check and
|
|
141
|
+
// unlink; supervised cleanup runs only before a daemon starts any workers.
|
|
142
|
+
await unlink(path);
|
|
143
|
+
}
|
|
144
|
+
async function syncDirectory(path) {
|
|
145
|
+
let handle;
|
|
146
|
+
try {
|
|
147
|
+
handle = await open(path, "r");
|
|
148
|
+
await handle.sync();
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (process.platform !== "win32")
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await handle?.close().catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const sleepAbortable = async (ms, signal) => {
|
|
159
|
+
signal?.throwIfAborted();
|
|
160
|
+
await new Promise((resolve, reject) => {
|
|
161
|
+
let timer;
|
|
162
|
+
const aborted = () => {
|
|
163
|
+
if (timer)
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
signal?.removeEventListener("abort", aborted);
|
|
166
|
+
reject(signal?.reason instanceof Error ? signal.reason : new DOMException("Background transfer cancelled", "AbortError"));
|
|
167
|
+
};
|
|
168
|
+
timer = setTimeout(() => { signal?.removeEventListener("abort", aborted); resolve(); }, ms);
|
|
169
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
/** Cross-process reservation shared by the daemon and its index child. */
|
|
173
|
+
export async function reserveBackgroundBytes(dataDir, bytes, bytesPerSecond, options = {}) {
|
|
174
|
+
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0)
|
|
175
|
+
throw new Error("Background byte rate must be positive");
|
|
176
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
177
|
+
throw new Error("Background byte reservation must be non-negative");
|
|
178
|
+
const now = options.now ?? Date.now;
|
|
179
|
+
const sleep = options.sleep ?? sleepAbortable;
|
|
180
|
+
const runtime = join(dataDir, "runtime");
|
|
181
|
+
const statePath = join(runtime, "background-bandwidth.json");
|
|
182
|
+
const lockPath = join(runtime, "background-bandwidth.lock");
|
|
183
|
+
await mkdir(runtime, { recursive: true, mode: 0o700 });
|
|
184
|
+
const lookup = options.processIdentityLookup ?? daemonProcessIdentity;
|
|
185
|
+
const identity = await boundedIdentityLookup(lookup, process.pid, options.identityLookupTimeoutMs ?? DEFAULT_IDENTITY_LOOKUP_TIMEOUT_MS);
|
|
186
|
+
if (identity.state !== "identified") {
|
|
187
|
+
throw new Error("Sealkeep could not identify this process, so background bandwidth reservation failed closed");
|
|
188
|
+
}
|
|
189
|
+
const owner = {
|
|
190
|
+
version: 1,
|
|
191
|
+
token: randomUUID(),
|
|
192
|
+
pid: process.pid,
|
|
193
|
+
processIdentity: identity.identity,
|
|
194
|
+
createdAtMs: Date.now(),
|
|
195
|
+
};
|
|
196
|
+
const deadline = performance.now() + Math.max(0, options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS);
|
|
197
|
+
let lock = null;
|
|
198
|
+
for (;;) {
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
200
|
+
lock = await publishBandwidthLock(runtime, lockPath, owner);
|
|
201
|
+
if (lock)
|
|
202
|
+
break;
|
|
203
|
+
const remaining = deadline - performance.now();
|
|
204
|
+
if (remaining <= 0) {
|
|
205
|
+
throw new Error("Background bandwidth governor is busy; refusing to bypass its shared transfer limit");
|
|
206
|
+
}
|
|
207
|
+
await sleep(Math.min(10, remaining), options.signal);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const current = await readFile(statePath, "utf8")
|
|
211
|
+
.then((raw) => JSON.parse(raw))
|
|
212
|
+
.catch(() => ({}));
|
|
213
|
+
const observed = now();
|
|
214
|
+
const burst = Math.max(0, options.burstBytes ?? ARCHIVE_IO_CHUNK_BYTES);
|
|
215
|
+
const previousAt = Number.isFinite(current.at) ? Number(current.at) : observed;
|
|
216
|
+
const previousTokens = Number.isFinite(current.tokens) ? Math.max(0, Number(current.tokens)) : burst;
|
|
217
|
+
const baseAt = Math.max(observed, previousAt);
|
|
218
|
+
const available = previousAt <= observed
|
|
219
|
+
? Math.min(burst, previousTokens + (observed - previousAt) / 1_000 * bytesPerSecond)
|
|
220
|
+
: 0;
|
|
221
|
+
const deficit = Math.max(0, bytes - available);
|
|
222
|
+
const reservedAt = baseAt + deficit / bytesPerSecond * 1_000;
|
|
223
|
+
const tokens = deficit > 0 ? 0 : available - bytes;
|
|
224
|
+
const temp = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
225
|
+
const state = await open(temp, "wx", 0o600);
|
|
226
|
+
try {
|
|
227
|
+
await state.writeFile(JSON.stringify({ at: reservedAt, tokens }));
|
|
228
|
+
await state.sync();
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await state.close();
|
|
232
|
+
}
|
|
233
|
+
await rename(temp, statePath);
|
|
234
|
+
return Math.max(0, reservedAt - observed);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await releaseBandwidthLock(lockPath, lock);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Recovers a crash-left bandwidth mutex before a daemon starts any lane.
|
|
242
|
+
*
|
|
243
|
+
* This is deliberately unavailable to ordinary reservations. The daemon's
|
|
244
|
+
* process-generation lease supplies the mutual exclusion that a POSIX pathname
|
|
245
|
+
* alone cannot: once stale ownership is proved, no legitimate worker may
|
|
246
|
+
* recreate the fixed lock name between the final inode/content check and
|
|
247
|
+
* unlink. Unknown, live, malformed, or concurrently replaced locks fail closed.
|
|
248
|
+
*/
|
|
249
|
+
export async function recoverBackgroundBandwidthLockAtDaemonStartup(dataDir, daemonLease, options = {}) {
|
|
250
|
+
await daemonLease.assertOwned();
|
|
251
|
+
const runtime = join(dataDir, "runtime");
|
|
252
|
+
const lockPath = join(runtime, "background-bandwidth.lock");
|
|
253
|
+
const observed = await observeBandwidthLock(lockPath);
|
|
254
|
+
if (!observed)
|
|
255
|
+
return "absent";
|
|
256
|
+
if (options.expectedOwner && (observed.owner.pid !== options.expectedOwner.pid
|
|
257
|
+
|| observed.owner.processIdentity !== options.expectedOwner.processIdentity))
|
|
258
|
+
return "not-owned";
|
|
259
|
+
const owner = await boundedIdentityLookup(options.processIdentityLookup ?? daemonProcessIdentity, observed.owner.pid, options.identityLookupTimeoutMs ?? DEFAULT_IDENTITY_LOOKUP_TIMEOUT_MS);
|
|
260
|
+
const generation = classifyDaemonProcessGeneration(owner, observed.owner.processIdentity);
|
|
261
|
+
if (generation === "unknown") {
|
|
262
|
+
throw new Error("Background bandwidth lock owner could not be verified; daemon startup failed closed");
|
|
263
|
+
}
|
|
264
|
+
if (generation === "current") {
|
|
265
|
+
throw new Error("A live background transfer still owns the bandwidth governor; daemon startup failed closed");
|
|
266
|
+
}
|
|
267
|
+
await options.beforeFinalLockCheck?.();
|
|
268
|
+
await daemonLease.assertOwned();
|
|
269
|
+
const current = await observeBandwidthLock(lockPath);
|
|
270
|
+
if (!current || !sameLock(observed, current)) {
|
|
271
|
+
throw new Error("Background bandwidth lock changed during supervised recovery; it was left intact");
|
|
272
|
+
}
|
|
273
|
+
await unlink(lockPath);
|
|
274
|
+
await syncDirectory(runtime);
|
|
275
|
+
return "recovered";
|
|
276
|
+
}
|
|
277
|
+
/** One aggregate background transfer lane for a vault, even across processes. */
|
|
278
|
+
export function paceBackgroundByteStream(dataDir, chunks, bytesPerSecond, options = {}) {
|
|
279
|
+
// Deterministic transport tests inject their own clock. Production uses the
|
|
280
|
+
// durable cross-process bucket above.
|
|
281
|
+
const paced = options.now || options.sleep
|
|
282
|
+
? paceByteStream(chunks, bytesPerSecond, options)
|
|
283
|
+
: {
|
|
284
|
+
async *[Symbol.asyncIterator]() {
|
|
285
|
+
for await (const chunk of chunks) {
|
|
286
|
+
const delay = await reserveBackgroundBytes(dataDir, chunk.length, bytesPerSecond, options);
|
|
287
|
+
if (delay > 0)
|
|
288
|
+
await sleepAbortable(delay, options.signal);
|
|
289
|
+
yield chunk;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
return {
|
|
294
|
+
async *[Symbol.asyncIterator]() {
|
|
295
|
+
for await (const chunk of paced) {
|
|
296
|
+
options.onProgress?.(chunk.length);
|
|
297
|
+
yield chunk;
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Pure duty-cycle calculation shared by every low-priority CPU lane. */
|
|
2
|
+
export declare function backgroundCpuDelayMs(cpuMs: number, wallMs: number, target: number, maximumMs?: number): number;
|
|
3
|
+
export declare const BACKGROUND_CPU_TOTAL_TARGET = 0.3;
|
|
4
|
+
/** Only accepts the already-owned ChildProcess handle, never an arbitrary PID.
|
|
5
|
+
* Policy refusal is not proof of exit; callers retain their close/barrier fence. */
|
|
6
|
+
export declare function signalOwnedBackgroundProcess(child: {
|
|
7
|
+
kill(signal: NodeJS.Signals): boolean;
|
|
8
|
+
}, signal: NodeJS.Signals): boolean;
|
|
9
|
+
/** These workers own no stdout/stderr pipe (only ignore/inherit plus IPC).
|
|
10
|
+
* A denied kill can prevent Node's later `close` bookkeeping even after the
|
|
11
|
+
* exact child emits `exit`. Either event proves this captured child ended;
|
|
12
|
+
* callers still perform their generation-dead barrier retirement. */
|
|
13
|
+
export declare function onOwnedBackgroundProcessExit(child: Pick<ChildProcess, "once">, listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
|
|
14
|
+
/** One share is reserved for the responsive parent process's cooperative
|
|
15
|
+
* sealing/retention work. Child shares are released only when governing stops.
|
|
16
|
+
* This intentionally leaves some capacity unused when the parent is idle. */
|
|
17
|
+
export declare function createBackgroundCpuBudget(totalTarget?: number): {
|
|
18
|
+
parentTarget: (requested: number) => number;
|
|
19
|
+
register(): {
|
|
20
|
+
target: (requested: number) => number;
|
|
21
|
+
release(): void;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export type BackgroundCpuBudget = ReturnType<typeof createBackgroundCpuBudget>;
|
|
25
|
+
/** Shared by all background work hosted by this daemon process. Never pauses
|
|
26
|
+
* the daemon itself, so heartbeat, cancellation and hook I/O remain runnable. */
|
|
27
|
+
export declare const backgroundDaemonCpuBudget: {
|
|
28
|
+
parentTarget: (requested: number) => number;
|
|
29
|
+
register(): {
|
|
30
|
+
target: (requested: number) => number;
|
|
31
|
+
release(): void;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Creates one cooperative CPU lane. Each checkpoint accounts only for the work
|
|
36
|
+
* since the previous checkpoint, so slow network I/O cannot build up a credit
|
|
37
|
+
* that later permits a long compression burst at full core speed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createBackgroundCpuYield(options: {
|
|
40
|
+
target: number;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
cpuUsage?: typeof process.cpuUsage;
|
|
43
|
+
now?: () => number;
|
|
44
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
45
|
+
maximumSleepMs?: number;
|
|
46
|
+
/** Owned children already have the parent governor: they explicitly opt out
|
|
47
|
+
* of this process-local parent accounting instead of paying twice. */
|
|
48
|
+
budget?: BackgroundCpuBudget | null;
|
|
49
|
+
}): () => Promise<void>;
|
|
50
|
+
export type BackgroundProcessCpuGovernor = {
|
|
51
|
+
/** Stop governing and make sure the owned process is runnable again. */
|
|
52
|
+
stop(): void;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Enforces a short wall-clock duty cycle from a process other than the worker.
|
|
56
|
+
*
|
|
57
|
+
* Cooperative checkpoints can account for JavaScript, compression and crypto,
|
|
58
|
+
* but they cannot interrupt a stop-the-world collection: the event loop that
|
|
59
|
+
* would choose to sleep is the thing being collected. A historical worker is
|
|
60
|
+
* already an owned child, so its parent can impose the missing hard boundary
|
|
61
|
+
* with 30 ms runnable / 70 ms paused windows on POSIX. This bounds even native
|
|
62
|
+
* V8 work; Windows retains the cooperative controller (and deliberately does
|
|
63
|
+
* not receive Unix signals).
|
|
64
|
+
*/
|
|
65
|
+
export declare function startBackgroundProcessCpuGovernor(options: {
|
|
66
|
+
target: number;
|
|
67
|
+
/** Must identify only the caller's owned child process. */
|
|
68
|
+
signal: (signal: NodeJS.Signals) => boolean;
|
|
69
|
+
platform?: NodeJS.Platform;
|
|
70
|
+
periodMs?: number;
|
|
71
|
+
setTimer?: (callback: () => void, ms: number) => NodeJS.Timeout;
|
|
72
|
+
clearTimer?: (timer: NodeJS.Timeout) => void;
|
|
73
|
+
/** Explicit aggregate allocation; standalone governor diagnostics omit it. */
|
|
74
|
+
budget?: BackgroundCpuBudget;
|
|
75
|
+
/** Owning workers probe both controls before sending any secret/work IPC. */
|
|
76
|
+
verifySignals?: boolean;
|
|
77
|
+
/** A lost governor is a failed pass, never permission to run at full speed. */
|
|
78
|
+
onFailure?: (error: Error & {
|
|
79
|
+
code: string;
|
|
80
|
+
}) => void;
|
|
81
|
+
}): BackgroundProcessCpuGovernor;
|
|
82
|
+
import type { ChildProcess } from "node:child_process";
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/** Pure duty-cycle calculation shared by every low-priority CPU lane. */
|
|
2
|
+
export function backgroundCpuDelayMs(cpuMs, wallMs, target, maximumMs = 1_000) {
|
|
3
|
+
if (!Number.isFinite(cpuMs) || cpuMs <= 0 || !Number.isFinite(wallMs) || wallMs <= 0)
|
|
4
|
+
return 0;
|
|
5
|
+
if (!Number.isFinite(target) || target <= 0 || target > 1)
|
|
6
|
+
throw new Error("Background CPU target must be between 0 and 1");
|
|
7
|
+
const delay = cpuMs / target - wallMs;
|
|
8
|
+
return Math.max(0, Math.min(maximumMs, Math.ceil(delay)));
|
|
9
|
+
}
|
|
10
|
+
export const BACKGROUND_CPU_TOTAL_TARGET = 0.30;
|
|
11
|
+
/** Only accepts the already-owned ChildProcess handle, never an arbitrary PID.
|
|
12
|
+
* Policy refusal is not proof of exit; callers retain their close/barrier fence. */
|
|
13
|
+
export function signalOwnedBackgroundProcess(child, signal) {
|
|
14
|
+
try {
|
|
15
|
+
return child.kill(signal);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** These workers own no stdout/stderr pipe (only ignore/inherit plus IPC).
|
|
22
|
+
* A denied kill can prevent Node's later `close` bookkeeping even after the
|
|
23
|
+
* exact child emits `exit`. Either event proves this captured child ended;
|
|
24
|
+
* callers still perform their generation-dead barrier retirement. */
|
|
25
|
+
export function onOwnedBackgroundProcessExit(child, listener) {
|
|
26
|
+
let ended = false;
|
|
27
|
+
const done = (code, signal) => {
|
|
28
|
+
if (ended)
|
|
29
|
+
return;
|
|
30
|
+
ended = true;
|
|
31
|
+
listener(code, signal);
|
|
32
|
+
};
|
|
33
|
+
child.once("exit", done);
|
|
34
|
+
// Failed spawn may emit close without an exit; it cannot own live work.
|
|
35
|
+
child.once("close", done);
|
|
36
|
+
}
|
|
37
|
+
/** One share is reserved for the responsive parent process's cooperative
|
|
38
|
+
* sealing/retention work. Child shares are released only when governing stops.
|
|
39
|
+
* This intentionally leaves some capacity unused when the parent is idle. */
|
|
40
|
+
export function createBackgroundCpuBudget(totalTarget = BACKGROUND_CPU_TOTAL_TARGET) {
|
|
41
|
+
backgroundCpuDelayMs(1, 1, totalTarget);
|
|
42
|
+
const members = new Set();
|
|
43
|
+
const target = (requested) => Math.min(requested, totalTarget / (members.size + 1));
|
|
44
|
+
return {
|
|
45
|
+
parentTarget: target,
|
|
46
|
+
register() {
|
|
47
|
+
const id = Symbol("owned-background-child");
|
|
48
|
+
members.add(id);
|
|
49
|
+
return { target, release() { members.delete(id); } };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Shared by all background work hosted by this daemon process. Never pauses
|
|
54
|
+
* the daemon itself, so heartbeat, cancellation and hook I/O remain runnable. */
|
|
55
|
+
export const backgroundDaemonCpuBudget = createBackgroundCpuBudget();
|
|
56
|
+
const sleepAbortable = async (ms, signal) => {
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
await new Promise((resolve, reject) => {
|
|
59
|
+
let timer;
|
|
60
|
+
const aborted = () => {
|
|
61
|
+
if (timer)
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
signal?.removeEventListener("abort", aborted);
|
|
64
|
+
reject(signal?.reason instanceof Error ? signal.reason : new DOMException("Background work cancelled", "AbortError"));
|
|
65
|
+
};
|
|
66
|
+
timer = setTimeout(() => { signal?.removeEventListener("abort", aborted); resolve(); }, ms);
|
|
67
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Creates one cooperative CPU lane. Each checkpoint accounts only for the work
|
|
72
|
+
* since the previous checkpoint, so slow network I/O cannot build up a credit
|
|
73
|
+
* that later permits a long compression burst at full core speed.
|
|
74
|
+
*/
|
|
75
|
+
export function createBackgroundCpuYield(options) {
|
|
76
|
+
const cpuUsage = options.cpuUsage ?? process.cpuUsage;
|
|
77
|
+
const now = options.now ?? Date.now;
|
|
78
|
+
const sleep = options.sleep ?? sleepAbortable;
|
|
79
|
+
// Explicit foreground/embedding targets above the automatic .30 policy
|
|
80
|
+
// retain their old meaning. Only automatic low-priority work joins by
|
|
81
|
+
// default; an owned child opts out because its parent already governs it.
|
|
82
|
+
const budget = options.budget === undefined
|
|
83
|
+
? (options.target <= BACKGROUND_CPU_TOTAL_TARGET ? backgroundDaemonCpuBudget : null)
|
|
84
|
+
: options.budget;
|
|
85
|
+
let previousCpu = cpuUsage();
|
|
86
|
+
let previousWall = now();
|
|
87
|
+
// Validate before work begins rather than after the first expensive chunk.
|
|
88
|
+
backgroundCpuDelayMs(1, 1, options.target, options.maximumSleepMs);
|
|
89
|
+
return async () => {
|
|
90
|
+
options.signal?.throwIfAborted();
|
|
91
|
+
// V8 may finish concurrent marking/sweeping while JavaScript is asleep.
|
|
92
|
+
// Sampling only before the sleep and resetting the baseline afterwards
|
|
93
|
+
// silently forgave that CPU. On a large index this produced isolated
|
|
94
|
+
// 80%+ process spikes despite a nominal 30% lane. Keep the same window
|
|
95
|
+
// open until work performed by *all* process threads, including work that
|
|
96
|
+
// happened during the repayment sleep, has actually been repaid.
|
|
97
|
+
for (;;) {
|
|
98
|
+
const observedAt = now();
|
|
99
|
+
const used = cpuUsage(previousCpu);
|
|
100
|
+
const cpuMs = (used.user + used.system) / 1_000;
|
|
101
|
+
const wallMs = Math.max(1, observedAt - previousWall);
|
|
102
|
+
const delay = backgroundCpuDelayMs(cpuMs, wallMs, budget?.parentTarget(options.target) ?? options.target, options.maximumSleepMs);
|
|
103
|
+
if (delay <= 1)
|
|
104
|
+
break;
|
|
105
|
+
await sleep(delay, options.signal);
|
|
106
|
+
options.signal?.throwIfAborted();
|
|
107
|
+
}
|
|
108
|
+
options.signal?.throwIfAborted();
|
|
109
|
+
previousCpu = cpuUsage();
|
|
110
|
+
previousWall = now();
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Enforces a short wall-clock duty cycle from a process other than the worker.
|
|
115
|
+
*
|
|
116
|
+
* Cooperative checkpoints can account for JavaScript, compression and crypto,
|
|
117
|
+
* but they cannot interrupt a stop-the-world collection: the event loop that
|
|
118
|
+
* would choose to sleep is the thing being collected. A historical worker is
|
|
119
|
+
* already an owned child, so its parent can impose the missing hard boundary
|
|
120
|
+
* with 30 ms runnable / 70 ms paused windows on POSIX. This bounds even native
|
|
121
|
+
* V8 work; Windows retains the cooperative controller (and deliberately does
|
|
122
|
+
* not receive Unix signals).
|
|
123
|
+
*/
|
|
124
|
+
export function startBackgroundProcessCpuGovernor(options) {
|
|
125
|
+
backgroundCpuDelayMs(1, 1, options.target);
|
|
126
|
+
const allocation = options.budget?.register();
|
|
127
|
+
if ((options.platform ?? process.platform) === "win32")
|
|
128
|
+
return { stop() { allocation?.release(); } };
|
|
129
|
+
const periodMs = Math.max(20, Math.floor(options.periodMs ?? 100));
|
|
130
|
+
// Timers and SIGSTOP are not real-time primitives: a runnable window can
|
|
131
|
+
// overrun by a few milliseconds before the kernel observes the stop. Keep a
|
|
132
|
+
// small fixed headroom so the measured process stays near the requested
|
|
133
|
+
// target instead of routinely landing several points above it.
|
|
134
|
+
const runnableMs = () => Math.max(1, Math.min(periodMs - 1, Math.floor(periodMs * (allocation?.target(options.target) ?? options.target) * 0.90)));
|
|
135
|
+
let lastRunnableMs = runnableMs();
|
|
136
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
137
|
+
const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
138
|
+
let timer;
|
|
139
|
+
let stopped = false;
|
|
140
|
+
let paused = false;
|
|
141
|
+
const send = (signal) => {
|
|
142
|
+
try {
|
|
143
|
+
return options.signal(signal);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const failed = () => {
|
|
150
|
+
stopped = true;
|
|
151
|
+
allocation?.release();
|
|
152
|
+
options.onFailure?.(Object.assign(new Error("Background CPU process control is unavailable"), {
|
|
153
|
+
code: "background_cpu_control_unavailable",
|
|
154
|
+
}));
|
|
155
|
+
};
|
|
156
|
+
const run = () => {
|
|
157
|
+
if (stopped)
|
|
158
|
+
return;
|
|
159
|
+
if (paused) {
|
|
160
|
+
if (!send("SIGCONT")) {
|
|
161
|
+
failed();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
paused = false;
|
|
165
|
+
}
|
|
166
|
+
lastRunnableMs = runnableMs();
|
|
167
|
+
timer = setTimer(pause, lastRunnableMs);
|
|
168
|
+
timer.unref?.();
|
|
169
|
+
};
|
|
170
|
+
const pause = () => {
|
|
171
|
+
if (stopped)
|
|
172
|
+
return;
|
|
173
|
+
if (!send("SIGSTOP")) {
|
|
174
|
+
failed();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
paused = true;
|
|
178
|
+
timer = setTimer(run, periodMs - lastRunnableMs);
|
|
179
|
+
timer.unref?.();
|
|
180
|
+
};
|
|
181
|
+
if (options.verifySignals) {
|
|
182
|
+
if (!send("SIGSTOP"))
|
|
183
|
+
failed();
|
|
184
|
+
else {
|
|
185
|
+
paused = true;
|
|
186
|
+
if (!send("SIGCONT"))
|
|
187
|
+
failed();
|
|
188
|
+
else
|
|
189
|
+
paused = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!stopped) {
|
|
193
|
+
timer = setTimer(pause, lastRunnableMs);
|
|
194
|
+
timer.unref?.();
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
stop() {
|
|
198
|
+
if (stopped)
|
|
199
|
+
return;
|
|
200
|
+
stopped = true;
|
|
201
|
+
allocation?.release();
|
|
202
|
+
if (timer)
|
|
203
|
+
clearTimer(timer);
|
|
204
|
+
timer = undefined;
|
|
205
|
+
// Never strand the child stopped if its owner cancels, times out, or
|
|
206
|
+
// receives the result between two pulses.
|
|
207
|
+
if (paused)
|
|
208
|
+
send("SIGCONT");
|
|
209
|
+
paused = false;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Bounded machine-readable code only; never serialize provider details or
|
|
2
|
+
* arbitrary error properties across a background worker boundary. */
|
|
3
|
+
export type BackgroundWorkerError = {
|
|
4
|
+
name?: string;
|
|
5
|
+
message: string;
|
|
6
|
+
stack?: string;
|
|
7
|
+
code?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function backgroundWorkerErrorToWire(cause: unknown): BackgroundWorkerError;
|
|
10
|
+
export declare function backgroundWorkerErrorFromWire(wire: BackgroundWorkerError): Error & {
|
|
11
|
+
code?: string;
|
|
12
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function safeCode(value) {
|
|
2
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(value) ? value : undefined;
|
|
3
|
+
}
|
|
4
|
+
export function backgroundWorkerErrorToWire(cause) {
|
|
5
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
6
|
+
const code = safeCode(error.code);
|
|
7
|
+
return { name: error.name, message: error.message, stack: error.stack, ...(code ? { code } : {}) };
|
|
8
|
+
}
|
|
9
|
+
export function backgroundWorkerErrorFromWire(wire) {
|
|
10
|
+
const error = new Error(wire.message);
|
|
11
|
+
error.name = wire.name ?? "Error";
|
|
12
|
+
if (wire.stack)
|
|
13
|
+
error.stack = wire.stack;
|
|
14
|
+
const code = safeCode(wire.code);
|
|
15
|
+
if (code)
|
|
16
|
+
error.code = code;
|
|
17
|
+
return error;
|
|
18
|
+
}
|