start-command 0.39.0 → 0.39.1

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # start-command
2
2
 
3
+ ## 0.39.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 55c57b0: Stop the Docker cgroup sampler when the counters can never be read (#247). On hosts whose containers have no `memory.events` (the `memory` controller is not delegated, e.g. a root `cgroup.subtree_control` of `cpuset cpu pids`) and whose local cgroup is hidden, the sampler started one `docker exec` and one `docker inspect -f {{.State.Running}}` every interval for the whole life of the task without ever recording a sample. Exit 3 (unreadable counters) from the long-lived stream or from the per-round read now records the reason and stops sampling, as a missing `sh` (exit 126/127) already did; the post-mortem reports `Memory: unavailable (local cgroup unavailable; Docker exec exit=3: private cgroup v2 counters unreadable)` instead of the last round's error. Other failed rounds are still retried, but each one doubles the wait, up to 60 s (or the interval, if longer); a sample restores the interval. The completion watcher (`docker wait`) still finishes the run.
8
+
9
+ Attached `--isolated screen` runs no longer report a failed command as exit 0 when `screen -dmS` returns before `screen -ls` lists the new session. A session missing from `screen -ls` now counts as finished only once its exit-code file is written, or after 20 polls (about 2 s) without the session.
10
+
3
11
  ## 0.39.0
4
12
 
5
13
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.39.0",
3
+ "version": "0.39.1",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -30,6 +30,15 @@ const { shellQuote } = require('./isolation-log-utils');
30
30
  /** Default seconds between samples; the watcher environment may override it. */
31
31
  const CGROUP_SAMPLE_INTERVAL_SECONDS = 5;
32
32
 
33
+ /**
34
+ * Longest wait between rounds that keep failing (issue #247), unless the
35
+ * interval itself is longer. Each round without a sample doubles the wait.
36
+ */
37
+ const CGROUP_SAMPLE_BACKOFF_MAX_SECONDS = 60;
38
+
39
+ /** Exit status of both in-container reads for unreadable counters. */
40
+ const CGROUP_UNREADABLE_EXIT = 3;
41
+
33
42
  /** Shell variables used by the sampler snippets. */
