start-command 0.35.3 → 0.36.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/CHANGELOG.md +12 -0
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/bin/cli.js +43 -7
- package/src/lib/args-parser.js +12 -0
- package/src/lib/atomic-write.js +34 -0
- package/src/lib/attached-diagnostics.js +90 -0
- package/src/lib/cgroup-memory.js +43 -11
- package/src/lib/command-builder.js +40 -0
- package/src/lib/cpu-penalty-monitor.js +226 -0
- package/src/lib/cpu-penalty.js +175 -0
- package/src/lib/detached-finalize.js +47 -7
- package/src/lib/detached-output.js +61 -0
- package/src/lib/docker-cleanup.js +43 -4
- package/src/lib/docker-resource-limits.js +28 -0
- package/src/lib/docker-resource-options.js +180 -0
- package/src/lib/docker-runtime-args.js +15 -0
- package/src/lib/execution-attempt.js +153 -0
- package/src/lib/execution-recovery.js +191 -15
- package/src/lib/execution-resume-all.js +23 -1
- package/src/lib/execution-resume.js +203 -40
- package/src/lib/execution-store.js +56 -121
- package/src/lib/exit-evidence.js +123 -0
- package/src/lib/exit-reason.js +24 -6
- package/src/lib/isolation-log-utils.js +3 -3
- package/src/lib/isolation.js +36 -9
- package/src/lib/launch-owner.js +25 -0
- package/src/lib/launch-persistence.js +54 -0
- package/src/lib/query-commands.js +14 -3
- package/src/lib/resume-resources.js +88 -0
- package/src/lib/status-formatter.js +66 -6
- package/src/lib/store-lock.js +133 -0
- package/src/lib/usage.js +11 -0
- package/test/docker-runtime-options.js +6 -0
- package/test/execution-resume.js +10 -3
- package/test/exit-reason.js +6 -1
- package/test/isolation.js +5 -4
- package/test/regression-174.js +3 -1
- package/test/regression-176.js +5 -1
- package/test/regression-180.js +17 -10
- package/test/regression-182.js +7 -3
- package/test/regression-185.js +4 -9
- package/test/regression-187.js +547 -0
- package/test/regression-189.js +224 -0
- package/test/regression-190.js +111 -0
- package/test/regression-191.js +152 -0
- package/test/regression-192.js +66 -0
- package/test/regression-193.js +241 -0
- package/test/regression-194.js +182 -0
package/src/lib/exit-reason.js
CHANGED
|
@@ -181,8 +181,16 @@ function resolveMemoryExhaustion(input) {
|
|
|
181
181
|
return null;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
const evidence =
|
|
185
|
+
input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
|
|
186
|
+
if (evidence.daemonRestart) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
184
189
|
const marker = detectMemoryMarker(input.logTail);
|
|
185
|
-
if (
|
|
190
|
+
if (
|
|
191
|
+
marker &&
|
|
192
|
+
!(marker.reason.includes('kernel-oom-killer') && !evidence.mainOom)
|
|
193
|
+
) {
|
|
186
194
|
return { memoryExhausted: true, memoryExhaustedReason: marker.line };
|
|
187
195
|
}
|
|
188
196
|
if (input.oomKilled === true && isOomKillOfCommand(input)) {
|
|
@@ -230,9 +238,9 @@ function isOomKillOfCommand(input) {
|
|
|
230
238
|
if (!input) {
|
|
231
239
|
return false;
|
|
232
240
|
}
|
|
233
|
-
const
|
|
234
|
-
input.
|
|
235
|
-
if (!
|
|
241
|
+
const evidence =
|
|
242
|
+
input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
|
|
243
|
+
if (!evidence.mainOom || evidence.daemonRestart) {
|
|
236
244
|
return false;
|
|
237
245
|
}
|
|
238
246
|
const { exitCode } = input;
|
|
@@ -318,8 +326,16 @@ function resolveExitReason(input) {
|
|
|
318
326
|
return null;
|
|
319
327
|
}
|
|
320
328
|
|
|
329
|
+
const evidence =
|
|
330
|
+
input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
|
|
331
|
+
if (evidence.daemonRestart) {
|
|
332
|
+
return 'killed (docker daemon restart)';
|
|
333
|
+
}
|
|
321
334
|
const fromLog = detectExitReason(input.logTail);
|
|
322
|
-
if (
|
|
335
|
+
if (
|
|
336
|
+
fromLog &&
|
|
337
|
+
!(fromLog.includes('kernel-oom-killer') && !evidence.mainOom)
|
|
338
|
+
) {
|
|
323
339
|
return fromLog;
|
|
324
340
|
}
|
|
325
341
|
|
|
@@ -328,7 +344,9 @@ function resolveExitReason(input) {
|
|
|
328
344
|
}
|
|
329
345
|
|
|
330
346
|
const signalName = signalNameForExitCode(input.exitCode);
|
|
331
|
-
return signalName
|
|
347
|
+
return signalName
|
|
348
|
+
? `signal (${signalName}${signalName === 'SIGKILL' ? '; cause unknown' : ''})`
|
|
349
|
+
: null;
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
module.exports = {
|
|
@@ -240,16 +240,16 @@ const FATAL_MARKER_TAIL_BYTES = 64 * 1024;
|
|
|
240
240
|
* @param {number} [bytes] - Maximum number of trailing bytes to read
|
|
241
241
|
* @returns {string|null} Tail content, or null when the file cannot be read
|
|
242
242
|
*/
|
|
243
|
-
function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
|
|
243
|
+
function readLogTail(logPath, bytes = LOG_TAIL_BYTES, offset = 0) {
|
|
244
244
|
let fd;
|
|
245
245
|
try {
|
|
246
246
|
fd = fs.openSync(logPath, 'r');
|
|
247
247
|
const size = fs.fstatSync(fd).size;
|
|
248
|
-
const length = Math.min(size, bytes);
|
|
248
|
+
const length = Math.min(Math.max(0, size - offset), bytes);
|
|
249
249
|
const buffer = Buffer.alloc(length);
|
|
250
250
|
fs.readSync(fd, buffer, 0, length, size - length);
|
|
251
251
|
const tail = buffer.toString('utf8');
|
|
252
|
-
if (size
|
|
252
|
+
if (size - length === offset || length === 0) {
|
|
253
253
|
return tail;
|
|
254
254
|
}
|
|
255
255
|
const firstNewline = tail.indexOf('\n');
|
package/src/lib/isolation.js
CHANGED
|
@@ -547,6 +547,23 @@ function runInDocker(command, options = {}) {
|
|
|
547
547
|
});
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
if (!options.resolvedLimits) {
|
|
551
|
+
try {
|
|
552
|
+
Object.assign(
|
|
553
|
+
options,
|
|
554
|
+
require('./docker-resource-options').resolveResourceOptions(
|
|
555
|
+
options,
|
|
556
|
+
require('./execution-control').runCommand
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return Promise.resolve({
|
|
561
|
+
success: false,
|
|
562
|
+
containerName: null,
|
|
563
|
+
message: error.message,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
550
567
|
const containerName = options.session || generateSessionName('docker');
|
|
551
568
|
const containerExistedBeforeLaunch =
|
|
552
569
|
readDockerContainerStatus(containerName) !== null;
|
|
@@ -659,13 +676,15 @@ function runInDocker(command, options = {}) {
|
|
|
659
676
|
}
|
|
660
677
|
}
|
|
661
678
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
679
|
+
if (!options.deferCompletionWatcher) {
|
|
680
|
+
startDetachedDockerCompletionWatcher(
|
|
681
|
+
containerName,
|
|
682
|
+
cleanupPolicy,
|
|
683
|
+
options.logPath,
|
|
684
|
+
options.executionId || null,
|
|
685
|
+
{ recoverOnKill: Boolean(options.onKillResume), cpuOptions: options }
|
|
686
|
+
);
|
|
687
|
+
}
|
|
669
688
|
|
|
670
689
|
let message = `Command started in detached docker container: ${containerName}`;
|
|
671
690
|
message += `\nContainer ID: ${containerId.substring(0, 12)}`;
|
|
@@ -743,9 +762,14 @@ function runInDocker(command, options = {}) {
|
|
|
743
762
|
const launchArgs = needsNetworkSetup
|
|
744
763
|
? ['start', '-a', '-i', containerName]
|
|
745
764
|
: dockerArgs;
|
|
765
|
+
const diagnostics =
|
|
766
|
+
require('./attached-diagnostics').startAttachedDiagnostics(
|
|
767
|
+
containerName,
|
|
768
|
+
options
|
|
769
|
+
);
|
|
746
770
|
const child = spawnAttachedDocker(launchArgs, options.logPath);
|
|
747
771
|
|
|
748
|
-
child.on('exit', (code) => {
|
|
772
|
+
child.on('exit', async (code) => {
|
|
749
773
|
const durationMs = Date.now() - startTime;
|
|
750
774
|
const exitCode = code ?? 1;
|
|
751
775
|
let message = `Docker container "${containerName}" exited with code ${exitCode}`;
|
|
@@ -760,6 +784,7 @@ function runInDocker(command, options = {}) {
|
|
|
760
784
|
}
|
|
761
785
|
|
|
762
786
|
const dockerState = readDockerContainerState(containerName);
|
|
787
|
+
const diagnosticFacts = await diagnostics.finish(dockerState);
|
|
763
788
|
const oomKilled = dockerState ? dockerState.oomKilled : null;
|
|
764
789
|
const postMortem = (removed) =>
|
|
765
790
|
recordAttachedDockerPostMortem({
|
|
@@ -804,12 +829,14 @@ function runInDocker(command, options = {}) {
|
|
|
804
829
|
resolve({
|
|
805
830
|
success: exitCode === 0,
|
|
806
831
|
containerName,
|
|
832
|
+
diagnostics: diagnosticFacts,
|
|
807
833
|
message,
|
|
808
834
|
exitCode,
|
|
809
835
|
});
|
|
810
836
|
});
|
|
811
837
|
|
|
812
|
-
child.on('error', (err) => {
|
|
838
|
+
child.on('error', async (err) => {
|
|
839
|
+
await diagnostics.finish(null);
|
|
813
840
|
resolve({
|
|
814
841
|
success: false,
|
|
815
842
|
containerName,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A dead launcher leaves a usable reservation, rather than a permanent gate. */
|
|
2
|
+
const os = require('os');
|
|
3
|
+
|
|
4
|
+
function markLaunch(record) {
|
|
5
|
+
record.options.launchPending = true;
|
|
6
|
+
record.options.launchOwner = { pid: process.pid, hostname: os.hostname() };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hasActiveLaunch(record) {
|
|
10
|
+
if (!record?.options?.launchPending) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const owner = record.options.launchOwner;
|
|
14
|
+
if (!owner || owner.hostname !== os.hostname()) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
process.kill(owner.pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return error.code !== 'ESRCH';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { markLaunch, hasActiveLaunch };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Reserve before launch, and identify/stop a launch whose final save failed. */
|
|
2
|
+
const { getDockerCommand } = require('./docker-cleanup');
|
|
3
|
+
|
|
4
|
+
function reserveLaunch(store, record, previous) {
|
|
5
|
+
require('./launch-owner').markLaunch(record);
|
|
6
|
+
if (typeof store.reserveLaunch === 'function') {
|
|
7
|
+
store.reserveLaunch(record, previous);
|
|
8
|
+
} else {
|
|
9
|
+
store.save(record);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rollbackLaunch(store, previous) {
|
|
14
|
+
try {
|
|
15
|
+
store.save(previous);
|
|
16
|
+
} catch {
|
|
17
|
+
/* The durable reservation still names the session. */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function failLaunchedPersistence(store, record, runner, error) {
|
|
22
|
+
const name = record.options.sessionName;
|
|
23
|
+
let stopped;
|
|
24
|
+
try {
|
|
25
|
+
stopped =
|
|
26
|
+
record.options.isolated === 'docker'
|
|
27
|
+
? runner(getDockerCommand(), ['stop', name])
|
|
28
|
+
: null;
|
|
29
|
+
} catch {
|
|
30
|
+
stopped = null;
|
|
31
|
+
}
|
|
32
|
+
const warning = {
|
|
33
|
+
code: 'LAUNCH_PERSISTENCE_FAILED',
|
|
34
|
+
uuid: record.uuid,
|
|
35
|
+
containerName: name,
|
|
36
|
+
running: !stopped?.success,
|
|
37
|
+
error: error.message,
|
|
38
|
+
};
|
|
39
|
+
if (stopped?.success) {
|
|
40
|
+
record.status = 'executed';
|
|
41
|
+
record.exitCode = -1;
|
|
42
|
+
record.endTime = new Date().toISOString();
|
|
43
|
+
record.endTimeSource = 'observed-at';
|
|
44
|
+
record.options.launchPending = false;
|
|
45
|
+
try {
|
|
46
|
+
store.save(record);
|
|
47
|
+
} catch {
|
|
48
|
+
/* Completion watcher remains attached. */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return JSON.stringify(warning);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { reserveLaunch, rollbackLaunch, failLaunchedPersistence };
|
|
@@ -55,9 +55,19 @@ function handleAttachQuery(store, identifier, readOnly) {
|
|
|
55
55
|
return reportResult(attachExecution(store, identifier, { readOnly }));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
async function handleResumeQuery(
|
|
58
|
+
async function handleResumeQuery(
|
|
59
|
+
store,
|
|
60
|
+
identifier,
|
|
61
|
+
command,
|
|
62
|
+
outputFormat,
|
|
63
|
+
resourceOptions = {}
|
|
64
|
+
) {
|
|
59
65
|
return reportResult(
|
|
60
|
-
await resumeExecution(store, identifier, {
|
|
66
|
+
await resumeExecution(store, identifier, {
|
|
67
|
+
command,
|
|
68
|
+
outputFormat,
|
|
69
|
+
resourceOptions,
|
|
70
|
+
})
|
|
61
71
|
);
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -174,7 +184,8 @@ async function dispatchQueryCommand(options, context = {}) {
|
|
|
174
184
|
store,
|
|
175
185
|
options.resume,
|
|
176
186
|
context.command || null,
|
|
177
|
-
outputFormat
|
|
187
|
+
outputFormat,
|
|
188
|
+
options
|
|
178
189
|
);
|
|
179
190
|
}
|
|
180
191
|
if (options.resumeAll) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const {
|
|
2
|
+
normalizeResourceLimits,
|
|
3
|
+
cpuCount,
|
|
4
|
+
withCpuLimit,
|
|
5
|
+
readDockerResourceLimits,
|
|
6
|
+
} = require('./docker-resource-limits');
|
|
7
|
+
const { resolveResourceOptions } = require('./docker-resource-options');
|
|
8
|
+
|
|
9
|
+
// Temporary penalty quotas must never become a resumed execution's base quota.
|
|
10
|
+
function restoreBaseCpu(limits, options) {
|
|
11
|
+
if (!options.cpuPenaltyConfig) {
|
|
12
|
+
return limits;
|
|
13
|
+
}
|
|
14
|
+
const base = normalizeResourceLimits(
|
|
15
|
+
options.baseResourceLimits || options.resourceLimits
|
|
16
|
+
);
|
|
17
|
+
return [
|
|
18
|
+
...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
19
|
+
...base.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function prepareResumeResources(record, probe, overrides, runner, random) {
|
|
24
|
+
const options = record.options || {};
|
|
25
|
+
if (options.isolated !== 'docker') {
|
|
26
|
+
if (
|
|
27
|
+
['memory', 'memorySwap', 'cpus'].some(
|
|
28
|
+
(k) => overrides[k] !== null && overrides[k] !== undefined
|
|
29
|
+
)
|
|
30
|
+
) {
|
|
31
|
+
throw new Error('Resource overrides require a Docker execution');
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const live =
|
|
36
|
+
probe.state === 'stopped'
|
|
37
|
+
? readDockerResourceLimits(options.sessionName, runner)
|
|
38
|
+
: null;
|
|
39
|
+
const base = restoreBaseCpu(
|
|
40
|
+
normalizeResourceLimits(live || options.resourceLimits),
|
|
41
|
+
options
|
|
42
|
+
);
|
|
43
|
+
const oldMemory = base.find((s) => s.startsWith('--memory='))?.slice(9);
|
|
44
|
+
const result = resolveResourceOptions(
|
|
45
|
+
{ ...overrides, resourceLimits: base },
|
|
46
|
+
runner,
|
|
47
|
+
random
|
|
48
|
+
);
|
|
49
|
+
if (overrides.cpus && probe.state === 'stopped') {
|
|
50
|
+
// Docker cannot switch an existing legacy quota to NanoCPUs in place.
|
|
51
|
+
const cpu = withCpuLimit(base, result.resolvedLimits.cpus);
|
|
52
|
+
result.resourceLimits = [
|
|
53
|
+
...result.resourceLimits.filter(
|
|
54
|
+
(s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)
|
|
55
|
+
),
|
|
56
|
+
...cpu.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
result.oldMemory = oldMemory
|
|
60
|
+
? require('./docker-resource-options').parseResourceSpec(oldMemory).amount
|
|
61
|
+
: 'unlimited';
|
|
62
|
+
result.resolvedLimits = {
|
|
63
|
+
...options.resolvedLimits,
|
|
64
|
+
...result.resolvedLimits,
|
|
65
|
+
};
|
|
66
|
+
result.resourceLimitSpecs = {
|
|
67
|
+
...options.resourceLimitSpecs,
|
|
68
|
+
...result.resourceLimitSpecs,
|
|
69
|
+
};
|
|
70
|
+
result.update =
|
|
71
|
+
probe.state === 'stopped' &&
|
|
72
|
+
(['memory', 'memorySwap', 'cpus'].some(
|
|
73
|
+
(k) => overrides[k] !== null && overrides[k] !== undefined
|
|
74
|
+
) ||
|
|
75
|
+
Boolean(options.cpuPenaltyConfig));
|
|
76
|
+
if (options.cpuPenaltyConfig && !overrides.cpus) {
|
|
77
|
+
result.resourceLimits = clampCpuToDaemon(result.resourceLimits, runner);
|
|
78
|
+
result.resolvedLimits.cpus = cpuCount(result.resourceLimits);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clampCpuToDaemon(limits, runner) {
|
|
84
|
+
const { readDaemonCapacity } = require('./docker-resource-options');
|
|
85
|
+
const capacity = readDaemonCapacity(runner).cpus;
|
|
86
|
+
return withCpuLimit(limits, Math.min(cpuCount(limits) || capacity, capacity));
|
|
87
|
+
}
|
|
88
|
+
module.exports = { prepareResumeResources, restoreBaseCpu, clampCpuToDaemon };
|
|
@@ -94,7 +94,15 @@ function readDockerState(record) {
|
|
|
94
94
|
if (opts.isolated !== 'docker' || !opts.sessionName) {
|
|
95
95
|
return null;
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
const state = inspectDockerState(opts.sessionName);
|
|
98
|
+
if (
|
|
99
|
+
record.attempt &&
|
|
100
|
+
state?.startedAt &&
|
|
101
|
+
Date.parse(state.startedAt) < Date.parse(record.attempt.startedAt)
|
|
102
|
+
) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return state;
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
/**
|
|
@@ -318,6 +326,9 @@ function applyEndTime(enriched, dockerState, footerFinishedAt) {
|
|
|
318
326
|
}
|
|
319
327
|
|
|
320
328
|
function resolveDetachedStatus(record, logTail) {
|
|
329
|
+
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
330
|
+
return record;
|
|
331
|
+
}
|
|
321
332
|
const footer = parseFooterFromTail(logTail);
|
|
322
333
|
const footerExit = footer.exitCode;
|
|
323
334
|
const dockerState = isDetachedDockerRecord(record)
|
|
@@ -436,6 +447,7 @@ function attachExitReason(record, logTail) {
|
|
|
436
447
|
logTail,
|
|
437
448
|
oomKilled: record.oomKilled,
|
|
438
449
|
cgroupMemory: record.cgroupMemory,
|
|
450
|
+
exitEvidence: record.options?.exitEvidence,
|
|
439
451
|
});
|
|
440
452
|
|
|
441
453
|
if (!exitReason) {
|
|
@@ -482,9 +494,22 @@ function attachMemoryExhaustion(record, logTail) {
|
|
|
482
494
|
logTail,
|
|
483
495
|
oomKilled: record.oomKilled,
|
|
484
496
|
cgroupMemory: record.cgroupMemory,
|
|
497
|
+
exitEvidence: record.options?.exitEvidence,
|
|
485
498
|
});
|
|
486
499
|
|
|
487
500
|
if (!memory) {
|
|
501
|
+
if (
|
|
502
|
+
record.memoryExhaustedReason?.startsWith('Docker reported') ||
|
|
503
|
+
record.memoryExhaustedReason?.startsWith('cgroup memory.events')
|
|
504
|
+
) {
|
|
505
|
+
const cleared = Object.assign(
|
|
506
|
+
Object.create(Object.getPrototypeOf(record)),
|
|
507
|
+
record
|
|
508
|
+
);
|
|
509
|
+
delete cleared.memoryExhausted;
|
|
510
|
+
delete cleared.memoryExhaustedReason;
|
|
511
|
+
return cleared;
|
|
512
|
+
}
|
|
488
513
|
return record;
|
|
489
514
|
}
|
|
490
515
|
|
|
@@ -506,12 +531,34 @@ function attachMemoryExhaustion(record, logTail) {
|
|
|
506
531
|
* @returns {Object} Possibly updated execution record
|
|
507
532
|
*/
|
|
508
533
|
function enrichDetachedStatus(record) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
534
|
+
if (!record) {
|
|
535
|
+
return record;
|
|
536
|
+
}
|
|
537
|
+
const cpuPenalty = require('./cpu-penalty-monitor').readState(record);
|
|
538
|
+
if (cpuPenalty) {
|
|
539
|
+
record = Object.assign(
|
|
540
|
+
Object.create(Object.getPrototypeOf(record)),
|
|
541
|
+
record
|
|
542
|
+
);
|
|
543
|
+
record.cpuPenalty = cpuPenalty;
|
|
544
|
+
}
|
|
545
|
+
const {
|
|
546
|
+
readAttemptActivity,
|
|
547
|
+
readAttemptLogTail,
|
|
548
|
+
} = require('./execution-attempt');
|
|
549
|
+
const logTail = record
|
|
550
|
+
? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
|
|
551
|
+
: null;
|
|
513
552
|
const resolved = resolveDetachedStatus(record, logTail);
|
|
514
|
-
|
|
553
|
+
const enriched = attachMemoryExhaustion(
|
|
554
|
+
attachExitReason(resolved, logTail),
|
|
555
|
+
logTail
|
|
556
|
+
);
|
|
557
|
+
const copy = Object.assign(
|
|
558
|
+
Object.create(Object.getPrototypeOf(enriched)),
|
|
559
|
+
enriched
|
|
560
|
+
);
|
|
561
|
+
return readAttemptActivity(copy);
|
|
515
562
|
}
|
|
516
563
|
|
|
517
564
|
/**
|
|
@@ -671,6 +718,9 @@ function formatRecordAsText(record) {
|
|
|
671
718
|
...(formatCgroupMemory(obj.cgroupMemory)
|
|
672
719
|
? [`Cgroup Memory: ${formatCgroupMemory(obj.cgroupMemory)}`]
|
|
673
720
|
: []),
|
|
721
|
+
...(obj.cpuPenalty
|
|
722
|
+
? [`CPU Penalty: ${JSON.stringify(obj.cpuPenalty)}`]
|
|
723
|
+
: []),
|
|
674
724
|
`PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
|
|
675
725
|
`Working Directory: ${obj.workingDirectory}`,
|
|
676
726
|
`Shell: ${obj.shell}`,
|
|
@@ -693,6 +743,16 @@ function formatRecordAsText(record) {
|
|
|
693
743
|
lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
|
|
694
744
|
}
|
|
695
745
|
lines.push(`Log Path: ${obj.logPath}`);
|
|
746
|
+
if (obj.attempt) {
|
|
747
|
+
lines.push(
|
|
748
|
+
`Attempt: ${obj.attempt.number}`,
|
|
749
|
+
`Attempt Started: ${obj.attempt.startedAt}`,
|
|
750
|
+
`Attempt Log Offset: ${obj.attempt.logOffset ?? 'N/A'}`,
|
|
751
|
+
`Launch Accepted: ${obj.attempt.launchAcceptedAt || 'N/A'}`,
|
|
752
|
+
`Watcher Attached: ${obj.attempt.watcherAttachedAt || 'N/A'}`,
|
|
753
|
+
`Last Output: ${obj.attempt.lastOutputAt || 'N/A'}`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
696
756
|
|
|
697
757
|
// Format options as nested list instead of JSON
|
|
698
758
|
const optionEntries = Object.entries(obj.options || {}).filter(
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Populate before publishing: a failed write must never poison the shared lock. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const LOCK_TIMEOUT_MS = 30000;
|
|
6
|
+
const LOCK_STALE_MS = 60000;
|
|
7
|
+
const MALFORMED_LOCK_GRACE_MS = 3000;
|
|
8
|
+
|
|
9
|
+
class LockManager {
|
|
10
|
+
constructor(lockFilePath, filesystem = fs) {
|
|
11
|
+
this.lockFilePath = lockFilePath;
|
|
12
|
+
this.fs = filesystem;
|
|
13
|
+
this.lockAcquired = false;
|
|
14
|
+
this.identity = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
acquire(timeout = LOCK_TIMEOUT_MS) {
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
const debug = (message) => {
|
|
20
|
+
if (process.env.START_DEBUG === '1') {
|
|
21
|
+
console.error(`[DEBUG] Store lock ${this.lockFilePath}: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
while (Date.now() - started < timeout) {
|
|
25
|
+
const temp = `${this.lockFilePath}.${process.pid}.${crypto.randomUUID()}`;
|
|
26
|
+
try {
|
|
27
|
+
try {
|
|
28
|
+
const stat = this.fs.statSync(this.lockFilePath);
|
|
29
|
+
if (this.isLockStale(this.readLockFile(), stat)) {
|
|
30
|
+
const current = this.fs.statSync(this.lockFilePath);
|
|
31
|
+
if (current.ino === stat.ino && current.mtimeMs === stat.mtimeMs) {
|
|
32
|
+
debug('reclaiming stale or malformed lock');
|
|
33
|
+
this.fs.unlinkSync(this.lockFilePath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ENOENT') {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.fs.writeFileSync(
|
|
42
|
+
temp,
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
pid: process.pid,
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
hostname: os.hostname(),
|
|
47
|
+
}),
|
|
48
|
+
{ flag: 'wx' }
|
|
49
|
+
);
|
|
50
|
+
const handle = this.fs.openSync(temp, 'r+');
|
|
51
|
+
try {
|
|
52
|
+
this.fs.fsyncSync(handle);
|
|
53
|
+
} finally {
|
|
54
|
+
this.fs.closeSync(handle);
|
|
55
|
+
}
|
|
56
|
+
const identity = this.fs.statSync(temp);
|
|
57
|
+
this.fs.linkSync(temp, this.lockFilePath);
|
|
58
|
+
this.identity = identity;
|
|
59
|
+
this.lockAcquired = true;
|
|
60
|
+
debug(`acquired after ${Date.now() - started}ms`);
|
|
61
|
+
return true;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error.code !== 'EEXIST') {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
this.sleep(100);
|
|
67
|
+
} finally {
|
|
68
|
+
try {
|
|
69
|
+
this.fs.unlinkSync(temp);
|
|
70
|
+
} catch {
|
|
71
|
+
/* May not have been created. */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
debug('acquisition timed out');
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
release() {
|
|
80
|
+
if (!this.lockAcquired) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const current = this.fs.statSync(this.lockFilePath);
|
|
85
|
+
if (
|
|
86
|
+
current.dev === this.identity.dev &&
|
|
87
|
+
current.ino === this.identity.ino
|
|
88
|
+
) {
|
|
89
|
+
this.fs.unlinkSync(this.lockFilePath);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
/* The lock may already be gone. */
|
|
93
|
+
}
|
|
94
|
+
this.lockAcquired = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
readLockFile() {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(this.fs.readFileSync(this.lockFilePath, 'utf8'));
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
isLockStale(data, stat) {
|
|
106
|
+
if (
|
|
107
|
+
!data ||
|
|
108
|
+
!Number.isFinite(data.timestamp) ||
|
|
109
|
+
!Number.isInteger(data.pid) ||
|
|
110
|
+
data.pid <= 0 ||
|
|
111
|
+
data.pid > 2147483647
|
|
112
|
+
) {
|
|
113
|
+
return Boolean(
|
|
114
|
+
stat && Date.now() - stat.mtimeMs >= MALFORMED_LOCK_GRACE_MS
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (data.hostname === os.hostname()) {
|
|
118
|
+
try {
|
|
119
|
+
process.kill(data.pid, 0);
|
|
120
|
+
return false;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return error.code === 'ESRCH';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return Date.now() - data.timestamp > LOCK_STALE_MS;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
sleep(ms) {
|
|
129
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { LockManager, LOCK_TIMEOUT_MS, MALFORMED_LOCK_GRACE_MS };
|
package/src/lib/usage.js
CHANGED
|
@@ -19,6 +19,17 @@ Options:
|
|
|
19
19
|
--on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
|
|
20
20
|
--recovery-command <cmd> Command to run in the same container on such a resume
|
|
21
21
|
--on-kill-resume-delay <min[-max]> Wait a random number of seconds before each such resume (default 0)
|
|
22
|
+
--memory <size|N%|MIN%-MAX%> Docker RAM cap; percentages use daemon capacity
|
|
23
|
+
--memory-swap <spec> Combined memory+swap cap (defaults to memory, no extra swap)
|
|
24
|
+
--cpus <n|N%|MIN%-MAX%> Docker CPU cap; ranges draw once per creation
|
|
25
|
+
--on-kill-resume-memory <spec> New memory cap before a qualifying OOM recovery
|
|
26
|
+
--cpu-penalty Enable delayed Docker CPU penalty (default off)
|
|
27
|
+
--cpu-penalty-cpus <n> Penalty cap (default 2 CPUs)
|
|
28
|
+
--cpu-penalty-trigger <p%> Busy threshold (default 95% of usable CPUs)
|
|
29
|
+
--cpu-penalty-trigger-window <d> Fully covered busy window (default 15m)
|
|
30
|
+
--cpu-penalty-release <p%> Quiet threshold (default 65% of penalty cap)
|
|
31
|
+
--cpu-penalty-release-window <d> Minimum quiet/capped window (default 15m)
|
|
32
|
+
Durations accept ms, s, m, h; --status reports penalty state
|
|
22
33
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
23
34
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
24
35
|
--keep-user Keep isolated user after command completes
|
|
@@ -305,6 +305,9 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
305
305
|
networks: null,
|
|
306
306
|
networkAliases: null,
|
|
307
307
|
resourceLimits: null,
|
|
308
|
+
resolvedLimits: null,
|
|
309
|
+
resourceLimitSpecs: null,
|
|
310
|
+
onKillResumeMemory: null,
|
|
308
311
|
onKillResume: null,
|
|
309
312
|
recoveryCommand: null,
|
|
310
313
|
onKillResumeDelay: null,
|
|
@@ -331,6 +334,9 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
331
334
|
networks: ['hive-formal-ai', 'public-egress'],
|
|
332
335
|
networkAliases: ['formal-ai', 'checker'],
|
|
333
336
|
resourceLimits: null,
|
|
337
|
+
resolvedLimits: null,
|
|
338
|
+
resourceLimitSpecs: null,
|
|
339
|
+
onKillResumeMemory: null,
|
|
334
340
|
onKillResume: null,
|
|
335
341
|
recoveryCommand: null,
|
|
336
342
|
onKillResumeDelay: null,
|