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.
Files changed (67) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +48 -7
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +32 -1
  7. package/src/lib/atomic-write.js +34 -0
  8. package/src/lib/attached-diagnostics.js +90 -0
  9. package/src/lib/cgroup-memory.js +43 -11
  10. package/src/lib/command-builder.js +43 -0
  11. package/src/lib/cpu-penalty-monitor.js +226 -0
  12. package/src/lib/cpu-penalty.js +175 -0
  13. package/src/lib/detached-finalize.js +16 -0
  14. package/src/lib/docker-cleanup.js +35 -7
  15. package/src/lib/docker-command-handoff.js +75 -0
  16. package/src/lib/docker-labels.js +33 -0
  17. package/src/lib/docker-resource-limits.js +28 -0
  18. package/src/lib/docker-resource-options.js +180 -0
  19. package/src/lib/docker-runtime-args.js +23 -0
  20. package/src/lib/docker-snapshot-safety.js +164 -0
  21. package/src/lib/execution-recovery.js +153 -30
  22. package/src/lib/execution-resume-all.js +11 -0
  23. package/src/lib/execution-resume.js +314 -54
  24. package/src/lib/execution-store.js +25 -121
  25. package/src/lib/exit-evidence.js +123 -0
  26. package/src/lib/exit-reason.js +24 -6
  27. package/src/lib/failure-handler.js +15 -5
  28. package/src/lib/isolation.js +48 -7
  29. package/src/lib/launch-owner.js +25 -0
  30. package/src/lib/launch-persistence.js +54 -0
  31. package/src/lib/log-sanitizer.js +267 -0
  32. package/src/lib/log-uploader.js +38 -19
  33. package/src/lib/query-commands.js +18 -6
  34. package/src/lib/resume-resources.js +88 -0
  35. package/src/lib/shell-utils.js +67 -17
  36. package/src/lib/status-formatter.js +28 -0
  37. package/src/lib/store-lock.js +133 -0
  38. package/src/lib/usage.js +16 -1
  39. package/test/ci-workflow-invariants.js +15 -14
  40. package/test/docker-command-handoff.js +107 -0
  41. package/test/docker-runtime-options.js +10 -0
  42. package/test/docker-snapshot-safety.js +106 -0
  43. package/test/execution-resume.js +19 -11
  44. package/test/exit-reason.js +6 -1
  45. package/test/failure-handler.js +11 -0
  46. package/test/isolation.js +5 -4
  47. package/test/javascript-first-ci.js +216 -0
  48. package/test/log-sanitizer.js +306 -0
  49. package/test/regression-174.js +3 -1
  50. package/test/regression-176.js +7 -0
  51. package/test/regression-180.js +17 -10
  52. package/test/regression-182.js +7 -3
  53. package/test/regression-185.js +4 -9
  54. package/test/regression-187.js +11 -2
  55. package/test/regression-189.js +224 -0
  56. package/test/regression-190.js +111 -0
  57. package/test/regression-191.js +160 -0
  58. package/test/regression-192.js +66 -0
  59. package/test/regression-193.js +251 -0
  60. package/test/regression-194.js +182 -0
  61. package/test/regression-198.js +281 -0
  62. package/test/regression-199.js +79 -0
  63. package/test/regression-201.js +25 -0
  64. package/test/regression-202.js +92 -0
  65. package/test/regression-91.js +23 -20
  66. package/test/shared-golden.js +54 -0
  67. package/test/status-query.js +97 -15
