start-command 0.35.4 → 0.37.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 +14 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/bin/cli.js +48 -7
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +32 -1
- 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 +43 -0
- package/src/lib/cpu-penalty-monitor.js +226 -0
- package/src/lib/cpu-penalty.js +175 -0
- package/src/lib/detached-finalize.js +16 -0
- package/src/lib/docker-cleanup.js +35 -7
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- 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 +23 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +153 -30
- package/src/lib/execution-resume-all.js +11 -0
- package/src/lib/execution-resume.js +314 -54
- package/src/lib/execution-store.js +25 -121
- package/src/lib/exit-evidence.js +123 -0
- package/src/lib/exit-reason.js +24 -6
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +48 -7
- package/src/lib/launch-owner.js +25 -0
- package/src/lib/launch-persistence.js +54 -0
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +18 -6
- package/src/lib/resume-resources.js +88 -0
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/status-formatter.js +28 -0
- package/src/lib/store-lock.js +133 -0
- package/src/lib/usage.js +16 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +10 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +19 -11
- package/test/exit-reason.js +6 -1
- package/test/failure-handler.js +11 -0
- package/test/isolation.js +5 -4
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-174.js +3 -1
- package/test/regression-176.js +7 -0
- 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 +11 -2
- package/test/regression-189.js +224 -0
- package/test/regression-190.js +111 -0
- package/test/regression-191.js +160 -0
- package/test/regression-192.js +66 -0
- package/test/regression-193.js +251 -0
- package/test/regression-194.js +182 -0
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +54 -0
- package/test/status-query.js +97 -15
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
const { it, expect } = require('bun:test');
|
|
2
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
3
|
+
it('CPU penalty is opt-in and accepts durations and percentage settings', () => {
|
|
4
|
+
const options = parseArgs([
|
|
5
|
+
'--isolated',
|
|
6
|
+
'docker',
|
|
7
|
+
'--cpu-penalty',
|
|
8
|
+
'--cpu-penalty-trigger-window',
|
|
9
|
+
'1s',
|
|
10
|
+
'--',
|
|
11
|
+
'true',
|
|
12
|
+
]).wrapperOptions;
|
|
13
|
+
expect(options.cpuPenaltyConfig.triggerWindowMs).toBe(1000);
|
|
14
|
+
expect(options.cpuPenaltyConfig.release).toBe(65);
|
|
15
|
+
});
|
|
16
|
+
const { initialState, evaluate, average } = require('../src/lib/cpu-penalty');
|
|
17
|
+
const config = {
|
|
18
|
+
cpus: 2,
|
|
19
|
+
trigger: 95,
|
|
20
|
+
triggerWindowMs: 100,
|
|
21
|
+
release: 65,
|
|
22
|
+
releaseWindowMs: 100,
|
|
23
|
+
};
|
|
24
|
+
it('requires complete time-weighted windows, then lifts and observes afresh', () => {
|
|
25
|
+
let state = initialState(null, 0);
|
|
26
|
+
const tick = (now, cores) => {
|
|
27
|
+
const result = evaluate(
|
|
28
|
+
state,
|
|
29
|
+
{ now, cores, daemonCpus: 6, maxGapMs: 50 },
|
|
30
|
+
config
|
|
31
|
+
);
|
|
32
|
+
state = result.state;
|
|
33
|
+
return result.action;
|
|
34
|
+
};
|
|
35
|
+
expect(tick(0, 6)).toBeNull();
|
|
36
|
+
expect(tick(40, 6)).toBeNull();
|
|
37
|
+
expect(tick(80, 6)).toBeNull();
|
|
38
|
+
expect(tick(100, 0.4).kind).toBe('apply');
|
|
39
|
+
expect(state.penaltyCount).toBe(1);
|
|
40
|
+
expect(tick(120, 0.4)).toBeNull();
|
|
41
|
+
expect(tick(160, 0.4)).toBeNull();
|
|
42
|
+
expect(tick(200, 0.4)).toBeNull();
|
|
43
|
+
expect(tick(220, 6).kind).toBe('lift');
|
|
44
|
+
expect(state.penalizedMs).toBe(120);
|
|
45
|
+
expect(tick(240, 6)).toBeNull();
|
|
46
|
+
expect(tick(280, 6)).toBeNull();
|
|
47
|
+
expect(tick(320, 6)).toBeNull();
|
|
48
|
+
expect(tick(340, 6).kind).toBe('apply');
|
|
49
|
+
expect(state.penaltyCount).toBe(2);
|
|
50
|
+
});
|
|
51
|
+
it('resets coverage on outage, gaps, resize and restart, and skips ineffective caps', () => {
|
|
52
|
+
let state = initialState(4, 0);
|
|
53
|
+
for (const [now, cores, daemonCpus] of [
|
|
54
|
+
[0, 4, 6],
|
|
55
|
+
[40, 4, 6],
|
|
56
|
+
[80, NaN, 6],
|
|
57
|
+
[120, 4, 6],
|
|
58
|
+
[160, 4, 6],
|
|
59
|
+
[200, 4, 8],
|
|
60
|
+
]) {
|
|
61
|
+
const result = evaluate(
|
|
62
|
+
state,
|
|
63
|
+
{ now, cores, daemonCpus, maxGapMs: 50 },
|
|
64
|
+
config
|
|
65
|
+
);
|
|
66
|
+
state = result.state;
|
|
67
|
+
expect(result.action).toBeNull();
|
|
68
|
+
}
|
|
69
|
+
const restarted = initialState(4, 240, state);
|
|
70
|
+
expect(
|
|
71
|
+
evaluate(
|
|
72
|
+
restarted,
|
|
73
|
+
{ now: 240, cores: 4, daemonCpus: 8, maxGapMs: 50 },
|
|
74
|
+
config
|
|
75
|
+
).action
|
|
76
|
+
).toBeNull();
|
|
77
|
+
expect(
|
|
78
|
+
evaluate(state, { now: 400, cores: 4, daemonCpus: 8, maxGapMs: 50 }, config)
|
|
79
|
+
.action
|
|
80
|
+
).toBeNull();
|
|
81
|
+
state = initialState(1, 0);
|
|
82
|
+
for (const now of [0, 40, 80, 120]) {
|
|
83
|
+
const r = evaluate(
|
|
84
|
+
state,
|
|
85
|
+
{ now, cores: 1, daemonCpus: 6, maxGapMs: 50 },
|
|
86
|
+
config
|
|
87
|
+
);
|
|
88
|
+
state = r.state;
|
|
89
|
+
expect(r.action).toBeNull();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
it('weights timestamps rather than giving short intervals equal weight', () => {
|
|
93
|
+
expect(
|
|
94
|
+
average(
|
|
95
|
+
[
|
|
96
|
+
{ at: 0, cores: 6 },
|
|
97
|
+
{ at: 90, cores: 0 },
|
|
98
|
+
{ at: 100, cores: 0 },
|
|
99
|
+
],
|
|
100
|
+
100,
|
|
101
|
+
100
|
|
102
|
+
)
|
|
103
|
+
).toBe(5.4);
|
|
104
|
+
});
|
|
105
|
+
it('resume restores the original base within resized daemon capacity', () => {
|
|
106
|
+
const { prepareResumeResources } = require('../src/lib/resume-resources');
|
|
107
|
+
const record = {
|
|
108
|
+
options: {
|
|
109
|
+
isolated: 'docker',
|
|
110
|
+
sessionName: 'task',
|
|
111
|
+
cpuPenaltyConfig: config,
|
|
112
|
+
baseResourceLimits: ['--cpus=3'],
|
|
113
|
+
resourceLimits: ['--cpus=0.5'],
|
|
114
|
+
resolvedLimits: { cpus: 3 },
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const result = prepareResumeResources(
|
|
118
|
+
record,
|
|
119
|
+
{ state: 'stopped' },
|
|
120
|
+
{},
|
|
121
|
+
(_cmd, args) => ({
|
|
122
|
+
success: true,
|
|
123
|
+
stdout: JSON.stringify(
|
|
124
|
+
args[0] === 'info'
|
|
125
|
+
? { MemTotal: 1073741824, NCPU: 2 }
|
|
126
|
+
: { NanoCpus: 500000000 }
|
|
127
|
+
),
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
expect(result.resourceLimits).toContain('--cpus=2');
|
|
131
|
+
expect(result.resolvedLimits.cpus).toBe(2);
|
|
132
|
+
expect(record.options.baseResourceLimits).toEqual(['--cpus=3']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('monitor applies, lifts and reapplies with tracking disabled', () => {
|
|
136
|
+
if (process.platform === 'win32') {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const fs = require('fs'),
|
|
140
|
+
path = require('path'),
|
|
141
|
+
os = require('os');
|
|
142
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-monitor-189-'));
|
|
143
|
+
try {
|
|
144
|
+
const logPath = path.join(dir, 'task.log');
|
|
145
|
+
fs.writeFileSync(logPath, '');
|
|
146
|
+
const record = {
|
|
147
|
+
logPath,
|
|
148
|
+
options: {
|
|
149
|
+
sessionName: 'cpu-task',
|
|
150
|
+
cpuPenaltyConfig: {
|
|
151
|
+
...config,
|
|
152
|
+
triggerWindowMs: 200,
|
|
153
|
+
releaseWindowMs: 200,
|
|
154
|
+
},
|
|
155
|
+
baseResourceLimits: [],
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const result = require('child_process').spawnSync(
|
|
159
|
+
'/bin/sh',
|
|
160
|
+
[
|
|
161
|
+
path.resolve(__dirname, '../../experiments/issue-195-cpu-monitor.sh'),
|
|
162
|
+
process.execPath,
|
|
163
|
+
require.resolve('../src/lib/cpu-penalty-monitor'),
|
|
164
|
+
'',
|
|
165
|
+
'cpu-task',
|
|
166
|
+
'1',
|
|
167
|
+
JSON.stringify(record),
|
|
168
|
+
],
|
|
169
|
+
{ encoding: 'utf8' }
|
|
170
|
+
);
|
|
171
|
+
expect(result.status).toBe(0);
|
|
172
|
+
const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n');
|
|
173
|
+
expect(lines.length).toBe(3);
|
|
174
|
+
expect(lines[0]).toContain('CPU penalty applied: 2');
|
|
175
|
+
expect(lines[1]).toContain('CPU penalty lifted: 6');
|
|
176
|
+
expect(lines[2]).toContain('CPU penalty applied: 2');
|
|
177
|
+
const state = JSON.parse(
|
|
178
|
+
fs.readFileSync(`${logPath}.cpu-penalty.json`, 'utf8')
|
|
179
|
+
);
|
|
180
|
+
expect(state.penaltyCount).toBe(2);
|
|
181
|
+
expect(state.phase).toBe('penalized');
|
|
182
|
+
expect(state.penalizedMs).toBeGreaterThan(200);
|
|
183
|
+
} finally {
|
|
184
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
}, 15000);
|
|
187
|
+
|
|
188
|
+
it('preserves legacy CPU quota format and the lower original base', () => {
|
|
189
|
+
const limits = require('../src/lib/docker-resource-limits');
|
|
190
|
+
const base = ['--cpu-period=200000', '--cpu-quota=100000'];
|
|
191
|
+
expect(limits.cpuCount(base)).toBe(0.5);
|
|
192
|
+
expect(limits.withCpuLimit(base, 0.25)).toEqual([
|
|
193
|
+
'--cpu-period=100000',
|
|
194
|
+
'--cpu-quota=25000',
|
|
195
|
+
]);
|
|
196
|
+
expect(limits.cpuUpdateArgs(base, 0.25)).toEqual([
|
|
197
|
+
'--cpu-period=100000',
|
|
198
|
+
'--cpu-quota=25000',
|
|
199
|
+
]);
|
|
200
|
+
const { clampCpuToDaemon } = require('../src/lib/resume-resources');
|
|
201
|
+
expect(
|
|
202
|
+
clampCpuToDaemon(base, () => ({
|
|
203
|
+
success: true,
|
|
204
|
+
stdout: '{"NCPU":4,"MemTotal":1073741824}',
|
|
205
|
+
}))
|
|
206
|
+
).toEqual(['--cpu-period=100000', '--cpu-quota=50000']);
|
|
207
|
+
const { prepareResumeResources } = require('../src/lib/resume-resources');
|
|
208
|
+
const overridden = prepareResumeResources(
|
|
209
|
+
{
|
|
210
|
+
options: {
|
|
211
|
+
isolated: 'docker',
|
|
212
|
+
sessionName: 'task',
|
|
213
|
+
resourceLimits: base,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{ state: 'stopped' },
|
|
217
|
+
{ cpus: '0.25' },
|
|
218
|
+
() => ({ success: true, stdout: '{"CpuPeriod":200000,"CpuQuota":100000}' })
|
|
219
|
+
);
|
|
220
|
+
expect(overridden.resourceLimits).toEqual([
|
|
221
|
+
'--cpu-period=100000',
|
|
222
|
+
'--cpu-quota=25000',
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const { describe, it, expect } = require('bun:test');
|
|
2
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
3
|
+
|
|
4
|
+
describe('issue #190: launch resource options', () => {
|
|
5
|
+
it('recognizes resource flags and rejects invalid input before launch', () => {
|
|
6
|
+
const parsed = parseArgs([
|
|
7
|
+
'--isolated',
|
|
8
|
+
'docker',
|
|
9
|
+
'--image',
|
|
10
|
+
'alpine',
|
|
11
|
+
'--memory',
|
|
12
|
+
'90%-100%',
|
|
13
|
+
'--cpus=50%',
|
|
14
|
+
'--',
|
|
15
|
+
'true',
|
|
16
|
+
]);
|
|
17
|
+
expect(parsed.wrapperOptions.memory).toBe('90%-100%');
|
|
18
|
+
expect(parsed.wrapperOptions.cpus).toBe('50%');
|
|
19
|
+
for (const value of ['100%-90%', '101%', '0%', '0g', '3watts']) {
|
|
20
|
+
expect(() =>
|
|
21
|
+
parseArgs([
|
|
22
|
+
'--isolated',
|
|
23
|
+
'docker',
|
|
24
|
+
'--image',
|
|
25
|
+
'alpine',
|
|
26
|
+
'--memory',
|
|
27
|
+
value,
|
|
28
|
+
'--',
|
|
29
|
+
'true',
|
|
30
|
+
])
|
|
31
|
+
).toThrow();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
resolveResourceOptions,
|
|
38
|
+
} = require('../src/lib/docker-resource-options');
|
|
39
|
+
it('uses daemon capacity, draws each range once and disables extra swap by default', () => {
|
|
40
|
+
let draws = 0;
|
|
41
|
+
const calls = [];
|
|
42
|
+
const result = resolveResourceOptions(
|
|
43
|
+
{ memory: '70%-80%', cpus: '50%' },
|
|
44
|
+
(_cmd, args) => {
|
|
45
|
+
calls.push(args);
|
|
46
|
+
return {
|
|
47
|
+
success: true,
|
|
48
|
+
stdout: JSON.stringify({ MemTotal: 1024 ** 3 * 10, NCPU: 8 }),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
() => {
|
|
52
|
+
draws++;
|
|
53
|
+
return 0.5;
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
expect(result.resourceLimits).toEqual([
|
|
57
|
+
'--memory=8053063680',
|
|
58
|
+
'--cpus=4',
|
|
59
|
+
'--memory-swap=8053063680',
|
|
60
|
+
]);
|
|
61
|
+
expect(result.resolvedLimits.memorySwap).toBe(result.resolvedLimits.memory);
|
|
62
|
+
expect(calls).toHaveLength(1);
|
|
63
|
+
expect(draws).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
it('preserves stored resolutions without another random draw or daemon query', () => {
|
|
66
|
+
const limits = [
|
|
67
|
+
'--memory=8053063680',
|
|
68
|
+
'--memory-swap=8053063680',
|
|
69
|
+
'--cpus=4',
|
|
70
|
+
];
|
|
71
|
+
const result = resolveResourceOptions(
|
|
72
|
+
{ resourceLimits: limits },
|
|
73
|
+
() => {
|
|
74
|
+
throw Error('unexpected query');
|
|
75
|
+
},
|
|
76
|
+
() => {
|
|
77
|
+
throw Error('unexpected draw');
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
expect(result.resourceLimits).toEqual(limits);
|
|
81
|
+
});
|
|
82
|
+
it('rejects swap below memory and daemon capacity failures', () => {
|
|
83
|
+
expect(() =>
|
|
84
|
+
resolveResourceOptions({ memory: '64m', memorySwap: '32m' })
|
|
85
|
+
).toThrow();
|
|
86
|
+
expect(() =>
|
|
87
|
+
resolveResourceOptions({ memory: '80%' }, () => ({ success: false }))
|
|
88
|
+
).toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('forwards raw resource and penalty options through every remaining Docker level', () => {
|
|
92
|
+
const { buildNextLevelCommand } = require('../src/lib/command-builder');
|
|
93
|
+
const options = parseArgs([
|
|
94
|
+
'--isolated',
|
|
95
|
+
'screen docker',
|
|
96
|
+
'--memory',
|
|
97
|
+
'70%-80%',
|
|
98
|
+
'--cpus',
|
|
99
|
+
'50%',
|
|
100
|
+
'--cpu-penalty',
|
|
101
|
+
'--cpu-penalty-trigger-window',
|
|
102
|
+
'2s',
|
|
103
|
+
'--',
|
|
104
|
+
'work',
|
|
105
|
+
]).wrapperOptions;
|
|
106
|
+
const command = buildNextLevelCommand(options, 'work');
|
|
107
|
+
const words = require('../src/lib/shell-utils').splitShellWords(command);
|
|
108
|
+
expect(words[words.indexOf('--memory') + 1]).toBe('70%-80%');
|
|
109
|
+
expect(words[words.indexOf('--cpus') + 1]).toBe('50%');
|
|
110
|
+
expect(command).toContain('--cpu-penalty-trigger-window 2000ms');
|
|
111
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const { it, expect } = require('bun:test');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
ExecutionStore,
|
|
7
|
+
ExecutionRecord,
|
|
8
|
+
} = require('../src/lib/execution-store');
|
|
9
|
+
const { resumeExecution } = require('../src/lib/execution-resume');
|
|
10
|
+
const { recoverKilledExecution } = require('../src/lib/execution-recovery');
|
|
11
|
+
const { SessionState } = require('../src/lib/session-probe');
|
|
12
|
+
|
|
13
|
+
function fixture() {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-191-'));
|
|
15
|
+
const store = new ExecutionStore({ appFolder: dir, useLinks: false });
|
|
16
|
+
const record = new ExecutionRecord({
|
|
17
|
+
command: 'work',
|
|
18
|
+
status: 'executed',
|
|
19
|
+
logPath: path.join(dir, 'run.log'),
|
|
20
|
+
options: {
|
|
21
|
+
isolated: 'docker',
|
|
22
|
+
isolationMode: 'detached',
|
|
23
|
+
sessionName: 'task',
|
|
24
|
+
image: 'alpine',
|
|
25
|
+
onKillResume: 3,
|
|
26
|
+
onKillResumeMemory: '70%-80%',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
store.save(record);
|
|
30
|
+
const calls = [];
|
|
31
|
+
const runner = (_, args) => {
|
|
32
|
+
calls.push(args);
|
|
33
|
+
if (args[0] === 'info') {
|
|
34
|
+
return {
|
|
35
|
+
success: true,
|
|
36
|
+
stdout: JSON.stringify({
|
|
37
|
+
MemTotal: 1073741824,
|
|
38
|
+
NCPU: 4,
|
|
39
|
+
DockerRootDir: os.tmpdir(),
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (args.includes('--size')) {
|
|
44
|
+
return { success: true, stdout: '1024' };
|
|
45
|
+
}
|
|
46
|
+
if (args[0] === 'inspect') {
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
stdout: JSON.stringify({
|
|
50
|
+
Memory: 67108864,
|
|
51
|
+
MemorySwap: 67108864,
|
|
52
|
+
NanoCpus: 3000000000,
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { success: true, stdout: 'id' };
|
|
57
|
+
};
|
|
58
|
+
return { dir, store, record, runner, calls };
|
|
59
|
+
}
|
|
60
|
+
it('manual in-place memory override updates before start and records the resolution', async () => {
|
|
61
|
+
const f = fixture();
|
|
62
|
+
try {
|
|
63
|
+
const result = await resumeExecution(f.store, f.record.uuid, {
|
|
64
|
+
resourceOptions: { memory: '128m' },
|
|
65
|
+
runner: f.runner,
|
|
66
|
+
probe: () => ({ alive: false, state: SessionState.STOPPED }),
|
|
67
|
+
startWatcher: () => true,
|
|
68
|
+
});
|
|
69
|
+
expect(result.success).toBe(true);
|
|
70
|
+
const update = f.calls.find((a) => a[0] === 'update');
|
|
71
|
+
expect(update).toContain('--memory=134217728');
|
|
72
|
+
expect(update).toContain('--memory-swap=134217728');
|
|
73
|
+
expect(f.calls.indexOf(update)).toBeLessThan(
|
|
74
|
+
f.calls.findIndex((a) => a[0] === 'start')
|
|
75
|
+
);
|
|
76
|
+
expect(f.store.get(f.record.uuid).options.resolvedLimits.memory).toBe(
|
|
77
|
+
134217728
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
fs.rmSync(f.dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
it('manual snapshot override applies limits to the new container at creation', async () => {
|
|
84
|
+
const f = fixture();
|
|
85
|
+
try {
|
|
86
|
+
const result = await resumeExecution(f.store, f.record.uuid, {
|
|
87
|
+
command: 'new',
|
|
88
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
89
|
+
resourceOptions: { memory: '128m' },
|
|
90
|
+
runner: f.runner,
|
|
91
|
+
probe: () => ({ alive: false, state: SessionState.STOPPED }),
|
|
92
|
+
startWatcher: () => true,
|
|
93
|
+
});
|
|
94
|
+
expect(result.success).toBe(true);
|
|
95
|
+
expect(f.calls.find((a) => a[0] === 'run')).toContain('--memory=134217728');
|
|
96
|
+
} finally {
|
|
97
|
+
fs.rmSync(f.dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
for (const [exitCode, oomKilled, sample, change] of [
|
|
101
|
+
[137, true, '67108864 66000000 1 1 100 100', true],
|
|
102
|
+
[137, true, '67108864 66000000 1 1 0 100', false],
|
|
103
|
+
[137, false, '67108864 66000000 0 0 0 100', false],
|
|
104
|
+
[1, true, '67108864 66000000 1 1 100 100', false],
|
|
105
|
+
]) {
|
|
106
|
+
it(`automatic memory change requires a qualifying OOM (exit=${exitCode}, fresh=${change})`, () => {
|
|
107
|
+
const f = fixture();
|
|
108
|
+
try {
|
|
109
|
+
const result = recoverKilledExecution({
|
|
110
|
+
store: f.store,
|
|
111
|
+
executionId: f.record.uuid,
|
|
112
|
+
exitCode,
|
|
113
|
+
oomKilled,
|
|
114
|
+
cgroupMemory: sample,
|
|
115
|
+
finishedAt: '1970-01-01T00:01:40Z',
|
|
116
|
+
runner: f.runner,
|
|
117
|
+
random: () => 0.5,
|
|
118
|
+
startWatcher: () => true,
|
|
119
|
+
});
|
|
120
|
+
const update = f.calls.find((a) => a[0] === 'update');
|
|
121
|
+
expect(Boolean(update)).toBe(change);
|
|
122
|
+
if (change) {
|
|
123
|
+
expect(result.recovered).toBe(true);
|
|
124
|
+
expect(update).toContain('--memory=805306368');
|
|
125
|
+
expect(f.calls.indexOf(update)).toBeLessThan(
|
|
126
|
+
f.calls.findIndex((a) => a[0] === 'start')
|
|
127
|
+
);
|
|
128
|
+
expect(f.store.get(f.record.uuid).options.resolvedLimits.memory).toBe(
|
|
129
|
+
805306368
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (exitCode === 1) {
|
|
133
|
+
expect(result.recovered).toBe(false);
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
fs.rmSync(f.dir, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it('a failed memory update prevents starting the old-limit command', async () => {
|
|
142
|
+
const f = fixture();
|
|
143
|
+
try {
|
|
144
|
+
const result = await resumeExecution(f.store, f.record.uuid, {
|
|
145
|
+
resourceOptions: { memory: '128m' },
|
|
146
|
+
runner: (cmd, args) =>
|
|
147
|
+
args[0] === 'update'
|
|
148
|
+
? (f.calls.push(args),
|
|
149
|
+
{ success: false, stderr: 'daemon rejected update' })
|
|
150
|
+
: f.runner(cmd, args),
|
|
151
|
+
probe: () => ({ alive: false, state: SessionState.STOPPED }),
|
|
152
|
+
startWatcher: () => true,
|
|
153
|
+
});
|
|
154
|
+
expect(result.success).toBe(false);
|
|
155
|
+
expect(f.calls.some((args) => args[0] === 'start')).toBe(false);
|
|
156
|
+
expect(f.store.get(f.record.uuid).status).toBe('executed');
|
|
157
|
+
} finally {
|
|
158
|
+
fs.rmSync(f.dir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const { it, expect } = require('bun:test');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const {
|
|
7
|
+
buildCgroupSamplerStartSnippet,
|
|
8
|
+
buildCgroupMemoryLogSnippet,
|
|
9
|
+
} = require('../src/lib/cgroup-memory');
|
|
10
|
+
it('samples remote/private cgroups through Docker and reports unavailable counters', () => {
|
|
11
|
+
expect(buildCgroupSamplerStartSnippet('remote-task')).toContain(
|
|
12
|
+
'docker exec'
|
|
13
|
+
);
|
|
14
|
+
expect(buildCgroupMemoryLogSnippet("'/tmp/log'")).toContain('unavailable');
|
|
15
|
+
expect(buildCgroupSamplerStartSnippet('remote-task')).toContain(
|
|
16
|
+
'HostConfig.Memory'
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
for (const mode of ['remote', 'no-shell', 'outage', 'shared']) {
|
|
21
|
+
it(`real watcher shell: ${mode}`, () => {
|
|
22
|
+
if (process.platform === 'win32') {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'remote-192-'));
|
|
26
|
+
try {
|
|
27
|
+
const script = path.join(dir, 'sample.sh');
|
|
28
|
+
fs.writeFileSync(
|
|
29
|
+
script,
|
|
30
|
+
[
|
|
31
|
+
buildCgroupSamplerStartSnippet('remote-task'),
|
|
32
|
+
'sleep 1.2',
|
|
33
|
+
require('../src/lib/cgroup-memory').buildCgroupSamplerStopSnippet(),
|
|
34
|
+
buildCgroupMemoryLogSnippet('"$TMPDIR/memory.log"'),
|
|
35
|
+
'cat "$TMPDIR/memory.log"',
|
|
36
|
+
].join('; ')
|
|
37
|
+
);
|
|
38
|
+
const result = spawnSync(
|
|
39
|
+
'/bin/sh',
|
|
40
|
+
[
|
|
41
|
+
path.resolve(
|
|
42
|
+
__dirname,
|
|
43
|
+
'../../experiments/issue-195-memory-sampler.sh'
|
|
44
|
+
),
|
|
45
|
+
mode,
|
|
46
|
+
script,
|
|
47
|
+
],
|
|
48
|
+
{ encoding: 'utf8' }
|
|
49
|
+
);
|
|
50
|
+
expect(result.status).toBe(0);
|
|
51
|
+
if (mode === 'remote' || mode === 'outage') {
|
|
52
|
+
expect(result.stdout).toContain(
|
|
53
|
+
'memory.max=67108864 memory.peak=33554432 oom=1 oom_kill=3'
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
expect(result.stdout).toContain('Memory: unavailable');
|
|
57
|
+
expect(result.stdout).toContain('memory.limit=67108864 (HostConfig)');
|
|
58
|
+
expect(result.stdout).toContain(
|
|
59
|
+
mode === 'no-shell' ? 'sh: not found' : 'namespace=host'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
}, 10000);
|
|
66
|
+
}
|