34
43
  const CGROUP_SHELL_VARS = {
35
44
  /** Last sample: `<memory.max> <memory.peak> <oom> <oom_kill>`. */
@@ -80,17 +89,18 @@ function buildCgroupFunctionsSnippet() {
80
89
  * (`docker exec -i`), so it runs only shell builtins: the task's own CPU
81
90
  * limit pays for no `cat`, `sed` or `sleep` processes. Stdin reaches EOF when
82
91
  * the exec's client is gone, so the loop never outlives the sampler; dockerd
83
- * would accept its output even then. Exit 3: unreadable counters.
92
+ * would accept its output even then. Exit 3: unreadable counters (no
93
+ * delegated `memory` controller), which no later round can change.
84
94
  * @returns {string} Shell script for `sh -c`
85
95
  */
86
96
  function buildCgroupStreamScript() {
87
97
  return [
88
98
  'while read -r __scx_tick; do __scx_oom=; __scx_kill=; __scx_max=-; __scx_peak=-',
89
- '[ -r "$1/memory.events" ] || exit 3',
99
+ `[ -r "$1/memory.events" ] || exit ${CGROUP_UNREADABLE_EXIT}`,
90
100
  'while read -r __scx_key __scx_value; do case "$__scx_key" in ' +
91
101
  'oom) __scx_oom=$__scx_value;; oom_kill) __scx_kill=$__scx_value;; esac; ' +
92
102
  'done < "$1/memory.events"',
93
- 'case "$__scx_oom:$__scx_kill" in *[!0-9:]*|:*|*:) exit 3;; esac',
103
+ `case "$__scx_oom:$__scx_kill" in *[!0-9:]*|:*|*:) exit ${CGROUP_UNREADABLE_EXIT};; esac`,
94
104
  '[ ! -r "$1/memory.max" ] || read -r __scx_max < "$1/memory.max"',
95
105
  '[ ! -r "$1/memory.peak" ] || read -r __scx_peak < "$1/memory.peak"',
96
106
  'printf \'%s %s %s %s %s\\n\' "${__scx_max:--}" "${__scx_peak:--}" ' +
@@ -116,15 +126,21 @@ const CGROUP_SAMPLING_OFF_REASON =
116
126
  * successful read proves the container runs, so Docker is asked for liveness
117
127
  * only after a failed round or when the stream ends. Without a usable stream it falls back
118
128
  * to one `docker exec` per round; without `sh` in the image (exit 126/127) or
119
- * without any readable cgroup it records the reason and stops.
120
- * `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` disables sampling.
129
+ * without any readable cgroup (exit 3 from either exec, issue #247) it
130
+ * records the reason and stops. Other failed rounds are retried, each one
131
+ * doubling the wait up to CGROUP_SAMPLE_BACKOFF_MAX_SECONDS; a sample restores
132
+ * the interval. `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` disables
133
+ * sampling.
121
134
  * @param {string} containerName - Container to sample
122
135
  * @returns {string} Shell command
123
136
  */
124
137
  function buildCgroupSamplerStartSnippet(containerName) {
125
138
  const v = CGROUP_SHELL_VARS;
126
139
  const name = shellQuote(containerName);
127
- const remoteRead = '__start_command_cgroup_read /sys/fs/cgroup';
140
+ const remoteRead = `__start_command_cgroup_read /sys/fs/cgroup || exit ${CGROUP_UNREADABLE_EXIT}`;
141
+ // Exit statuses no later round can change: no `sh`, or no counters.
142
+ const permanent = `${CGROUP_UNREADABLE_EXIT}|126|127`;
143
+ const backoff = CGROUP_SAMPLE_BACKOFF_MAX_SECONDS;
128
144
  const unavailable = (detail) =>
129
145
  `printf '%s\\n' "local cgroup unavailable; ${detail}" > "$${v.file}.reason"`;
130
146
  const execReason = unavailable(
@@ -155,7 +171,8 @@ function buildCgroupSamplerStartSnippet(containerName) {
155
171
  `__scs_dir=$(__start_command_cgroup_dir ${name}); ` +
156
172
  // The namespace is fixed at container creation, including when reads fail.
157
173
  `__scs_ns=$(docker inspect -f '{{.HostConfig.CgroupnsMode}}' ${name} 2>/dev/null); __scs_stream=1; __scs_reader=''; ` +
158
- `while :; do __scs_ok=''; if [ -n "$__scs_dir" ]; then ` +
174
+ `__scs_delay=$__scs_interval; ` +
175
+ `while :; do __scs_ok=''; __scs_sampled=''; if [ -n "$__scs_dir" ]; then ` +
159
176
  '__scs_line=$(__start_command_cgroup_read "$__scs_dir") && __scs_record "$__scs_line" && __scs_ok=1; ' +
160
177
  `elif [ "$__scs_ns" != private ]; then ${unavailable('container cgroup namespace=${__scs_ns:-unavailable}, private required')}; break; ` +
161
178
  `elif [ "$__scs_stream" = 1 ]; then ` +
@@ -171,15 +188,20 @@ function buildCgroupSamplerStartSnippet(containerName) {
171
188
  `[ -z "$__scs_got" ] || exit 0; ${execReason}; ` +
172
189
  `case "$__scs_exit" in ''|0|*[!0-9]*) exit 1;; esac; exit "$__scs_exit"; } & ` +
173
190
  `__scs_reader=$!; wait "$__scs_reader"; __scs_exit=$?; trap - TERM; __scs_reader=''; ` +
174
- // No shell in the image: a per-round exec cannot succeed either.
175
- `case "$__scs_exit" in 0) ;; 126|127) break;; *) __scs_stream=0;; esac; ` +
191
+ // No shell in the image or no counters: a per-round exec cannot
192
+ // succeed either.
193
+ `case "$__scs_exit" in 0) __scs_sampled=1;; ${permanent}) break;; *) __scs_stream=0;; esac; ` +
176
194
  `else __scs_remote=$(docker exec ${name} sh -c ${shellQuote(`${buildCgroupFunctionsSnippet()}; ${remoteRead}`)} 2>&1); __scs_exit=$?; ` +
177
195
  'if [ "$__scs_exit" = 0 ] && __scs_record "$__scs_remote"; then __scs_ok=1; ' +
178
- `else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); ${execReason}; fi; fi; ` +
196
+ `else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); ${execReason}; ` +
197
+ `case "$__scs_exit" in ${permanent}) break;; esac; fi; fi; ` +
179
198
  // A successful read needs no Docker liveness probe, and the completion
180
199
  // watcher still stops us after docker wait.
181
200
  `if [ -z "$__scs_ok" ] && [ "$(docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = false ]; then break; fi; ` +
182
- `sleep "$__scs_interval"; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
201
+ // Back off while rounds keep failing, never below the interval.
202
+ `if [ -n "$__scs_ok$__scs_sampled" ]; then __scs_delay=$__scs_interval; else __scs_delay=$((__scs_delay*2)); ` +
203
+ `[ "$__scs_delay" -le ${backoff} ] || __scs_delay=${backoff}; [ "$__scs_delay" -ge "$__scs_interval" ] || __scs_delay=$__scs_interval; fi; ` +
204
+ `sleep "$__scs_delay"; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
183
205
  ].join('; ');
184
206
  }
185
207
 
@@ -373,10 +395,12 @@ function formatCgroupMemory(memory) {
373
395
  }
374
396
 
375
397
  module.exports = {
398
+ CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
376
399
  CGROUP_SAMPLE_INTERVAL_SECONDS,
377
400
  CGROUP_SAMPLING_OFF_REASON,
378
401
  CGROUP_SHELL_VARS,
379
402
  CGROUP_STREAM_NAME,
403
+ CGROUP_UNREADABLE_EXIT,
380
404
  OOM_SCOPE,
381
405
  OOM_SCOPE_NOTES,
382
406
  buildCgroupFunctionsSnippet,
@@ -100,6 +100,28 @@ function supportsLogfileOption() {
100
100
  return version.patch >= 1;
101
101
  }
102
102
 
103
+ /**
104
+ * Polls without the session in `screen -ls` and without an exit code before
105
+ * the session counts as gone. `screen -dmS` can return before the session is
106
+ * listed, so a missing session alone is no proof that the command finished;
107
+ * its exit code is written just before the session ends.
108
+ */
109
+ const SCREEN_UNLISTED_GRACE_POLLS = 20;
110
+
111
+ /**
112
+ * Whether an attached screen run is over.
113
+ * @param {boolean} listed - The session is in `screen -ls`
114
+ * @param {boolean} exitCodeReady - The exit-code sidecar has content (or no
115
+ * sidecar is expected, as for a bare shell)
116
+ * @param {number} unlistedPolls - Consecutive polls without the session
117
+ * @returns {boolean}
118
+ */
119
+ function isScreenRunFinished(listed, exitCodeReady, unlistedPolls) {
120
+ return (
121
+ !listed && (exitCodeReady || unlistedPolls >= SCREEN_UNLISTED_GRACE_POLLS)
122
+ );
123
+ }
124
+
103
125
  /**
104
126
  * Run command in GNU Screen using detached mode with log capture.
105
127
  *
@@ -346,58 +368,64 @@ function runScreenWithLogCapture(
346
368
  const checkInterval = 100; // ms
347
369
  const maxWait = 300000; // 5 minutes max
348
370
  let waited = 0;
371
+ let unlistedPolls = 0;
372
+
373
+ const exitCodeReady = () => {
374
+ if (isBareShell) {
375
+ return true;
376
+ }
377
+ try {
378
+ return fs.readFileSync(exitCodeFile, 'utf8').trim() !== '';
379
+ } catch {
380
+ return false;
381
+ }
382
+ };
383
+
384
+ const finish = () => {
385
+ readAndDisplayOutput().then((output) => {
386
+ const exitCode = readExitCode();
387
+ cleanupTempFiles();
388
+ resolve({
389
+ success: exitCode === 0,
390
+ sessionName,
391
+ message: `Screen session "${sessionName}" exited with code ${exitCode}`,
392
+ exitCode,
393
+ output,
394
+ });
395
+ });
396
+ };
349
397
 
350
398
  const checkCompletion = () => {
399
+ let listed;
351
400
  try {
352
401
  // Check if session still exists
353
- const sessions = execSync('screen -ls', {
402
+ listed = execSync('screen -ls', {
354
403
  encoding: 'utf8',
355
404
  stdio: ['pipe', 'pipe', 'pipe'],
356
- });
357
-
358
- if (!sessions.includes(sessionName)) {
359
- // Session ended, read output and exit code
360
- readAndDisplayOutput().then((output) => {
361
- const exitCode = readExitCode();
362
- cleanupTempFiles();
363
- resolve({
364
- success: exitCode === 0,
365
- sessionName,
366
- message: `Screen session "${sessionName}" exited with code ${exitCode}`,
367
- exitCode,
368
- output,
369
- });
370
- });
371
- return;
372
- }
373
-
374
- waited += checkInterval;
375
- if (waited >= maxWait) {
376
- cleanupTempFiles();
377
- resolve({
378
- success: false,
379
- sessionName,
380
- message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
381
- exitCode: 1,
382
- });
383
- return;
384
- }
385
-
386
- setTimeout(checkCompletion, checkInterval);
405
+ }).includes(sessionName);
387
406
  } catch {
388
407
  // screen -ls failed, session probably ended
389
- readAndDisplayOutput().then((output) => {
390
- const exitCode = readExitCode();
391
- cleanupTempFiles();
392
- resolve({
393
- success: exitCode === 0,
394
- sessionName,
395
- message: `Screen session "${sessionName}" exited with code ${exitCode}`,
396
- exitCode,
397
- output,
398
- });
408
+ listed = false;
409
+ }
410
+ unlistedPolls = listed ? 0 : unlistedPolls + 1;
411
+ if (isScreenRunFinished(listed, exitCodeReady(), unlistedPolls)) {
412
+ finish();
413
+ return;
414
+ }
415
+
416
+ waited += checkInterval;
417
+ if (waited >= maxWait) {
418
+ cleanupTempFiles();
419
+ resolve({
420
+ success: false,
421
+ sessionName,
422
+ message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
423
+ exitCode: 1,
399
424
  });
425
+ return;
400
426
  }
427
+
428
+ setTimeout(checkCompletion, checkInterval);
401
429
  };
402
430
 
403
431
  // Start checking after a brief delay
@@ -522,7 +550,9 @@ function resetScreenVersionCache() {
522
550
  }
523
551
 
524
552
  module.exports = {
553
+ SCREEN_UNLISTED_GRACE_POLLS,
525
554
  getScreenVersion,
555
+ isScreenRunFinished,
526
556
  supportsLogfileOption,
527
557
  runScreenWithLogCapture,
528
558
  startDetachedScreenWithLogCapture,
package/test/isolation.js CHANGED
@@ -812,3 +812,35 @@ describe('isInteractiveShellCommand (issue #84)', () => {
812
812
  assert.strictEqual(isInteractiveShellCommand(' '), false);
813
813
  });
814
814
  });
815
+
816
+ describe('isScreenRunFinished', () => {
817
+ // `screen -dmS` can return before `screen -ls` lists the session; a failed
818
+ // command then read as exit 0 (Rust CI run 39873000384).
819
+ const {
820
+ SCREEN_UNLISTED_GRACE_POLLS,
821
+ isScreenRunFinished,
822
+ } = require('../src/lib/screen-isolation');
823
+
824
+ it('waits while the session is listed', () => {
825
+ assert.strictEqual(isScreenRunFinished(true, true, 0), false);
826
+ });
827
+
828
+ it('finishes once the session is gone and its exit code is written', () => {
829
+ assert.strictEqual(isScreenRunFinished(false, true, 1), true);
830
+ });
831
+
832
+ it('keeps polling an unlisted session without an exit code', () => {
833
+ assert.strictEqual(isScreenRunFinished(false, false, 1), false);
834
+ assert.strictEqual(
835
+ isScreenRunFinished(false, false, SCREEN_UNLISTED_GRACE_POLLS - 1),
836
+ false
837
+ );
838
+ });
839
+
840
+ it('gives up after the grace polls', () => {
841
+ assert.strictEqual(
842
+ isScreenRunFinished(false, false, SCREEN_UNLISTED_GRACE_POLLS),
843
+ true
844
+ );
845
+ });
846
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Regression tests for issue #247: with cgroup counters unreadable on both
3
+ * sides (no local cgroup, no `memory.events` in the container because the
4
+ * `memory` controller is not delegated), the sampler kept starting one
5
+ * `docker exec` and one `docker inspect -f {{.State.Running}}` every interval
6
+ * for the whole life of the task, although no round could ever record a
7
+ * sample.
8
+ *
9
+ * Now exit 3 (unreadable counters) from the stream or from the per-round read
10
+ * records the reason and stops, as exit 126/127 already did, and other failed
11
+ * rounds back off exponentially. The no-memory fixture modes run the real
12
+ * in-container scripts against a cgroup directory without `memory.events`.
13
+ */
14
+
15
+ const { describe, it, expect } = require('bun:test');
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const { spawnSync } = require('child_process');
20
+ const {
21
+ CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
22
+ CGROUP_UNREADABLE_EXIT,
23
+ buildCgroupSamplerStartSnippet,
24
+ buildCgroupSamplerStopSnippet,
25
+ buildCgroupMemoryLogSnippet,
26
+ } = require('../src/lib/cgroup-memory');
27
+
28
+ /** Runs the shared fixture of issue #238 and returns its trace. */
29
+ function sample(mode, interval) {
30
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sampler-247-'));
31
+ try {
32
+ const script = path.join(dir, 'sample.sh');
33
+ fs.writeFileSync(
34
+ script,
35
+ [
36
+ buildCgroupSamplerStartSnippet("task '238'"),
37
+ 'wait "$__start_command_cgroup_sampler"',
38
+ buildCgroupSamplerStopSnippet(),
39
+ 'printf "sample=%s\\n" "$__start_command_cgroup"',
40
+ buildCgroupMemoryLogSnippet('"$TMPDIR/memory.log"'),
41
+ 'cat "$TMPDIR/memory.log" "$TMPDIR/calls"',
42
+ 'sed "s/^/sleep=/" "$TMPDIR/sleeps"',
43
+ 'for file in "$TMPDIR"/start-command-cgroup*; do [ ! -e "$file" ] || exit 1; done',
44
+ ].join('; ')
45
+ );
46
+ const env = { ...process.env };
47
+ delete env.START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS;
48
+ if (interval !== undefined) {
49
+ env.START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS = interval;
50
+ }
51
+ const result = spawnSync(
52
+ '/bin/sh',
53
+ [
54
+ path.resolve(
55
+ __dirname,
56
+ '../../experiments/issue-238-cgroup-polling.sh'
57
+ ),
58
+ mode,
59
+ script,
60
+ ],
61
+ { encoding: 'utf8', env, timeout: 30000 }
62
+ );
63
+ expect(result.status).toBe(0);
64
+ return result.stdout;
65
+ } finally {
66
+ fs.rmSync(dir, { recursive: true, force: true });
67
+ }
68
+ }
69
+
70
+ const count = (output, text) =>
71
+ output.split('\n').filter((line) => line.includes(text)).length;
72
+ const sleeps = (output) =>
73
+ output
74
+ .split('\n')
75
+ .filter((line) => line.startsWith('sleep='))
76
+ .map((line) => Number(line.slice('sleep='.length)));
77
+ const posix = process.platform === 'win32' ? describe.skip : describe;
78
+ const unreadable = `Memory: unavailable (local cgroup unavailable; Docker exec exit=${CGROUP_UNREADABLE_EXIT}: private cgroup v2 counters unreadable)`;
79
+
80
+ posix('issue #247: unreadable cgroup counters stop the sampler', () => {
81
+ it('stops after the stream exits 3, without per-round execs or liveness probes', () => {
82
+ const output = sample('no-memory');
83
+ expect(output).toContain('sample=\n');
84
+ expect(output).toContain(unreadable);
85
+ expect(count(output, "exec -i task '238' sh -c")).toBe(1);
86
+ expect(count(output, "task '238' sh -c")).toBe(1);
87
+ expect(count(output, '{{.State.Running}}')).toBe(0);
88
+ expect(sleeps(output)).toEqual([]);
89
+ });
90
+
91
+ it('stops after a per-round read exits 3', () => {
92
+ // The stream fails for a transient reason first, so one per-round exec
93
+ // runs; its real read finds no memory.events.
94
+ const output = sample('no-memory-outage');
95
+ expect(output).toContain(unreadable);
96
+ expect(count(output, "exec -i task '238' sh -c")).toBe(1);
97
+ expect(count(output, "exec task '238' sh -c")).toBe(1);
98
+ expect(count(output, '{{.State.Running}}')).toBe(1);
99
+ expect(sleeps(output)).toHaveLength(1);
100
+ });
101
+
102
+ it('stops after a per-round exec finds no shell', () => {
103
+ const output = sample('no-shell-outage');
104
+ expect(output).toContain('Docker exec exit=127: sh: not found');
105
+ expect(count(output, "exec task '238' sh -c")).toBe(1);
106
+ expect(count(output, '{{.State.Running}}')).toBe(1);
107
+ });
108
+
109
+ it('records the per-round reason as exit 3, not as an exec failure', () => {
110
+ const snippet = buildCgroupSamplerStartSnippet('task');
111
+ expect(snippet).toContain(
112
+ `__start_command_cgroup_read /sys/fs/cgroup || exit ${CGROUP_UNREADABLE_EXIT}`
113
+ );
114
+ expect(snippet).toContain('3|126|127) break;;');
115
+ });
116
+ });
117
+
118
+ posix('issue #247: failed rounds back off exponentially', () => {
119
+ it('doubles the wait after each round without a sample', () => {
120
+ // Round 0 streams a sample; the stream, then per-round execs, fail.
121
+ expect(sleeps(sample('outage'))).toEqual([5, 10, 20]);
122
+ });
123
+
124
+ it(`caps the wait at ${CGROUP_SAMPLE_BACKOFF_MAX_SECONDS} seconds`, () => {
125
+ expect(sleeps(sample('outage', '40'))).toEqual([
126
+ 40,
127
+ CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
128
+ CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
129
+ ]);
130
+ });
131
+
132
+ it('never waits less than a longer interval', () => {
133
+ expect(sleeps(sample('outage', '100'))).toEqual([100, 100, 100]);
134
+ });
135
+
136
+ it('keeps the interval while samples arrive', () => {
137
+ expect(sleeps(sample('remote', '2'))).toEqual([2, 2, 2]);
138
+ expect(sleeps(sample('local'))).toEqual([5, 5, 5]);
139
+ });
140
+ });