@@ -0,0 +1,216 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { readdirSync, readFileSync } = require('node:fs');
4
+ const { resolve } = require('node:path');
5
+ const { YAML } = require('bun');
6
+ const { spawnSync } = require('node:child_process');
7
+ const {
8
+ checkJavaScriptFirst,
9
+ } = require('../../scripts/check-javascript-first.mjs');
10
+ const {
11
+ checkFeatureChanges,
12
+ } = require('../../scripts/check-feature-parity.mjs');
13
+
14
+ const root = resolve(__dirname, '../..');
15
+ function workflows() {
16
+ return Object.fromEntries(
17
+ readdirSync(resolve(root, '.github/workflows'))
18
+ .filter((name) => name.endsWith('.yml'))
19
+ .map((name) => [
20
+ name,
21
+ YAML.parse(
22
+ readFileSync(resolve(root, '.github/workflows', name), 'utf8')
23
+ ),
24
+ ])
25
+ );
26
+ }
27
+
28
+ describe('JavaScript first CI gate', () => {
29
+ it('validates every current workflow using parsed YAML', () => {
30
+ assert.deepEqual(checkJavaScriptFirst(workflows()), []);
31
+ });
32
+
33
+ for (const [name, mutate] of [
34
+ [
35
+ 'independent Rust PR trigger',
36
+ (files) => {
37
+ files['rust.yml'].on.pull_request = {};
38
+ },
39
+ ],
40
+ [
41
+ 'removed successful-stage condition',
42
+ (files) => {
43
+ files['js.yml'].jobs['rust-stage'].if = 'always()';
44
+ },
45
+ ],
46
+ [
47
+ 'an alternative that bypasses the successful stage',
48
+ (files) => {
49
+ files['js.yml'].jobs['rust-stage'].if =
50
+ "${{ !cancelled() && needs.pipeline-status.result == 'success' || true }}";
51
+ },
52
+ ],
53
+ [
54
+ 'Rust-only path filter',
55
+ (files) => {
56
+ files['js.yml'].on.pull_request.paths = ['js/**'];
57
+ },
58
+ ],
59
+ [
60
+ 'Rust security matrix bypass',
61
+ (files) => {
62
+ files['security.yml'].jobs.codeql.strategy.matrix.language.push('rust');
63
+ },
64
+ ],
65
+ [
66
+ 'direct Rust CodeQL bypass',
67
+ (files) => {
68
+ files['new.yml'] = {
69
+ jobs: {
70
+ scan: {
71
+ steps: [
72
+ {
73
+ uses: 'github/codeql-action/init@v4',
74
+ with: { languages: 'rust' },
75
+ },
76
+ ],
77
+ },
78
+ },
79
+ };
80
+ },
81
+ ],
82
+ [
83
+ 'comma-delimited Rust CodeQL bypass',
84
+ (files) => {
85
+ files['new.yml'] = {
86
+ jobs: {
87
+ scan: {
88
+ steps: [
89
+ {
90
+ uses: 'github/codeql-action/init@v4',
91
+ with: { languages: 'javascript-typescript,rust' },
92
+ },
93
+ ],
94
+ },
95
+ },
96
+ };
97
+ },
98
+ ],
99
+ [
100
+ 'Rust CodeQL matrix include bypass',
101
+ (files) => {
102
+ files['security.yml'].jobs.codeql.strategy.matrix = {
103
+ include: [{ language: 'rust' }],
104
+ };
105
+ },
106
+ ],
107
+ [
108
+ 'independent Rustup tooling',
109
+ (files) => {
110
+ files['new.yml'] = {
111
+ jobs: {
112
+ install: {
113
+ steps: [{ run: 'rustup toolchain install stable' }],
114
+ },
115
+ },
116
+ };
117
+ },
118
+ ],
119
+ [
120
+ 'release key outside main environment',
121
+ (files) => {
122
+ delete files['js.yml'].jobs.release.environment;
123
+ },
124
+ ],
125
+ [
126
+ 'a new ungated Rust workflow',
127
+ (files) => {
128
+ files['new.yml'] = {
129
+ on: { push: {} },
130
+ jobs: { build: { steps: [{ run: 'cargo build' }] } },
131
+ };
132
+ },
133
+ ],
134
+ [
135
+ 'conditional parity',
136
+ (files) => {
137
+ files['js.yml'].jobs.parity.if = 'false';
138
+ },
139
+ ],
140
+ ]) {
141
+ it(`rejects ${name}`, () => {
142
+ const files = workflows();
143
+ mutate(files);
144
+ assert.ok(checkJavaScriptFirst(files).length > 0);
145
+ });
146
+ }
147
+
148
+ for (const result of ['failure', 'cancelled', 'skipped']) {
149
+ it(`rejects a ${result} required JavaScript check`, () => {
150
+ const check = spawnSync(
151
+ process.execPath,
152
+ [resolve(root, 'scripts/check-stage-status.mjs'), '--required', 'test'],
153
+ {
154
+ env: {
155
+ ...process.env,
156
+ NEEDS_JSON: JSON.stringify({ test: { result } }),
157
+ },
158
+ }
159
+ );
160
+ assert.equal(check.status, 1);
161
+ });
162
+ }
163
+
164
+ it('opens the gate only after all required checks succeed', () => {
165
+ const check = spawnSync(
166
+ process.execPath,
167
+ [resolve(root, 'scripts/check-stage-status.mjs'), '--required', 'test'],
168
+ {
169
+ env: {
170
+ ...process.env,
171
+ NEEDS_JSON: JSON.stringify({
172
+ test: { result: 'success' },
173
+ release: { result: 'skipped' },
174
+ }),
175
+ },
176
+ }
177
+ );
178
+ assert.equal(check.status, 0);
179
+ });
180
+
181
+ it('rejects a change to only one implementation of a feature', () => {
182
+ const features = [
183
+ {
184
+ id: 'sample',
185
+ javascript: { implementation: ['js/source.js'], tests: ['js/test.js'] },
186
+ rust: { implementation: ['rust/source.rs'], tests: ['rust/test.rs'] },
187
+ },
188
+ ];
189
+ assert.equal(checkFeatureChanges(features, ['rust/source.rs']).length, 1);
190
+ assert.equal(checkFeatureChanges(features, ['js/source.js']).length, 1);
191
+ assert.deepEqual(
192
+ checkFeatureChanges(features, ['js/source.js', 'rust/test.rs']),
193
+ []
194
+ );
195
+ });
196
+
197
+ it('makes Rust callable only after the complete JavaScript stage', () => {
198
+ const files = workflows();
199
+ assert.deepEqual(Object.keys(files['rust.yml'].on), ['workflow_call']);
200
+ const call = files['js.yml'].jobs['rust-stage'];
201
+ assert.equal(call.uses, './.github/workflows/rust.yml');
202
+ assert.deepEqual(call.needs, ['pipeline-status']);
203
+ assert.match(call.if, /needs\.pipeline-status\.result == 'success'/);
204
+ assert.equal(call.concurrency['cancel-in-progress'], false);
205
+ });
206
+
207
+ it('always runs JavaScript and its behavioral parity gate on Rust-only changes', () => {
208
+ const js = workflows()['js.yml'];
209
+ for (const trigger of ['push', 'pull_request']) {
210
+ assert.equal(js.on[trigger].paths, undefined);
211
+ assert.equal(js.on[trigger]['paths-ignore'], undefined);
212
+ }
213
+ assert.ok(js.jobs.parity);
214
+ assert.equal(js.jobs.parity.if, undefined);
215
+ });
216
+ });
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env bun
2
+ const { describe, it } = require('node:test');
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { execFileSync } = require('child_process');
8
+ const {
9
+ sanitizeLogToTemp,
10
+ StreamingSanitizer,
11
+ } = require('../src/lib/log-sanitizer');
12
+
13
+ function credentials() {
14
+ return [
15
+ ...'pousr'.split('').map((kind) => `gh${kind}_${'a'.repeat(36)}`),
16
+ `github_` + `pat_${'b'.repeat(82)}`,
17
+ `sk-${'c'.repeat(48)}`,
18
+ `sk-` + `proj-${'d'.repeat(90)}`,
19
+ `sk-` + `ant-api03-${'e'.repeat(90)}`,
20
+ `AK` + `IA${'F'.repeat(16)}`,
21
+ `AS` + `IA${'G'.repeat(16)}`,
22
+ 'ey' + 'JhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature',
23
+ 'Authorization: Bearer arbitrary-credential',
24
+ 'Proxy-Authorization: Basic encoded-credential',
25
+ 'https://user:password@example.test/repo.git',
26
+ 'AWS_SECRET_ACCESS_KEY=arbitrary-aws-secret',
27
+ 'custom-secret-value',
28
+ `${Buffer.from(' \n{"alg":"HS256"}').toString('base64url')}.${Buffer.from('{"sub":"x"}').toString('base64url')}.signature`,
29
+ ];
30
+ }
31
+
32
+ function sanitize(input, blockSize) {
33
+ const sanitizer = new StreamingSanitizer({
34
+ START_TEST_SECRET: 'custom-secret-value',
35
+ });
36
+ const blocks = [];
37
+ for (let offset = 0; offset < input.length; offset += blockSize) {
38
+ blocks.push(sanitizer.push(input.subarray(offset, offset + blockSize)));
39
+ }
40
+ blocks.push(sanitizer.push(Buffer.alloc(0), true));
41
+ return Buffer.concat(blocks);
42
+ }
43
+
44
+ describe('log sanitization', () => {
45
+ it('redacts required formats and environment values at every chunk boundary', () => {
46
+ const secrets = credentials();
47
+ for (const size of [1, 7, 31, 256, 1024, 65536]) {
48
+ const input = Buffer.from(
49
+ `ordinary utf8: café 😀\n${' '.repeat(Math.max(0, size - 43))}${secrets.join('\n')}\n${'padding\n'.repeat(400)}finished\n`
50
+ );
51
+ const output = sanitize(input, size).toString();
52
+ for (const secret of secrets) {
53
+ assert.ok(!output.includes(secret));
54
+ }
55
+ for (const value of [
56
+ 'arbitrary-credential',
57
+ 'encoded-credential',
58
+ 'password',
59
+ 'arbitrary-aws-secret',
60
+ ]) {
61
+ assert.ok(!output.includes(value));
62
+ }
63
+ assert.ok(output.startsWith('ordinary utf8: café 😀\n'));
64
+ assert.ok(output.endsWith('\nfinished\n'));
65
+ }
66
+ });
67
+
68
+ it('bounds memory for long tokens and authorization values without releasing suffixes', () => {
69
+ for (const prefix of ['gh' + 'p_', 'Authorization: Bearer ']) {
70
+ const sanitizer = new StreamingSanitizer({});
71
+ let output = sanitizer.push(Buffer.from(prefix));
72
+ for (let i = 0; i < 128; i++) {
73
+ output = Buffer.concat([
74
+ output,
75
+ sanitizer.push(Buffer.alloc(16384, 97)),
76
+ ]);
77
+ assert.ok(sanitizer.pendingBytes <= 65536);
78
+ }
79
+ output = Buffer.concat([
80
+ output,
81
+ sanitizer.push(Buffer.from('\nordinary\n'), true),
82
+ ]);
83
+ assert.strictEqual(output.toString(), '[REDACTED]\nordinary\n');
84
+ }
85
+ });
86
+
87
+ it('retains full header continuation when an inner token reaches the same block boundary', () => {
88
+ const sanitizer = new StreamingSanitizer({});
89
+ const input = Buffer.from(
90
+ `Authorization: Bearer gh${'p_'}${'a'.repeat(131072)} trailing-private-value\nordinary\n`
91
+ );
92
+ const output = [];
93
+ for (let offset = 0; offset < input.length; offset += 65536) {
94
+ output.push(sanitizer.push(input.subarray(offset, offset + 65536)));
95
+ }
96
+ output.push(sanitizer.push(Buffer.alloc(0), true));
97
+ assert.strictEqual(
98
+ Buffer.concat(output).toString(),
99
+ '[REDACTED]\nordinary\n'
100
+ );
101
+ });
102
+
103
+ it('redacts AWS assignment values enclosed in either quote style', () => {
104
+ const sanitizer = new StreamingSanitizer({});
105
+ const output = sanitizer
106
+ .push(
107
+ Buffer.from(
108
+ `AWS_SECRET_ACCESS_KEY="quoted-aws-secret"\nAWS_SESSION_TOKEN='quoted-session-value'\n`
109
+ ),
110
+ true
111
+ )
112
+ .toString();
113
+ assert.ok(!output.includes('quoted-aws-secret'));
114
+ assert.ok(!output.includes('quoted-session-value'));
115
+ });
116
+
117
+ it('redacts multiline and short known secrets, preserving unrelated bytes', () => {
118
+ const sanitizer = new StreamingSanitizer({
119
+ GH_TOKEN: 'abc\nxyz',
120
+ TEST_PASSWORD: '!',
121
+ });
122
+ const output = sanitizer
123
+ .push(Buffer.from('safe abc\nxyz value !'), true)
124
+ .toString();
125
+ assert.strictEqual(output, 'safe [REDACTED] value [REDACTED]');
126
+ });
127
+
128
+ it('merges overlapping environment secrets and preserves ordinary domain names', () => {
129
+ const sanitizer = new StreamingSanitizer({
130
+ GH_TOKEN: 'abcde',
131
+ TEST_PASSWORD: 'cdefg',
132
+ GITHUB_PAT: 'opaque-known-pat',
133
+ });
134
+ assert.strictEqual(
135
+ sanitizer
136
+ .push(
137
+ Buffer.from('abcdefg opaque-known-pat example.test.invalid'),
138
+ true
139
+ )
140
+ .toString(),
141
+ '[REDACTED] [REDACTED] example.test.invalid'
142
+ );
143
+ const short = new StreamingSanitizer({ GH_TOKEN: 'a' });
144
+ assert.strictEqual(
145
+ short.push(Buffer.alloc(65536, 97), true).toString(),
146
+ '[REDACTED]'
147
+ );
148
+ });
149
+
150
+ it('creates a private sanitized copy and removes it without changing the source', () => {
151
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-test-'));
152
+ const source = path.join(dir, 'source.log');
153
+ const input = `gh` + `p_${'x'.repeat(36)}`;
154
+ fs.writeFileSync(source, input);
155
+ try {
156
+ const copy = sanitizeLogToTemp(source, { env: {} });
157
+ assert.notStrictEqual(copy.path, source);
158
+ assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), '[REDACTED]');
159
+ if (process.platform !== 'win32') {
160
+ assert.strictEqual(fs.statSync(copy.path).mode & 0o777, 0o600);
161
+ assert.strictEqual(
162
+ fs.statSync(path.dirname(copy.path)).mode & 0o777,
163
+ 0o700
164
+ );
165
+ }
166
+ assert.strictEqual(fs.readFileSync(source, 'utf8'), input);
167
+ copy.cleanup();
168
+ assert.ok(!fs.existsSync(copy.path));
169
+ } finally {
170
+ fs.rmSync(dir, { recursive: true, force: true });
171
+ }
172
+ });
173
+
174
+ it('enforces private permissions at creation even with a permissive umask', () => {
175
+ if (process.platform === 'win32') {
176
+ return;
177
+ }
178
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-creation-'));
179
+ const source = path.join(dir, 'source.log');
180
+ fs.writeFileSync(source, 'ordinary output');
181
+ const originalOpen = fs.openSync;
182
+ const originalUmask = process.umask(0);
183
+ let creationChecked = false;
184
+ let copy;
185
+ fs.openSync = (...args) => {
186
+ if (args[1] !== 'wx') {
187
+ return originalOpen(...args);
188
+ }
189
+ assert.strictEqual(
190
+ fs.statSync(path.dirname(args[0])).mode & 0o777,
191
+ 0o700
192
+ );
193
+ const descriptor = originalOpen(...args);
194
+ assert.strictEqual(fs.fstatSync(descriptor).mode & 0o777, 0o600);
195
+ creationChecked = true;
196
+ return descriptor;
197
+ };
198
+ try {
199
+ copy = sanitizeLogToTemp(source, { env: {} });
200
+ assert.ok(creationChecked);
201
+ assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), 'ordinary output');
202
+ } finally {
203
+ fs.openSync = originalOpen;
204
+ process.umask(originalUmask);
205
+ copy?.cleanup();
206
+ fs.rmSync(dir, { recursive: true, force: true });
207
+ }
208
+ });
209
+
210
+ it('fails closed on invalid source and excessive environment secret sizes', () => {
211
+ assert.throws(
212
+ () => sanitizeLogToTemp('/does-not-exist', { env: {} }),
213
+ /sanitization failed/i
214
+ );
215
+ assert.throws(
216
+ () => new StreamingSanitizer({ GH_TOKEN: 'a'.repeat(65537) }),
217
+ /too large/i
218
+ );
219
+ });
220
+
221
+ it('blocks both upload paths on read or write failure and does not expose exception text', () => {
222
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-errors-'));
223
+ const source = path.join(dir, 'source.log');
224
+ fs.writeFileSync(source, 'ordinary log');
225
+ const { uploadLogPath } = require('../src/lib/log-uploader');
226
+ const { uploadLog } = require('../src/lib/failure-handler');
227
+ const messages = [];
228
+ const originalLog = console.log;
229
+ console.log = (message) => messages.push(message);
230
+ try {
231
+ for (const method of ['readSync', 'writeSync', 'fsyncSync']) {
232
+ const original = fs[method];
233
+ fs[method] = () => {
234
+ throw new Error('sensitive error details');
235
+ };
236
+ try {
237
+ assert.strictEqual(uploadLogPath(source).success, false);
238
+ assert.strictEqual(uploadLog(source), null);
239
+ } finally {
240
+ fs[method] = original;
241
+ }
242
+ }
243
+ assert.ok(
244
+ messages.every(
245
+ (message) => !message.includes('sensitive error details')
246
+ )
247
+ );
248
+ } finally {
249
+ console.log = originalLog;
250
+ fs.rmSync(dir, { recursive: true, force: true });
251
+ }
252
+ });
253
+
254
+ it('sanitizes manual and automatic uploads, explicitly requests private visibility, and cleans copies', () => {
255
+ if (process.platform === 'win32') {
256
+ return;
257
+ }
258
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-sanitize-test-'));
259
+ const log = path.join(dir, 'source.log');
260
+ fs.writeFileSync(log, `gh` + `p_${'x'.repeat(36)}`);
261
+ fs.writeFileSync(
262
+ path.join(dir, 'gh-upload-log'),
263
+ '#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE_ARGS"\ncat "$1" > "$CAPTURE_LOG"\nls -ld "$1" | cut -c1-10 > "$CAPTURE_MODE"\necho https://gist.github.com/test/id\n',
264
+ { mode: 0o755 }
265
+ );
266
+ try {
267
+ for (const kind of ['manual', 'automatic', 'optout']) {
268
+ const script =
269
+ kind === 'automatic'
270
+ ? `require(${JSON.stringify(require.resolve('../src/lib/failure-handler'))}).uploadLog(process.env.SOURCE_LOG)`
271
+ : `require(${JSON.stringify(require.resolve('../src/lib/log-uploader'))}).uploadLogPath(process.env.SOURCE_LOG, { noSanitize: ${kind === 'optout'} })`;
272
+ execFileSync(process.execPath, ['-e', script], {
273
+ env: {
274
+ ...process.env,
275
+ PATH: `${dir}${path.delimiter}${process.env.PATH}`,
276
+ SOURCE_LOG: log,
277
+ CAPTURE_ARGS: path.join(dir, 'args'),
278
+ CAPTURE_LOG: path.join(dir, 'uploaded'),
279
+ CAPTURE_MODE: path.join(dir, 'mode'),
280
+ GH_UPLOAD_PUBLIC: 'true',
281
+ },
282
+ stdio: 'pipe',
283
+ });
284
+ const args = fs
285
+ .readFileSync(path.join(dir, 'args'), 'utf8')
286
+ .trim()
287
+ .split('\n');
288
+ assert.strictEqual(args[1], '--private');
289
+ assert.strictEqual(
290
+ fs.readFileSync(path.join(dir, 'uploaded'), 'utf8'),
291
+ kind === 'optout' ? fs.readFileSync(log, 'utf8') : '[REDACTED]'
292
+ );
293
+ if (kind !== 'optout') {
294
+ assert.notStrictEqual(args[0], log);
295
+ assert.ok(!fs.existsSync(args[0]));
296
+ assert.strictEqual(
297
+ fs.readFileSync(path.join(dir, 'mode'), 'utf8').trim(),
298
+ '-rw-------'
299
+ );
300
+ }
301
+ }
302
+ } finally {
303
+ fs.rmSync(dir, { recursive: true, force: true });
304
+ }
305
+ });
306
+ });
@@ -100,9 +100,11 @@ function runWatcher({ policy, running, state, withLog = true }) {
100
100
  [
101
101
  '#!/bin/sh',
102
102
  `dir='${dir}'`,
103
+ '[ "$START_COMMAND_CGROUP_SAMPLING" = 1 ] && exit 1',
103
104
  'case "$1" in',
104
105
  ' inspect)',
105
106
  ' case "$3" in',
107
+ ' *HostConfig*) exit 1 ;;',
106
108
  ' *State.Running*)',
107
109
  ' answer=$(head -n 1 "$dir/running")',
108
110
  ' if [ "$(wc -l < "$dir/running")" -gt 1 ]; then',
@@ -173,7 +175,7 @@ describe('issue #174: docker logs -f returning is not the container exiting', ()
173
175
  });
174
176
 
175
177
  expect(run.status).toBe(0);
176
- expect(run.calls).toContain('wait');
178
+ expect(run.calls).toContain('inspect-running false');
177
179
  expect(run.calls.indexOf('wait')).toBeGreaterThan(
178
180
  run.calls.indexOf('logs')
179
181
  );
@@ -127,6 +127,12 @@ function fakeRunner(hostConfig, failures = {}) {
127
127
  if (args[0] === 'inspect' && args.includes('{{json .HostConfig}}')) {
128
128
  return ok(`${JSON.stringify(hostConfig)}\n`);
129
129
  }
130
+ if (args.includes('--size')) {
131
+ return ok('1024');
132
+ }
133
+ if (args[0] === 'info') {
134
+ return ok(JSON.stringify({ DockerRootDir: os.tmpdir() }));
135
+ }
130
136
  return ok('cid\n');
131
137
  };
132
138
  runner.calls = calls;
@@ -300,6 +306,7 @@ describe('issue #176: snapshot resume keeps the resource limits', () => {
300
306
  command: 'npm run build',
301
307
  probe: stoppedProbe,
302
308
  runner,
309
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
303
310
  startWatcher: (...args) => watchers.push(args),
304
311
  });
305
312
  expect(result.success).toBe(true);
@@ -70,20 +70,20 @@ describe('issue #180: the sticky OOMKilled flag is not an exit reason', () => {
70
70
  {
71
71
  exitCode: 137,
72
72
  oomKilled: true,
73
- reason: CGROUP_OOM_EXIT_REASON,
74
- memory: true,
73
+ reason: 'signal (SIGKILL; cause unknown)',
74
+ memory: false,
75
75
  },
76
76
  {
77
77
  exitCode: 137,
78
78
  oomKilled: false,
79
- reason: 'signal (SIGKILL)',
79
+ reason: 'signal (SIGKILL; cause unknown)',
80
80
  memory: false,
81
81
  },
82
82
  {
83
83
  exitCode: -1,
84
84
  oomKilled: true,
85
- reason: CGROUP_OOM_EXIT_REASON,
86
- memory: true,
85
+ reason: null,
86
+ memory: false,
87
87
  },
88
88
  {
89
89
  exitCode: 139,
@@ -104,10 +104,17 @@ describe('issue #180: the sticky OOMKilled flag is not an exit reason', () => {
104
104
  });
105
105
  }
106
106
 
107
- it('blames the flag on the command only for SIGKILL or an unknown exit', () => {
108
- expect(isOomKillOfCommand({ exitCode: 137, oomKilled: true })).toBe(true);
109
- expect(isOomKillOfCommand({ exitCode: null, oomKilled: true })).toBe(true);
110
- expect(isOomKillOfCommand({ exitCode: -1, oomKilled: true })).toBe(true);
107
+ it('requires fresh attribution in addition to SIGKILL or an unknown exit', () => {
108
+ expect(isOomKillOfCommand({ exitCode: 137, oomKilled: true })).toBe(false);
109
+ expect(
110
+ isOomKillOfCommand({
111
+ exitCode: 137,
112
+ oomKilled: true,
113
+ exitEvidence: { mainOom: true },
114
+ })
115
+ ).toBe(true);
116
+ expect(isOomKillOfCommand({ exitCode: null, oomKilled: true })).toBe(false);
117
+ expect(isOomKillOfCommand({ exitCode: -1, oomKilled: true })).toBe(false);
111
118
  expect(isOomKillOfCommand({ exitCode: 0, oomKilled: true })).toBe(false);
112
119
  expect(isOomKillOfCommand({ exitCode: 1, oomKilled: true })).toBe(false);
113
120
  expect(isOomKillOfCommand({ exitCode: 137, oomKilled: false })).toBe(false);
@@ -191,6 +198,6 @@ describe('issue #180: the sticky OOMKilled flag is not an exit reason', () => {
191
198
  oomKilled: true,
192
199
  logPath: null,
193
200
  });
194
- expect(main).toContain('Docker reports it was OOM-killed.');
201
+ expect(main).toContain('Docker reports a process in it was OOM-killed.');
195
202
  });
196
203
  });
@@ -285,9 +285,13 @@ describe('issue #182: the watcher samples the cgroup while the container runs',
285
285
  timeout: 20000,
286
286
  });
287
287
  expect(result.status).toBe(0);
288
- expect(fs.readFileSync(path.join(host.dir, 'sample'), 'utf8')).toBe(
289
- OOM_SAMPLE
290
- );
288
+ expect(
289
+ fs
290
+ .readFileSync(path.join(host.dir, 'sample'), 'utf8')
291
+ .split(' ')
292
+ .slice(0, 4)
293
+ .join(' ')
294
+ ).toBe(OOM_SAMPLE);
291
295
  expect(fs.readFileSync(logPath, 'utf8')).toBe(
292
296
  `${formatCgroupMemoryLogLine(OOM_SAMPLE)}\n`
293
297
  );
@@ -97,15 +97,10 @@ describe('issue #185: counter-only OOM scope is unknown', () => {
97
97
  }
98
98
  for (const exitCode of [137, null, -1]) {
99
99
  const input = { exitCode, oomKilled: false, cgroupMemory };
100
- expect(resolveExitReason(input)).toBe(CGROUP_OOM_EXIT_REASON);
101
- expect(resolveMemoryExhaustion(input)).toEqual(
102
- exitCode === null
103
- ? null
104
- : {
105
- memoryExhausted: true,
106
- memoryExhaustedReason: 'cgroup memory.events reported oom_kill=3',
107
- }
108
- );
100
+ expect(resolveExitReason(input)).not.toBe(CGROUP_OOM_EXIT_REASON);
101
+ expect(resolveMemoryExhaustion(input)).toBeNull();
102
+ const attributed = { ...input, exitEvidence: { mainOom: true } };
103
+ expect(resolveExitReason(attributed)).toBe(CGROUP_OOM_EXIT_REASON);
109
104
  }
110
105
  });
111
106
  });
