@loucompanion/forge-bridge 0.1.3-dev.9d8f2e7e5241 → 0.1.3-dev.a58d7cd1092f
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/README.md +83 -0
- package/dist/bridge/bridge.base.d.ts +4 -1
- package/dist/bridge/bridge.service.d.ts +55 -3
- package/dist/bridge/bridge.service.js +834 -87
- package/dist/bridge/bridge.types.d.ts +89 -1
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +14 -2
- package/dist/cleanup/cleanup.service.js +496 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +37 -3
- package/dist/cli/cli.service.d.ts +33 -2
- package/dist/cli/cli.service.js +209 -27
- package/dist/cli/local-config.d.ts +5 -0
- package/dist/cli/local-config.js +45 -1
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
- package/dist/runtime/attempt-process-supervisor.js +386 -0
- package/dist/runtime/docker-engine-probe.d.ts +83 -0
- package/dist/runtime/docker-engine-probe.js +300 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +46 -0
- package/dist/runtime/host-listener-inspector.js +349 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +197 -0
- package/dist/runtime/host-runtime-lease.js +2203 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/memory-capacity.d.ts +68 -0
- package/dist/runtime/memory-capacity.js +121 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +284 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/preflight.d.ts +45 -1
- package/dist/sandbox/preflight.js +299 -34
- package/dist/sandbox/process.js +21 -2
- package/dist/sandbox/repository-lock.js +41 -3
- package/dist/sandbox/runtime.d.ts +66 -3
- package/dist/sandbox/runtime.js +670 -105
- package/dist/sandbox/sandbox.types.d.ts +10 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.js +1 -1
- package/dist/sandbox/worker.js +2 -5
- package/dist/sandbox/workspace.service.d.ts +1 -0
- package/dist/sandbox/workspace.service.js +28 -5
- package/dist/session/managed-attempt-tools.d.ts +21 -0
- package/dist/session/managed-attempt-tools.js +912 -0
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +335 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +233 -24
- package/dist/session/phase-completion.d.ts +12 -2
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +51 -10
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +24 -8
- package/dist/session/phase-completion.runtime.d.ts +13 -1
- package/dist/session/phase-completion.runtime.js +189 -8
- package/dist/session/phase-completion.schemas.js +117 -4
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +153 -22
- package/dist/session/pre-publication-validation.d.ts +46 -0
- package/dist/session/pre-publication-validation.js +208 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
- package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
- package/dist/session/provider-cli/provider-cli.process.d.ts +25 -0
- package/dist/session/provider-cli/provider-cli.process.js +463 -34
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
- package/dist/session/provider-cli/provider-cli.prompts.js +108 -25
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +22 -2
- package/dist/session/provider-cli/provider-cli.service.js +529 -100
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +91 -16
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +38 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +522 -41
- package/dist/session/session.types.d.ts +72 -0
- package/dist/session/session.types.js +74 -3
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
- package/dist/shared/process/dotnet-toolchain.js +179 -0
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +59 -0
- package/dist/shared/process/host-process-supervisor.js +330 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/redaction/redaction.js +12 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +28 -1
- package/dist/worktree/dependency-warmer.js +308 -9
- package/dist/worktree/index.d.ts +2 -1
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +13 -2
- package/dist/worktree/worktree.service.js +238 -47
- package/dist/worktree/worktree.types.d.ts +5 -1
- package/package.json +5 -3
- package/runtime/windows-job-supervisor.ps1 +444 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { formatBytes } from "../shared/format/format-bytes.js";
|
|
6
|
+
import { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
|
|
7
|
+
import { RecoveryStabilizedMemory, resolveAdaptiveMemoryReserveBytes } from "../runtime/memory-capacity.js";
|
|
5
8
|
import { BoundedCommandRunner } from "./command-runner.js";
|
|
6
9
|
const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
|
|
7
10
|
export class DockerPreflight {
|
|
@@ -9,17 +12,35 @@ export class DockerPreflight {
|
|
|
9
12
|
cacheMs;
|
|
10
13
|
generation = 0;
|
|
11
14
|
cached = null;
|
|
12
|
-
|
|
15
|
+
fullPreflightAt = 0;
|
|
16
|
+
staticPreflight = null;
|
|
17
|
+
inspectionInFlight = null;
|
|
18
|
+
lastCapacitySample = null;
|
|
13
19
|
options;
|
|
14
20
|
docker;
|
|
15
21
|
ownership = new DockerContextOwnershipLock();
|
|
16
22
|
diskPressureLatched = false;
|
|
23
|
+
memoryAvailability = new RecoveryStabilizedMemory();
|
|
17
24
|
constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 30_000) {
|
|
18
25
|
this.commands = commands;
|
|
19
26
|
this.cacheMs = cacheMs;
|
|
20
27
|
this.options = typeof options === "string" ? { dockerExecutable: options } : options;
|
|
21
28
|
this.docker = this.options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
|
|
22
29
|
}
|
|
30
|
+
unavailable(code, reason, features) {
|
|
31
|
+
const totalMemoryBytes = os.totalmem();
|
|
32
|
+
this.memoryAvailability.observe(0);
|
|
33
|
+
return unavailable(this.generation, code, reason, this.memoryReserveBytes(totalMemoryBytes), this.defaultAdmissionMemoryBytes(), totalMemoryBytes, features);
|
|
34
|
+
}
|
|
35
|
+
memoryReserveBytes(totalMemoryBytes) {
|
|
36
|
+
return resolveSandboxHostMemoryReserveBytes(totalMemoryBytes, this.options.hostMemoryReserveBytes);
|
|
37
|
+
}
|
|
38
|
+
defaultAdmissionMemoryBytes() {
|
|
39
|
+
const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
|
|
40
|
+
const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
|
|
41
|
+
const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
|
|
42
|
+
return workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes;
|
|
43
|
+
}
|
|
23
44
|
current() {
|
|
24
45
|
return this.cached;
|
|
25
46
|
}
|
|
@@ -35,25 +56,97 @@ export class DockerPreflight {
|
|
|
35
56
|
await this.ownership.release();
|
|
36
57
|
}
|
|
37
58
|
async run(force = false) {
|
|
38
|
-
|
|
59
|
+
const inFlight = this.inspectionInFlight;
|
|
60
|
+
if (inFlight) {
|
|
61
|
+
const result = await inFlight.promise;
|
|
62
|
+
if (inFlight.kind === "full" || (!force && Date.now() - this.fullPreflightAt < this.cacheMs)) {
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
return await this.run(force);
|
|
66
|
+
}
|
|
67
|
+
if (!force && this.cached && Date.now() - this.fullPreflightAt < this.cacheMs)
|
|
39
68
|
return this.cached;
|
|
69
|
+
return await this.startInspection("full", () => this.inspect().catch((error) => this.unavailable("docker_unreachable", error instanceof Error ? error.message : String(error))), true);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Refreshes only the admission inputs that change while the daemon's static
|
|
73
|
+
* capability snapshot remains valid. Heartbeats use this path so periodic
|
|
74
|
+
* liveness reporting does not wake Docker/WSL to rerun feature probes.
|
|
75
|
+
*/
|
|
76
|
+
async refreshCapacity() {
|
|
77
|
+
if (this.inspectionInFlight) {
|
|
78
|
+
return await this.inspectionInFlight.promise;
|
|
79
|
+
}
|
|
80
|
+
const staticPreflight = this.staticPreflight;
|
|
81
|
+
if (!staticPreflight || !this.cached || !canRunDockerMaintenance(this.cached)) {
|
|
82
|
+
return this.cached ?? this.unavailable("docker_preflight_required", "Docker capability preflight has not completed.");
|
|
83
|
+
}
|
|
84
|
+
return await this.startInspection("capacity", () => this.inspectCapacity(staticPreflight).catch((error) => this.unavailable("docker_capacity_unavailable", error instanceof Error ? error.message : String(error), staticPreflight.features)), false);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* When the cached static preflight remains usable, produces a capacity sample
|
|
88
|
+
* that began after this admission request. An older inspection is awaited,
|
|
89
|
+
* not preempted or reused. Later admission callers may share the next sample
|
|
90
|
+
* only when it began after they entered this method. Without a usable static
|
|
91
|
+
* snapshot, the existing fail-closed result is returned without probing.
|
|
92
|
+
*/
|
|
93
|
+
async refreshCapacityForAdmission() {
|
|
94
|
+
const generationAtRequest = this.generation;
|
|
95
|
+
while (true) {
|
|
96
|
+
const inFlight = this.inspectionInFlight;
|
|
97
|
+
if (inFlight) {
|
|
98
|
+
if (inFlight.kind === "capacity" && inFlight.generation > generationAtRequest) {
|
|
99
|
+
return await inFlight.promise;
|
|
100
|
+
}
|
|
101
|
+
await inFlight.promise;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const lastCapacitySample = this.lastCapacitySample;
|
|
105
|
+
if (lastCapacitySample
|
|
106
|
+
&& lastCapacitySample.generation > generationAtRequest
|
|
107
|
+
&& lastCapacitySample.generation === this.generation
|
|
108
|
+
&& lastCapacitySample.result === this.cached) {
|
|
109
|
+
return lastCapacitySample.result;
|
|
110
|
+
}
|
|
111
|
+
const staticPreflight = this.staticPreflight;
|
|
112
|
+
if (!staticPreflight || !this.cached || !canRunDockerMaintenance(this.cached)) {
|
|
113
|
+
return this.cached ?? this.unavailable("docker_preflight_required", "Docker capability preflight has not completed.");
|
|
114
|
+
}
|
|
115
|
+
return await this.startInspection("capacity", () => this.inspectCapacity(staticPreflight).catch((error) => this.unavailable("docker_capacity_unavailable", error instanceof Error ? error.message : String(error), staticPreflight.features)), false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
startInspection(kind, inspect, full) {
|
|
40
119
|
this.generation += 1;
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
120
|
+
const generation = this.generation;
|
|
121
|
+
const operation = Promise.resolve()
|
|
122
|
+
.then(inspect)
|
|
123
|
+
.then((result) => {
|
|
124
|
+
this.cached = result;
|
|
125
|
+
if (kind === "capacity")
|
|
126
|
+
this.lastCapacitySample = { generation, result };
|
|
127
|
+
if (full)
|
|
128
|
+
this.fullPreflightAt = Date.now();
|
|
129
|
+
return result;
|
|
130
|
+
})
|
|
131
|
+
.finally(() => {
|
|
132
|
+
if (this.inspectionInFlight?.promise === operation) {
|
|
133
|
+
this.inspectionInFlight = null;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
this.inspectionInFlight = { kind, generation, promise: operation };
|
|
137
|
+
return operation;
|
|
45
138
|
}
|
|
46
139
|
async inspect() {
|
|
47
140
|
if (!new Set(["linux", "darwin", "win32"]).has(process.platform)) {
|
|
48
|
-
return unavailable(
|
|
141
|
+
return this.unavailable("platform_unsupported", `Docker sandboxes are not supported on ${process.platform}.`);
|
|
49
142
|
}
|
|
50
143
|
const dockerArgs = this.options.dockerContext ? ["--context", this.options.dockerContext] : [];
|
|
51
144
|
const infoResult = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
|
|
52
145
|
if (infoResult.exitCode !== 0)
|
|
53
|
-
return unavailable(
|
|
146
|
+
return this.unavailable("docker_unreachable", singleLine(infoResult.stderr || infoResult.stdout || "Docker daemon is unreachable."));
|
|
54
147
|
const info = parseInfo(infoResult.stdout);
|
|
55
148
|
if (String(info.OSType ?? "").toLowerCase() !== "linux")
|
|
56
|
-
return unavailable(
|
|
149
|
+
return this.unavailable("docker_linux_required", "Forge sandbox images require a Linux Docker daemon.");
|
|
57
150
|
const securityOptions = strings(info.SecurityOptions).map((value) => value.toLowerCase());
|
|
58
151
|
const rootless = securityOptions.some((value) => value.includes("rootless"));
|
|
59
152
|
const userNamespaces = rootless || securityOptions.some((value) => value.includes("userns"));
|
|
@@ -65,7 +158,7 @@ export class DockerPreflight {
|
|
|
65
158
|
const desktopDeveloperMode = process.platform !== "linux" && process.env.FORGE_ALLOW_DOCKER_DESKTOP_SANDBOX === "true";
|
|
66
159
|
const requireRootless = this.options.requireRootless ?? process.env.FORGE_SANDBOX_REQUIRE_ROOTLESS !== "false";
|
|
67
160
|
if (!rootless && !desktopDeveloperMode && requireRootless) {
|
|
68
|
-
return unavailable(
|
|
161
|
+
return this.unavailable("docker_rootless_required", "Docker daemon is reachable but is not running rootless.", {
|
|
69
162
|
rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy: false, compose: false, internalNetwork: true, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
|
|
70
163
|
});
|
|
71
164
|
}
|
|
@@ -81,7 +174,7 @@ export class DockerPreflight {
|
|
|
81
174
|
&& buildxPruneHelp?.exitCode === 0
|
|
82
175
|
&& ["--max-used-space", "--min-free-space", "--reserved-space"].every(flag => buildxPruneHelp.stdout.includes(flag))
|
|
83
176
|
&& containerUpdateHelp?.exitCode === 0
|
|
84
|
-
&& containerUpdateHelp.stdout.includes(
|
|
177
|
+
&& ["--pids-limit", "--memory", "--memory-swap"].every(flag => containerUpdateHelp.stdout.includes(flag));
|
|
85
178
|
const imagePolicy = process.env.FORGE_SANDBOX_IMAGE_POLICY_ENFORCED === "true"
|
|
86
179
|
&& Boolean(process.env.FORGE_SANDBOX_SBOM_EXECUTABLE?.trim())
|
|
87
180
|
&& Boolean(process.env.FORGE_SANDBOX_VULNERABILITY_EXECUTABLE?.trim())
|
|
@@ -99,50 +192,154 @@ export class DockerPreflight {
|
|
|
99
192
|
const dockerRoot = process.platform === "linux" && typeof info.DockerRootDir === "string" && path.isAbsolute(info.DockerRootDir)
|
|
100
193
|
? info.DockerRootDir
|
|
101
194
|
: configuredStateRoot;
|
|
102
|
-
const
|
|
195
|
+
const dockerDesktop = process.platform !== "linux"
|
|
196
|
+
&& String(info.OperatingSystem ?? "").toLowerCase().includes("docker desktop");
|
|
197
|
+
const reportedDockerMemoryBytes = Number(info.MemTotal);
|
|
198
|
+
const features = { rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy, compose, internalNetwork: true, buildkit, imagePolicy, securityProfile, egressProxy };
|
|
199
|
+
const staticPreflight = {
|
|
200
|
+
dockerArgs,
|
|
201
|
+
dockerRoot,
|
|
202
|
+
dockerDesktop,
|
|
203
|
+
dockerMemoryBytes: Number.isFinite(reportedDockerMemoryBytes) && reportedDockerMemoryBytes > 0
|
|
204
|
+
? reportedDockerMemoryBytes
|
|
205
|
+
: null,
|
|
206
|
+
runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
|
|
207
|
+
features,
|
|
208
|
+
};
|
|
209
|
+
this.staticPreflight = staticPreflight;
|
|
210
|
+
return await this.inspectCapacity(staticPreflight);
|
|
211
|
+
}
|
|
212
|
+
async inspectCapacity(staticPreflight) {
|
|
213
|
+
const disk = await diskSpace(staticPreflight.dockerRoot);
|
|
103
214
|
const requiredWorkspaceBytes = this.options.minimumWorkspaceBytes ?? minimumWorkspaceBytes;
|
|
104
215
|
const highWatermark = Math.min(99, Math.max(1, this.options.diskHighWatermarkPercent ?? 90));
|
|
105
216
|
const lowWatermark = Math.min(highWatermark - 1, Math.max(0, this.options.diskLowWatermarkPercent ?? highWatermark - 10));
|
|
106
217
|
const diskLow = evaluateDiskPressure(disk.available, disk.total, requiredWorkspaceBytes, highWatermark, lowWatermark, this.diskPressureLatched);
|
|
107
218
|
this.diskPressureLatched = diskLow;
|
|
108
|
-
const
|
|
219
|
+
const requiredAvailableBytes = requiredAvailableBytesToClearDiskPressure(disk.total, requiredWorkspaceBytes, lowWatermark);
|
|
220
|
+
const rawHostAvailableMemoryBytesBefore = Math.max(0, os.freemem());
|
|
221
|
+
const hostTotalMemoryBytes = os.totalmem();
|
|
222
|
+
let totalMemoryBytes = hostTotalMemoryBytes;
|
|
223
|
+
// Native Docker shares the host capacity domain. Docker Desktop has a
|
|
224
|
+
// second VM/daemon domain, so neither domain may be treated as a substitute
|
|
225
|
+
// for the other when deciding whether new committed memory is safe.
|
|
226
|
+
let rawDaemonAvailableMemoryBytes = rawHostAvailableMemoryBytesBefore;
|
|
227
|
+
let daemonMemorySampleFailure = null;
|
|
228
|
+
if (staticPreflight.dockerDesktop) {
|
|
229
|
+
rawDaemonAvailableMemoryBytes = null;
|
|
230
|
+
if (staticPreflight.dockerMemoryBytes) {
|
|
231
|
+
totalMemoryBytes = staticPreflight.dockerMemoryBytes;
|
|
232
|
+
const stats = await this.commands.run(this.docker, [...staticPreflight.dockerArgs, "stats", "--no-stream", "--format", "{{.MemUsage}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 }).catch(() => null);
|
|
233
|
+
if (stats?.exitCode === 0) {
|
|
234
|
+
let dockerUsedMemoryBytes = 0;
|
|
235
|
+
for (const line of stats.stdout.split(/\r?\n/gu).map(value => value.trim()).filter(Boolean)) {
|
|
236
|
+
const usage = tryParseDockerMemoryUsage(line);
|
|
237
|
+
if (usage === null) {
|
|
238
|
+
daemonMemorySampleFailure = "Docker returned an invalid memory-usage sample.";
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
dockerUsedMemoryBytes += usage;
|
|
242
|
+
}
|
|
243
|
+
if (daemonMemorySampleFailure === null) {
|
|
244
|
+
rawDaemonAvailableMemoryBytes = Math.max(0, staticPreflight.dockerMemoryBytes - dockerUsedMemoryBytes);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
daemonMemorySampleFailure = singleLine(stats?.stderr || stats?.stdout || "Docker Desktop memory capacity could not be sampled.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
daemonMemorySampleFailure = "Docker Desktop did not report a valid memory-capacity limit.";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const rawHostAvailableMemoryBytesAfter = Math.max(0, os.freemem());
|
|
256
|
+
const rawHostAvailableMemoryBytes = Math.min(rawHostAvailableMemoryBytesBefore, rawHostAvailableMemoryBytesAfter);
|
|
257
|
+
if (!staticPreflight.dockerDesktop) {
|
|
258
|
+
rawDaemonAvailableMemoryBytes = rawHostAvailableMemoryBytes;
|
|
259
|
+
}
|
|
260
|
+
const sampledAt = new Date().toISOString();
|
|
261
|
+
const rawAvailableMemoryBytes = rawDaemonAvailableMemoryBytes === null
|
|
262
|
+
? 0
|
|
263
|
+
: Math.min(rawHostAvailableMemoryBytes, rawDaemonAvailableMemoryBytes);
|
|
264
|
+
const availableMemoryBytes = this.memoryAvailability.observe(rawAvailableMemoryBytes);
|
|
109
265
|
const cpuCount = Math.max(1, os.availableParallelism?.() ?? os.cpus().length);
|
|
110
266
|
const availableCpuMillis = Math.max(0, (cpuCount - 1) * 1000);
|
|
111
|
-
|
|
112
|
-
|
|
267
|
+
// The reserve protects the physical host and its control plane. Docker
|
|
268
|
+
// Desktop's VM total remains the independent daemon commitment ceiling,
|
|
269
|
+
// but must not shrink the reserve derived from the Windows host.
|
|
270
|
+
const memoryReserveBytes = this.memoryReserveBytes(hostTotalMemoryBytes);
|
|
271
|
+
const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
|
|
272
|
+
const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
|
|
273
|
+
const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
|
|
274
|
+
const sandboxMemoryBytes = workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes;
|
|
275
|
+
const minimumAdmissionMemoryBytes = 128 * 1024 ** 2 + Math.max(128 * 1024 ** 2, runtimeOverheadBytes);
|
|
113
276
|
const sandboxCpuMillis = 2_000;
|
|
114
|
-
const memorySlots = Math.max(0, Math.floor((availableMemoryBytes - memoryReserveBytes) / sandboxMemoryBytes));
|
|
115
277
|
const cpuSlots = Math.max(0, Math.floor(availableCpuMillis / sandboxCpuMillis));
|
|
116
278
|
const configuredMax = positiveInteger(this.options.maxConcurrentSandboxes, positiveInteger(process.env.FORGE_MAX_CONCURRENT_SANDBOXES, 3));
|
|
117
|
-
const
|
|
118
|
-
const
|
|
279
|
+
const availableForAdmissionBytes = Math.max(0, availableMemoryBytes - memoryReserveBytes);
|
|
280
|
+
const capacityLowCode = !diskLow && rawDaemonAvailableMemoryBytes === null
|
|
281
|
+
? "docker_memory_sample_unavailable"
|
|
282
|
+
: !diskLow && availableForAdmissionBytes < minimumAdmissionMemoryBytes
|
|
283
|
+
? "docker_memory_low"
|
|
284
|
+
: !diskLow && cpuSlots <= 0
|
|
285
|
+
? "docker_cpu_low"
|
|
286
|
+
: null;
|
|
287
|
+
const maxConcurrentSandboxes = diskLow || capacityLowCode ? 0 : Math.min(3, configuredMax, cpuSlots);
|
|
119
288
|
const unavailableCode = diskLow ? "docker_disk_low" : capacityLowCode;
|
|
120
289
|
const unavailableReason = diskLow
|
|
121
|
-
? `Docker workspace has
|
|
122
|
-
: unavailableCode === "
|
|
123
|
-
? `Docker
|
|
124
|
-
: unavailableCode === "
|
|
125
|
-
?
|
|
126
|
-
|
|
127
|
-
|
|
290
|
+
? `Docker workspace has ${formatBytes(disk.available)} available · ${formatBytes(requiredAvailableBytes)} required to clear disk pressure.`
|
|
291
|
+
: unavailableCode === "docker_memory_sample_unavailable"
|
|
292
|
+
? `Docker Desktop memory capacity is unavailable (${daemonMemorySampleFailure ?? "unknown sampler failure"}); the host reports ${formatBytes(rawHostAvailableMemoryBytes)} free, but Forge will not admit a sandbox until both memory domains are known.`
|
|
293
|
+
: unavailableCode === "docker_memory_low"
|
|
294
|
+
? `${staticPreflight.dockerDesktop
|
|
295
|
+
? `The host reports ${formatBytes(rawHostAvailableMemoryBytes)} free and the Docker domain reports ${formatBytes(rawDaemonAvailableMemoryBytes ?? 0)} free; Forge uses the conservative ${formatBytes(rawAvailableMemoryBytes)} bound`
|
|
296
|
+
: `Docker reports ${formatBytes(rawAvailableMemoryBytes)} free`} and reserves ${formatBytes(memoryReserveBytes)} for the host, leaving ${formatBytes(availableForAdmissionBytes)} for admission; the smallest supported sandbox requires ${formatBytes(minimumAdmissionMemoryBytes)}. Exact attempt memory is calculated dynamically from its worker profile, sidecars, and runtime helpers.`
|
|
297
|
+
: unavailableCode === "docker_cpu_low"
|
|
298
|
+
? `Docker host has ${availableCpuMillis} available CPU millis; at least ${sandboxCpuMillis} are needed for one sandbox.`
|
|
299
|
+
: null;
|
|
128
300
|
const capacityAvailable = unavailableCode === null;
|
|
129
301
|
return {
|
|
130
302
|
available: capacityAvailable,
|
|
131
303
|
healthy: capacityAvailable,
|
|
132
304
|
generation: this.generation,
|
|
133
|
-
|
|
305
|
+
sampledAt,
|
|
306
|
+
runtimeVersion: staticPreflight.runtimeVersion,
|
|
134
307
|
maxConcurrentSandboxes,
|
|
308
|
+
totalMemoryBytes,
|
|
309
|
+
rawHostAvailableMemoryBytes,
|
|
310
|
+
rawDaemonAvailableMemoryBytes,
|
|
311
|
+
rawAvailableMemoryBytes,
|
|
135
312
|
availableMemoryBytes,
|
|
313
|
+
hostReserveBytes: memoryReserveBytes,
|
|
314
|
+
admissionMemoryBytes: sandboxMemoryBytes,
|
|
136
315
|
availableCpuMillis,
|
|
137
316
|
availableWorkspaceBytes: disk.available,
|
|
138
317
|
diskLow,
|
|
139
318
|
unavailableCode,
|
|
140
319
|
unavailableReason,
|
|
141
|
-
features,
|
|
320
|
+
features: staticPreflight.features,
|
|
142
321
|
};
|
|
143
322
|
}
|
|
144
323
|
}
|
|
145
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Uses the same reviewed reserve vector as host-runtime admission. The input
|
|
326
|
+
* is always the physical host total; Docker Desktop's VM total is tracked as
|
|
327
|
+
* a separate daemon commitment domain.
|
|
328
|
+
*/
|
|
329
|
+
export function resolveSandboxHostMemoryReserveBytes(totalMemoryBytes, explicitBytes, environment = process.env) {
|
|
330
|
+
return resolveAdaptiveMemoryReserveBytes(totalMemoryBytes, {
|
|
331
|
+
explicitBytes,
|
|
332
|
+
floorBytes: positiveInteger(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_FLOOR_BYTES, 1024 ** 3),
|
|
333
|
+
ceilingBytes: positiveInteger(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_CEILING_BYTES, 4 * 1024 ** 3),
|
|
334
|
+
percent: positiveNumber(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_PERCENT, 12.5),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
const dockerMaintenanceUnavailableCodes = new Set([
|
|
338
|
+
"docker_disk_low",
|
|
339
|
+
"docker_memory_low",
|
|
340
|
+
"docker_memory_sample_unavailable",
|
|
341
|
+
"docker_cpu_low",
|
|
342
|
+
]);
|
|
146
343
|
export function canRunDockerMaintenance(preflight) {
|
|
147
344
|
return preflight.healthy || dockerMaintenanceUnavailableCodes.has(preflight.unavailableCode ?? "");
|
|
148
345
|
}
|
|
@@ -156,39 +353,59 @@ export function evaluateDiskPressure(availableBytes, totalBytes, minimumAvailabl
|
|
|
156
353
|
return true;
|
|
157
354
|
return pressureLatched && usedPercent > lowWatermarkPercent;
|
|
158
355
|
}
|
|
356
|
+
export function requiredAvailableBytesToClearDiskPressure(totalBytes, minimumAvailableBytes, lowWatermarkPercent) {
|
|
357
|
+
const watermarkBytes = totalBytes > 0
|
|
358
|
+
? Math.ceil(totalBytes * (1 - Math.min(100, Math.max(0, lowWatermarkPercent)) / 100))
|
|
359
|
+
: 0;
|
|
360
|
+
return Math.max(0, minimumAvailableBytes, watermarkBytes);
|
|
361
|
+
}
|
|
159
362
|
export function storagePolicyAvailable(storageQuota, environment = process.env) {
|
|
160
363
|
return storageQuota || environment.FORGE_SANDBOX_DEDICATED_DISK === "true";
|
|
161
364
|
}
|
|
162
365
|
export class DockerContextOwnershipLock {
|
|
163
366
|
lockPath = null;
|
|
367
|
+
lockToken = null;
|
|
164
368
|
async acquire(contextName) {
|
|
165
369
|
if (this.lockPath)
|
|
166
370
|
return;
|
|
167
371
|
contextName = contextName?.trim() || process.env.DOCKER_CONTEXT?.trim() || process.env.DOCKER_HOST?.trim() || "default";
|
|
168
372
|
const key = createHash("sha256").update(contextName).digest("hex").slice(0, 24);
|
|
169
373
|
const lockPath = path.join(os.tmpdir(), `forge-docker-context-${key}.lock`);
|
|
374
|
+
const token = randomUUID();
|
|
170
375
|
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
171
376
|
try {
|
|
172
377
|
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
173
|
-
await handle.writeFile(JSON.stringify({
|
|
378
|
+
await handle.writeFile(JSON.stringify({
|
|
379
|
+
schemaVersion: 2,
|
|
380
|
+
token,
|
|
381
|
+
pid: process.pid,
|
|
382
|
+
processIdentity: captureHostProcessIdentity(process.pid),
|
|
383
|
+
contextName,
|
|
384
|
+
acquiredAt: new Date().toISOString(),
|
|
385
|
+
}));
|
|
174
386
|
await handle.sync();
|
|
175
387
|
await handle.close();
|
|
176
388
|
}
|
|
177
389
|
catch (error) {
|
|
178
390
|
const owner = await readLock(lockPath);
|
|
179
|
-
if (
|
|
391
|
+
if (!dockerContextOwnerIsActive(owner)) {
|
|
180
392
|
await fs.rm(lockPath, { force: true });
|
|
181
393
|
return await this.acquire(contextName);
|
|
182
394
|
}
|
|
183
395
|
throw new Error(`docker_context_owned: Docker context '${contextName}' is already owned by Forge runner pid ${owner?.pid ?? "unknown"}.`, { cause: error });
|
|
184
396
|
}
|
|
185
397
|
this.lockPath = lockPath;
|
|
398
|
+
this.lockToken = token;
|
|
186
399
|
}
|
|
187
400
|
async release() {
|
|
188
401
|
if (!this.lockPath)
|
|
189
402
|
return;
|
|
190
|
-
await
|
|
403
|
+
const owner = await readLock(this.lockPath);
|
|
404
|
+
if (owner?.token === this.lockToken) {
|
|
405
|
+
await fs.rm(this.lockPath, { force: true });
|
|
406
|
+
}
|
|
191
407
|
this.lockPath = null;
|
|
408
|
+
this.lockToken = null;
|
|
192
409
|
}
|
|
193
410
|
}
|
|
194
411
|
function parseInfo(stdout) {
|
|
@@ -202,16 +419,24 @@ function parseInfo(stdout) {
|
|
|
202
419
|
throw new Error("Docker returned an invalid info document.", { cause: error });
|
|
203
420
|
}
|
|
204
421
|
}
|
|
205
|
-
function unavailable(generation, code, reason, features = {
|
|
422
|
+
function unavailable(generation, code, reason, hostReserveBytes, admissionMemoryBytes, totalMemoryBytes, features = {
|
|
206
423
|
rootless: false, userNamespaces: false, cgroupV2: false, seccomp: false, lsm: false, storageQuota: false, storagePolicy: false, compose: false, internalNetwork: false, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
|
|
207
424
|
}) {
|
|
425
|
+
const rawHostAvailableMemoryBytes = Math.max(0, os.freemem());
|
|
208
426
|
return {
|
|
209
427
|
available: false,
|
|
210
428
|
healthy: false,
|
|
211
429
|
generation,
|
|
430
|
+
sampledAt: new Date().toISOString(),
|
|
212
431
|
runtimeVersion: null,
|
|
213
432
|
maxConcurrentSandboxes: 0,
|
|
214
|
-
|
|
433
|
+
totalMemoryBytes,
|
|
434
|
+
rawHostAvailableMemoryBytes,
|
|
435
|
+
rawDaemonAvailableMemoryBytes: null,
|
|
436
|
+
rawAvailableMemoryBytes: rawHostAvailableMemoryBytes,
|
|
437
|
+
availableMemoryBytes: rawHostAvailableMemoryBytes,
|
|
438
|
+
hostReserveBytes,
|
|
439
|
+
admissionMemoryBytes,
|
|
215
440
|
availableCpuMillis: Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) * 1000),
|
|
216
441
|
availableWorkspaceBytes: 0,
|
|
217
442
|
diskLow: code === "docker_disk_low",
|
|
@@ -242,9 +467,49 @@ function positiveInteger(value, fallback) {
|
|
|
242
467
|
function singleLine(value) {
|
|
243
468
|
return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
|
|
244
469
|
}
|
|
470
|
+
function nonNegativeInteger(value, fallback) {
|
|
471
|
+
const parsed = Number(value);
|
|
472
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
473
|
+
}
|
|
474
|
+
function positiveNumber(value, fallback) {
|
|
475
|
+
const parsed = Number(value);
|
|
476
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
477
|
+
}
|
|
478
|
+
function tryParseDockerMemoryUsage(value) {
|
|
479
|
+
const usage = value.split("/", 1)[0]?.trim().replaceAll(" ", "") ?? "";
|
|
480
|
+
const match = /^(\d+(?:\.\d+)?)(b|kib|mib|gib|tib)$/iu.exec(usage);
|
|
481
|
+
if (!match)
|
|
482
|
+
return null;
|
|
483
|
+
const amount = Number(match[1]);
|
|
484
|
+
const multiplier = { b: 1, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3, tib: 1024 ** 4 }[match[2].toLowerCase()];
|
|
485
|
+
return Number.isFinite(amount) ? Math.round(amount * multiplier) : null;
|
|
486
|
+
}
|
|
487
|
+
export function parseDockerMemoryUsage(value) {
|
|
488
|
+
return tryParseDockerMemoryUsage(value) ?? 0;
|
|
489
|
+
}
|
|
245
490
|
async function readLock(lockPath) {
|
|
246
491
|
return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
|
|
247
492
|
}
|
|
493
|
+
export function dockerContextOwnerIsActive(owner) {
|
|
494
|
+
if (!owner || !Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0 || !processExists(owner.pid)) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
const liveIdentity = captureHostProcessIdentity(owner.pid);
|
|
498
|
+
if (owner.processIdentity) {
|
|
499
|
+
// A live but unverifiable process must remain protected. Windows and Linux
|
|
500
|
+
// normally provide a birth marker, so a mismatch is positive PID-reuse proof.
|
|
501
|
+
return !liveIdentity || liveIdentity === owner.processIdentity;
|
|
502
|
+
}
|
|
503
|
+
if (process.platform === "win32" && liveIdentity?.startsWith("win32:") && owner.acquiredAt) {
|
|
504
|
+
const ticks = Number(liveIdentity.slice("win32:".length));
|
|
505
|
+
const acquiredAt = Date.parse(owner.acquiredAt);
|
|
506
|
+
const processStartedAt = ticks / 10_000 - 62_135_596_800_000;
|
|
507
|
+
if (Number.isFinite(processStartedAt) && Number.isFinite(acquiredAt) && processStartedAt > acquiredAt) {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
248
513
|
function processExists(pid) {
|
|
249
514
|
try {
|
|
250
515
|
process.kill(pid, 0);
|
package/dist/sandbox/process.js
CHANGED
|
@@ -2,6 +2,12 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
3
|
const terminationSigkillDelayMs = 5_000;
|
|
4
4
|
const forcedCloseSettleDelayMs = 5_000;
|
|
5
|
+
const expectedStdinCloseErrorCodes = new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_DESTROYED"]);
|
|
6
|
+
function isExpectedStdinCloseError(error) {
|
|
7
|
+
return "code" in error
|
|
8
|
+
&& typeof error.code === "string"
|
|
9
|
+
&& expectedStdinCloseErrorCodes.has(error.code);
|
|
10
|
+
}
|
|
5
11
|
export async function runSandboxProcess(request) {
|
|
6
12
|
const limit = Math.max(1024, request.maxOutputBytes ?? 1024 * 1024);
|
|
7
13
|
return await new Promise((resolve, reject) => {
|
|
@@ -161,12 +167,21 @@ export async function runSandboxProcess(request) {
|
|
|
161
167
|
reject(error);
|
|
162
168
|
}
|
|
163
169
|
};
|
|
170
|
+
const onStdinError = (error) => {
|
|
171
|
+
if (isExpectedStdinCloseError(error))
|
|
172
|
+
return;
|
|
173
|
+
onProcessError(error);
|
|
174
|
+
};
|
|
164
175
|
const onProcessClose = (code) => {
|
|
165
176
|
void settle(code, false);
|
|
166
177
|
};
|
|
167
178
|
child.stdout.on("data", onStdoutData);
|
|
168
179
|
child.stderr.on("data", onStderrData);
|
|
169
180
|
child.on("error", onProcessError);
|
|
181
|
+
child.on("close", onProcessClose);
|
|
182
|
+
// Keep this listener for the lifetime of stdin. A pipe error can be emitted
|
|
183
|
+
// after the child close event has settled the supervisor promise.
|
|
184
|
+
child.stdin?.on("error", onStdinError);
|
|
170
185
|
timeout = request.timeoutMs
|
|
171
186
|
? setTimeout(() => terminate(`process timeout after ${request.timeoutMs}ms`), request.timeoutMs)
|
|
172
187
|
: null;
|
|
@@ -176,9 +191,13 @@ export async function runSandboxProcess(request) {
|
|
|
176
191
|
abort();
|
|
177
192
|
}
|
|
178
193
|
if (request.input !== undefined) {
|
|
179
|
-
|
|
194
|
+
try {
|
|
195
|
+
child.stdin.end(request.input);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
onStdinError(error instanceof Error ? error : new Error(String(error)));
|
|
199
|
+
}
|
|
180
200
|
}
|
|
181
|
-
child.on("close", onProcessClose);
|
|
182
201
|
});
|
|
183
202
|
}
|
|
184
203
|
export async function requireSandboxProcess(request) {
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { resolvePathIdentity } from "../shared/path/path.js";
|
|
6
|
+
const LOCK_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
7
|
+
const LOCK_STALE_AFTER_MS = 2 * 60_000;
|
|
4
8
|
export class RepositoryCoordinatorLock {
|
|
5
9
|
stateRoot;
|
|
6
10
|
constructor(stateRoot) {
|
|
7
11
|
this.stateRoot = stateRoot;
|
|
8
12
|
}
|
|
9
13
|
async withLock(repoPath, action, signal) {
|
|
10
|
-
const resolved =
|
|
11
|
-
const
|
|
14
|
+
const resolved = await resolvePathIdentity(repoPath);
|
|
15
|
+
const keyInput = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
16
|
+
const key = createHash("sha256").update(keyInput).digest("hex");
|
|
12
17
|
const root = path.join(this.stateRoot, "repo-locks");
|
|
13
18
|
const target = path.join(root, `${key}.lock`);
|
|
14
19
|
const token = randomUUID();
|
|
@@ -41,10 +46,12 @@ export class RepositoryCoordinatorLock {
|
|
|
41
46
|
await abortableDelay(50, signal);
|
|
42
47
|
}
|
|
43
48
|
}
|
|
49
|
+
const heartbeat = startHeartbeat(target, token);
|
|
44
50
|
try {
|
|
45
51
|
return await action();
|
|
46
52
|
}
|
|
47
53
|
finally {
|
|
54
|
+
clearInterval(heartbeat);
|
|
48
55
|
const owner = await readOwner(target);
|
|
49
56
|
if (owner?.token === token)
|
|
50
57
|
await fs.rm(target, { force: true });
|
|
@@ -58,7 +65,7 @@ async function staleLock(target) {
|
|
|
58
65
|
const owner = await readOwner(target);
|
|
59
66
|
if (!owner || typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0)
|
|
60
67
|
return true;
|
|
61
|
-
if (Date.now() - stat.mtimeMs
|
|
68
|
+
if (Date.now() - stat.mtimeMs >= LOCK_STALE_AFTER_MS)
|
|
62
69
|
return true;
|
|
63
70
|
try {
|
|
64
71
|
process.kill(owner.pid, 0);
|
|
@@ -68,6 +75,37 @@ async function staleLock(target) {
|
|
|
68
75
|
return true;
|
|
69
76
|
}
|
|
70
77
|
}
|
|
78
|
+
function startHeartbeat(target, token) {
|
|
79
|
+
let refreshing = false;
|
|
80
|
+
const timer = setInterval(() => {
|
|
81
|
+
if (refreshing)
|
|
82
|
+
return;
|
|
83
|
+
refreshing = true;
|
|
84
|
+
void refreshHeartbeat(target, token).finally(() => { refreshing = false; });
|
|
85
|
+
}, LOCK_HEARTBEAT_INTERVAL_MS);
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
return timer;
|
|
88
|
+
}
|
|
89
|
+
async function refreshHeartbeat(target, token) {
|
|
90
|
+
let handle = null;
|
|
91
|
+
try {
|
|
92
|
+
handle = await fs.open(target, fsConstants.O_RDWR | fsConstants.O_NOFOLLOW);
|
|
93
|
+
const stat = await handle.stat();
|
|
94
|
+
if (!stat.isFile() || stat.size > 16 * 1024)
|
|
95
|
+
return;
|
|
96
|
+
const owner = await handle.readFile("utf8").then(value => JSON.parse(value)).catch(() => null);
|
|
97
|
+
if (owner?.token !== token)
|
|
98
|
+
return;
|
|
99
|
+
const now = new Date();
|
|
100
|
+
await handle.utimes(now, now);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// A missing or replaced lock is no longer owned by this action.
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await handle?.close().catch(() => undefined);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
71
109
|
async function readOwner(target) {
|
|
72
110
|
return await fs.readFile(target, "utf8").then(value => JSON.parse(value)).catch(() => null);
|
|
73
111
|
}
|