@@ -76,7 +76,15 @@ function fixture() {
76
76
  }
77
77
 
78
78
  const stopped = () => ({ alive: false, state: SessionState.STOPPED });
79
- const accepted = () => ({ success: true, stdout: 'container-id\n', status: 0 });
79
+ const accepted = (_bin, args = []) => ({
80
+ success: true,
81
+ stdout: args.includes('--size')
82
+ ? '1024'
83
+ : args[0] === 'info'
84
+ ? JSON.stringify({ DockerRootDir: os.tmpdir() })
85
+ : 'container-id\n',
86
+ status: 0,
87
+ });
80
88
 
81
89
  describe('issue #187: explicit resume attempts', () => {
82
90
  it('reattaches the current attempt without enabling kill recovery for snapshot commands', async () => {
@@ -368,7 +376,8 @@ esac
368
376
  probe: () => ({ alive: false, state: SessionState.MISSING }),
369
377
  runIsolated: (backend, command, options) => {
370
378
  expect(options.deferCompletionWatcher).toBe(true);
371
- expect(store.get(record.uuid).status).toBe('executed');
379
+ expect(store.get(record.uuid).status).toBe('executing');
380
+ expect(store.get(record.uuid).options.launchPending).toBe(true);
372
381
  return { success: true, containerId: 'replacement-container' };
373
382
  },
374
383
  startWatcher: () => {