start-command 0.30.1 → 0.30.3

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,19 @@
1
1
  # start-command
2
2
 
3
+ ## 0.30.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 55ff85b: Treat detached Docker sessions with `oomKilled` as terminal in status output, using Docker's exit code when available and 137 as the OOM fallback.
8
+
9
+ ## 0.30.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Apply the CI test runner's 30 second timeout consistently so slower integration-style tests do not fail at Bun's default 5 second limit.
14
+
15
+ Surface detached Docker OOMKilled status and preserve abnormal containers under the default cleanup policy.
16
+
3
17
  ## 0.30.1
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.30.1",
3
+ "version": "0.30.3",
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": {
@@ -20,9 +20,9 @@
20
20
  * --keep-user Keep isolated user after command completes (don't delete)
21
21
  * --keep-alive, -k Keep isolation environment alive after command exits
22
22
  * --auto-remove-docker-container Always remove docker container after exit (compatibility alias)
23
- * --always-cleanup-container Always remove docker container after exit (default)
23
+ * --always-cleanup-container Always remove docker container after exit
24
24
  * --keep-container Keep docker container filesystem after exit
25
- * --keep-container-on-fail Remove successful docker containers, keep failed ones
25
+ * --keep-container-on-fail Remove successful docker containers, keep failed or OOM-killed ones
26
26
  * --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
27
27
  * --use-command-stream Use command-stream library for command execution (experimental)
28
28
  * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
@@ -185,9 +185,9 @@ function parseArgs(args) {
185
185
  keepUser: false, // Keep isolated user after command completes (don't delete)
186
186
  keepAlive: false, // Keep environment alive after command exits
187
187
  autoRemoveDockerContainer: false, // Always remove docker container after exit (compatibility alias)
188
- alwaysCleanupContainer: false, // Explicitly request default always-cleanup docker policy
188
+ alwaysCleanupContainer: false, // Force docker container cleanup after exit
189
189
  keepContainer: false, // Keep docker container filesystem after exit
190
- keepContainerOnFail: false, // Keep docker container filesystem only when command fails
190
+ keepContainerOnFail: false, // Keep docker container filesystem when command fails or OOM-kills
191
191
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
192
192
  useCommandStream: false, // Use command-stream library for command execution
193
193
  status: null, // UUID to show status for
@@ -6,11 +6,23 @@ const {
6
6
  } = require('./isolation-log-utils');
7
7
 
8
8
  const DOCKER_CONTAINER_CLEANUP_POLICY = {
9
+ DEFAULT: 'default',
9
10
  ALWAYS: 'always',
10
11
  KEEP: 'keep',
11
12
  KEEP_ON_FAIL: 'keep-on-fail',
12
13
  };
13
14
 
15
+ function getDockerCommand() {
16
+ return process.env.START_DOCKER_BIN || 'docker';
17
+ }
18
+
19
+ function getDockerSpawnOptions(options = {}) {
20
+ if (process.platform === 'win32' && process.env.START_DOCKER_BIN) {
21
+ return { ...options, shell: true };
22
+ }
23
+ return options;
24
+ }
25
+
14
26
  function getDockerContainerCleanupPolicy(options = {}) {
15
27
  if (options.keepContainer) {
16
28
  return DOCKER_CONTAINER_CLEANUP_POLICY.KEEP;
@@ -18,15 +30,25 @@ function getDockerContainerCleanupPolicy(options = {}) {
18
30
  if (options.keepContainerOnFail) {
19
31
  return DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL;
20
32
  }
21
- return DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS;
33
+ if (options.alwaysCleanupContainer || options.autoRemoveDockerContainer) {
34
+ return DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS;
35
+ }
36
+ return DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT;
37
+ }
38
+
39
+ function isAbnormalDockerExit(exitCode, oomKilled = false) {
40
+ return exitCode !== 0 || oomKilled === true;
22
41
  }
23
42
 
24
- function shouldCleanupDockerContainer(policy, exitCode) {
43
+ function shouldCleanupDockerContainer(policy, exitCode, oomKilled = false) {
25
44
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
26
45
  return true;
27
46
  }
47
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT) {
48
+ return !isAbnormalDockerExit(exitCode, oomKilled);
49
+ }
28
50
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
29
- return exitCode === 0;
51
+ return !isAbnormalDockerExit(exitCode, oomKilled);
30
52
  }
31
53
  return false;
32
54
  }
@@ -50,18 +72,53 @@ function appendDockerContainerCleanupPolicyMessage(
50
72
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
51
73
  return (
52
74
  `${message}\nContainer will be removed after successful completion.` +
53
- `\nContainer will be kept if the command fails.` +
75
+ `\nContainer will be kept if the command fails or Docker reports OOMKilled.` +
76
+ `\nRemove when done: docker rm -f ${containerName}`
77
+ );
78
+ }
79
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT) {
80
+ return (
81
+ `${message}\nContainer will be removed after successful completion.` +
82
+ `\nContainer will be kept if the command fails or Docker reports OOMKilled.` +
54
83
  `\nRemove when done: docker rm -f ${containerName}`
55
84
  );
56
85
  }
57
86
  return `${message}\nContainer will be removed after command completes.`;
58
87
  }
59
88
 
89
+ function readDockerContainerOomKilled(containerName) {
90
+ const result = spawnSync(
91
+ getDockerCommand(),
92
+ ['inspect', '-f', '{{.State.OOMKilled}}', containerName],
93
+ getDockerSpawnOptions({
94
+ encoding: 'utf8',
95
+ env: process.env,
96
+ stdio: ['pipe', 'pipe', 'pipe'],
97
+ })
98
+ );
99
+ if (result.error || result.status !== 0) {
100
+ return null;
101
+ }
102
+ const value = String(result.stdout || '').trim();
103
+ if (value === 'true') {
104
+ return true;
105
+ }
106
+ if (value === 'false') {
107
+ return false;
108
+ }
109
+ return null;
110
+ }
111
+
60
112
  function removeDockerContainer(containerName, logPath = null) {
61
- const result = spawnSync('docker', ['rm', '-f', containerName], {
62
- encoding: 'utf8',
63
- stdio: ['pipe', 'pipe', 'pipe'],
64
- });
113
+ const result = spawnSync(
114
+ getDockerCommand(),
115
+ ['rm', '-f', containerName],
116
+ getDockerSpawnOptions({
117
+ encoding: 'utf8',
118
+ env: process.env,
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ })
121
+ );
65
122
  const output = `${result.stdout || ''}${result.stderr || ''}`;
66
123
  if (logPath && output) {
67
124
  appendLogFile(logPath, output.endsWith('\n') ? output : `${output}\n`);
@@ -69,6 +126,24 @@ function removeDockerContainer(containerName, logPath = null) {
69
126
  return !result.error && result.status === 0;
70
127
  }
71
128
 
129
+ function buildDockerKeptLogSnippet(containerName, quotedLogPath) {
130
+ const quotedName = shellQuote(containerName);
131
+ return (
132
+ `printf '\\nContainer kept for investigation: %s\\nReason: exitCode=%s oomKilled=%s\\n` +
133
+ `Inspect: docker exec -it %s sh (if running) or docker start -ai %s\\n` +
134
+ `Remove when done: docker rm -f %s\\n' ` +
135
+ `${quotedName} "$__start_command_exit" "$__start_command_oom" ` +
136
+ `${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
137
+ );
138
+ }
139
+
140
+ function buildSuccessfulNonOomCondition() {
141
+ return (
142
+ '[ "$__start_command_exit" -eq 0 ] 2>/dev/null && ' +
143
+ '[ "$__start_command_oom" != true ]'
144
+ );
145
+ }
146
+
72
147
  function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
73
148
  const quotedName = shellQuote(containerName);
74
149
  const parts = [];
@@ -77,26 +152,37 @@ function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
77
152
  const quotedLogPath = shellQuote(logPath);
78
153
  parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
79
154
  parts.push(
80
- `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${quotedName} 2>/dev/null || printf '%s' '-1')`
155
+ `__start_command_state=$(docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' ${quotedName} 2>/dev/null || printf '%s' '-1 false')`
81
156
  );
157
+ parts.push('__start_command_exit=${__start_command_state%% *}');
158
+ parts.push('__start_command_oom=${__start_command_state##* }');
82
159
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
83
160
  parts.push(`docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true`);
84
- } else if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
161
+ } else if (
162
+ policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
163
+ policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
164
+ ) {
165
+ const successCondition = buildSuccessfulNonOomCondition();
85
166
  parts.push(
86
- `if [ "$__start_command_exit" -eq 0 ] 2>/dev/null; then docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; fi`
167
+ `if ${successCondition}; then docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; else ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}; fi`
87
168
  );
88
169
  }
89
170
  parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
90
171
  } else {
91
172
  parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
92
173
  parts.push(
93
- `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${quotedName} 2>/dev/null || printf '%s' '-1')`
174
+ `__start_command_state=$(docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' ${quotedName} 2>/dev/null || printf '%s' '-1 false')`
94
175
  );
176
+ parts.push('__start_command_exit=${__start_command_state%% *}');
177
+ parts.push('__start_command_oom=${__start_command_state##* }');
95
178
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
96
179
  parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
97
- } else if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
180
+ } else if (
181
+ policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
182
+ policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
183
+ ) {
98
184
  parts.push(
99
- `if [ "$__start_command_exit" -eq 0 ] 2>/dev/null; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
185
+ `if ${buildSuccessfulNonOomCondition()}; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
100
186
  );
101
187
  }
102
188
  }
@@ -118,12 +204,20 @@ function startDetachedDockerCompletionWatcher(containerName, policy, logPath) {
118
204
 
119
205
  function spawnAttachedDocker(dockerArgs, logPath) {
120
206
  if (!logPath) {
121
- return spawn('docker', dockerArgs, { stdio: 'inherit' });
207
+ return spawn(
208
+ getDockerCommand(),
209
+ dockerArgs,
210
+ getDockerSpawnOptions({ stdio: 'inherit' })
211
+ );
122
212
  }
123
213
 
124
- const child = spawn('docker', dockerArgs, {
125
- stdio: ['inherit', 'pipe', 'pipe'],
126
- });
214
+ const child = spawn(
215
+ getDockerCommand(),
216
+ dockerArgs,
217
+ getDockerSpawnOptions({
218
+ stdio: ['inherit', 'pipe', 'pipe'],
219
+ })
220
+ );
127
221
  const tee = (chunk, stream) => {
128
222
  stream.write(chunk);
129
223
  appendLogFile(logPath, chunk.toString());
@@ -135,11 +229,16 @@ function spawnAttachedDocker(dockerArgs, logPath) {
135
229
 
136
230
  module.exports = {
137
231
  DOCKER_CONTAINER_CLEANUP_POLICY,
232
+ getDockerCommand,
233
+ getDockerSpawnOptions,
138
234
  getDockerContainerCleanupPolicy,
235
+ isAbnormalDockerExit,
139
236
  shouldCleanupDockerContainer,
140
237
  getDockerContainerCleanupInstructions,
141
238
  appendDockerContainerCleanupPolicyMessage,
239
+ readDockerContainerOomKilled,
142
240
  removeDockerContainer,
241
+ buildDetachedDockerCompletionScript,
143
242
  startDetachedDockerCompletionWatcher,
144
243
  spawnAttachedDocker,
145
244
  };
@@ -10,6 +10,7 @@ const {
10
10
  escapeForLinksNotation,
11
11
  formatAsNestedLinksNotation,
12
12
  } = require('./output-blocks');
13
+ const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
13
14
 
14
15
  const ControlAction = {
15
16
  STOP: 'stop',
@@ -17,10 +18,15 @@ const ControlAction = {
17
18
  };
18
19
 
19
20
  function runCommand(command, args) {
20
- const result = spawnSync(command, args, {
21
+ const options = {
21
22
  encoding: 'utf8',
22
23
  stdio: ['ignore', 'pipe', 'pipe'],
23
- });
24
+ };
25
+ const result = spawnSync(
26
+ command,
27
+ args,
28
+ command === getDockerCommand() ? getDockerSpawnOptions(options) : options
29
+ );
24
30
 
25
31
  return {
26
32
  success: !result.error && result.status === 0,
@@ -143,7 +149,7 @@ function collectProcessIds(record, runner = runCommand) {
143
149
  addIfPresent(processIds, 'commandPids', commandPids);
144
150
  } else if (isolated === 'docker') {
145
151
  addIfPresent(processIds, 'containerId', opts.containerId);
146
- const result = runner('docker', [
152
+ const result = runner(getDockerCommand(), [
147
153
  'inspect',
148
154
  '-f',
149
155
  '{{.Id}} {{.State.Pid}}',
@@ -14,6 +14,7 @@
14
14
  * - logPath: Path to the log file
15
15
  * - startTime: Timestamp when execution started
16
16
  * - endTime: Timestamp when execution completed (null while executing)
17
+ * - oomKilled: Docker resource-exhaustion signal when available
17
18
  * - options: Execution options (isolation mode, etc.)
18
19
  */
19
20
 
@@ -73,6 +74,8 @@ class ExecutionRecord {
73
74
  this.logPath = options.logPath || '';
74
75
  this.startTime = options.startTime || new Date().toISOString();
75
76
  this.endTime = options.endTime || null;
77
+ this.oomKilled =
78
+ options.oomKilled !== undefined ? options.oomKilled : undefined;
76
79
  this.workingDirectory = options.workingDirectory || process.cwd();
77
80
  this.shell = options.shell || process.env.SHELL || '/bin/sh';
78
81
  this.platform = options.platform || process.platform;
@@ -93,7 +96,7 @@ class ExecutionRecord {
93
96
  * Convert to plain object for serialization
94
97
  */
95
98
  toObject() {
96
- return {
99
+ const obj = {
97
100
  uuid: this.uuid,
98
101
  pid: this.pid,
99
102
  status: this.status,
@@ -102,11 +105,17 @@ class ExecutionRecord {
102
105
  logPath: this.logPath,
103
106
  startTime: this.startTime,
104
107
  endTime: this.endTime,
108
+ };
109
+ if (this.oomKilled !== undefined && this.oomKilled !== null) {
110
+ obj.oomKilled = this.oomKilled;
111
+ }
112
+ Object.assign(obj, {
105
113
  workingDirectory: this.workingDirectory,
106
114
  shell: this.shell,
107
115
  platform: this.platform,
108
116
  options: this.options,
109
- };
117
+ });
118
+ return obj;
110
119
  }
111
120
 
112
121
  /**
@@ -27,6 +27,7 @@ const {
27
27
  shouldCleanupDockerContainer,
28
28
  getDockerContainerCleanupInstructions,
29
29
  appendDockerContainerCleanupPolicyMessage,
30
+ readDockerContainerOomKilled,
30
31
  removeDockerContainer,
31
32
  startDetachedDockerCompletionWatcher,
32
33
  spawnAttachedDocker,
@@ -758,7 +759,11 @@ function runInDocker(command, options = {}) {
758
759
  message += `\n${hint}`;
759
760
  }
760
761
 
761
- if (shouldCleanupDockerContainer(cleanupPolicy, exitCode)) {
762
+ const oomKilled = readDockerContainerOomKilled(containerName);
763
+
764
+ if (
765
+ shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
766
+ ) {
762
767
  if (removeDockerContainer(containerName, options.logPath)) {
763
768
  message += `\nContainer removed after completion.`;
764
769
  } else {
@@ -768,9 +773,13 @@ function runInDocker(command, options = {}) {
768
773
  } else if (cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
769
774
  message += `\n${getDockerContainerCleanupInstructions(containerName)}`;
770
775
  } else if (
771
- cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
776
+ cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL ||
777
+ cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT
772
778
  ) {
773
- message += `\nContainer kept because the command failed.`;
779
+ message +=
780
+ oomKilled === true
781
+ ? `\nContainer kept because Docker reports it was OOM-killed.`
782
+ : `\nContainer kept because the command failed.`;
774
783
  message += `\nRemove when done: docker rm -f ${containerName}`;
775
784
  }
776
785
 
@@ -14,6 +14,7 @@ const {
14
14
  formatAsNestedLinksNotation,
15
15
  } = require('./output-blocks');
16
16
  const { collectProcessIds } = require('./execution-control');
17
+ const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
17
18
 
18
19
  /**
19
20
  * Inspect the live state of a detached docker container by name.
@@ -30,26 +31,54 @@ const { collectProcessIds } = require('./execution-control');
30
31
  * instead of fabricating a terminal `-1` result.
31
32
  *
32
33
  * @param {string} sessionName - Container name
33
- * @returns {{running: boolean, exitCode: number|null}|null} State, or null when
34
- * the container cannot be inspected (not found yet, removed, or docker error)
34
+ * @returns {{running: boolean, exitCode: number|null, oomKilled: boolean|null}|null} State,
35
+ * or null when the container cannot be inspected (not found yet, removed, or docker error)
35
36
  */
36
37
  function inspectDockerState(sessionName) {
37
38
  const result = spawnSync(
38
- 'docker',
39
- ['inspect', '-f', '{{.State.Running}} {{.State.ExitCode}}', sessionName],
40
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
39
+ getDockerCommand(),
40
+ [
41
+ 'inspect',
42
+ '-f',
43
+ '{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}',
44
+ sessionName,
45
+ ],
46
+ getDockerSpawnOptions({
47
+ encoding: 'utf8',
48
+ env: process.env,
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ })
41
51
  );
42
52
  if (result.error || result.status !== 0 || !result.stdout) {
43
53
  return null;
44
54
  }
45
- const [runningRaw, exitRaw] = result.stdout.trim().split(/\s+/);
55
+ const [runningRaw, exitRaw, oomKilledRaw] = result.stdout.trim().split(/\s+/);
46
56
  const exitCode = Number.parseInt(exitRaw, 10);
47
57
  return {
48
58
  running: runningRaw === 'true',
49
59
  exitCode: Number.isFinite(exitCode) ? exitCode : null,
60
+ oomKilled:
61
+ oomKilledRaw === 'true' ? true : oomKilledRaw === 'false' ? false : null,
50
62
  };
51
63
  }
52
64
 
65
+ function isDetachedDockerRecord(record) {
66
+ const opts = record.options || {};
67
+ return (
68
+ opts.isolated === 'docker' &&
69
+ opts.isolationMode === 'detached' &&
70
+ Boolean(opts.sessionName)
71
+ );
72
+ }
73
+
74
+ function readDockerState(record) {
75
+ const opts = record.options || {};
76
+ if (opts.isolated !== 'docker' || !opts.sessionName) {
77
+ return null;
78
+ }
79
+ return inspectDockerState(opts.sessionName);
80
+ }
81
+
53
82
  /**
54
83
  * Best-effort terminal exit code reported by the isolation backend itself
55
84
  * (currently docker via `docker inspect .State.ExitCode`). Returns null when
@@ -59,14 +88,25 @@ function inspectDockerState(sessionName) {
59
88
  * @returns {number|null}
60
89
  */
61
90
  function readBackendExitCode(record) {
62
- const opts = record.options || {};
63
- if (opts.isolated !== 'docker' || !opts.sessionName) {
64
- return null;
65
- }
66
- const state = inspectDockerState(opts.sessionName);
91
+ const state = readDockerState(record);
67
92
  return state && !state.running ? state.exitCode : null;
68
93
  }
69
94
 
95
+ function resolveOomExitCode(footerExit, dockerState) {
96
+ if (footerExit !== null && footerExit !== undefined) {
97
+ return footerExit;
98
+ }
99
+ if (
100
+ dockerState &&
101
+ dockerState.exitCode !== null &&
102
+ dockerState.exitCode !== undefined &&
103
+ (!dockerState.running || dockerState.exitCode !== 0)
104
+ ) {
105
+ return dockerState.exitCode;
106
+ }
107
+ return 137;
108
+ }
109
+
70
110
  /**
71
111
  * Check if a detached isolation session is still running
72
112
  * @param {Object} record - Execution record
@@ -151,8 +191,15 @@ function readExitCodeFromLog(logPath) {
151
191
  * @returns {Object} Possibly updated execution record
152
192
  */
153
193
  function enrichDetachedStatus(record) {
154
- const alive = isDetachedSessionAlive(record);
155
194
  const footerExit = readExitCodeFromLog(record.logPath);
195
+ const dockerState = isDetachedDockerRecord(record)
196
+ ? readDockerState(record)
197
+ : null;
198
+ const alive = isDetachedDockerRecord(record)
199
+ ? dockerState === null
200
+ ? null
201
+ : dockerState.running
202
+ : isDetachedSessionAlive(record);
156
203
 
157
204
  // Create a shallow copy to avoid mutating the original
158
205
  const cloneRecord = () => {
@@ -161,6 +208,19 @@ function enrichDetachedStatus(record) {
161
208
  return enriched;
162
209
  };
163
210
 
211
+ if (record.oomKilled === true || dockerState?.oomKilled === true) {
212
+ const enriched = cloneRecord();
213
+ enriched.oomKilled = true;
214
+ enriched.status = 'executed';
215
+ if (enriched.exitCode === null || enriched.exitCode === undefined) {
216
+ enriched.exitCode = resolveOomExitCode(footerExit, dockerState);
217
+ }
218
+ if (!enriched.endTime) {
219
+ enriched.endTime = new Date().toISOString();
220
+ }
221
+ return enriched;
222
+ }
223
+
164
224
  if (alive === null) {
165
225
  // Liveness is unknown: the backend could not be probed (e.g. a detached
166
226
  // docker container that is not visible yet on a slow Docker-in-Docker host,
@@ -183,6 +243,9 @@ function enrichDetachedStatus(record) {
183
243
  }
184
244
 
185
245
  const enriched = cloneRecord();
246
+ if (dockerState?.oomKilled !== null && dockerState?.oomKilled !== undefined) {
247
+ enriched.oomKilled = dockerState.oomKilled;
248
+ }
186
249
 
187
250
  if (alive && enriched.status === 'executed') {
188
251
  // A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
@@ -358,6 +421,9 @@ function formatRecordAsText(record) {
358
421
  `Status: ${obj.status}`,
359
422
  `Command: ${obj.command}`,
360
423
  `Exit Code: ${obj.exitCode !== null ? obj.exitCode : 'N/A'}`,
424
+ ...(obj.oomKilled !== undefined
425
+ ? [`OOM Killed: ${obj.oomKilled}`]
426
+ : []),
361
427
  `PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
362
428
  `Working Directory: ${obj.workingDirectory}`,
363
429
  `Shell: ${obj.shell}`,
package/src/lib/usage.js CHANGED
@@ -19,9 +19,9 @@ Options:
19
19
  --keep-user Keep isolated user after command completes
20
20
  --keep-alive, -k Keep isolation environment alive after command exits
21
21
  --auto-remove-docker-container Always remove docker container after exit (compatibility alias)
22
- --always-cleanup-container Always remove docker container after exit (default)
22
+ --always-cleanup-container Always remove docker container after exit
23
23
  --keep-container Keep docker container filesystem after exit
24
- --keep-container-on-fail Remove successful docker containers, keep failed ones
24
+ --keep-container-on-fail Remove successful docker containers, keep failed or OOM-killed ones
25
25
  --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
26
26
  --use-command-stream Use command-stream library for execution (experimental)
27
27
  --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
@@ -11,6 +11,12 @@ const path = require('path');
11
11
  const { isCommandAvailable } = require('../src/lib/isolation');
12
12
  const { runInDocker } = require('../src/lib/isolation');
13
13
  const { execSync } = require('child_process');
14
+ const {
15
+ DOCKER_CONTAINER_CLEANUP_POLICY,
16
+ buildDetachedDockerCompletionScript,
17
+ getDockerContainerCleanupPolicy,
18
+ shouldCleanupDockerContainer,
19
+ } = require('../src/lib/docker-cleanup');
14
20
 
15
21
  // Helper to wait for a condition with timeout
16
22
  async function waitFor(conditionFn, timeout = 5000, interval = 100) {
@@ -33,6 +39,47 @@ describe('Docker Container Cleanup Policy', () => {
33
39
  // These tests verify that docker isolation removes finished containers by
34
40
  // default while still providing explicit flags to keep them for investigation.
35
41
 
42
+ describe('cleanup decisions', () => {
43
+ it('should keep abnormal containers under the default policy', () => {
44
+ const policy = getDockerContainerCleanupPolicy({});
45
+ assert.strictEqual(policy, DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT);
46
+ assert.strictEqual(shouldCleanupDockerContainer(policy, 0, false), true);
47
+ assert.strictEqual(shouldCleanupDockerContainer(policy, 7, false), false);
48
+ assert.strictEqual(shouldCleanupDockerContainer(policy, 0, true), false);
49
+ });
50
+
51
+ it('should keep OOM-killed containers with keepContainerOnFail', () => {
52
+ const policy = getDockerContainerCleanupPolicy({
53
+ keepContainerOnFail: true,
54
+ });
55
+ assert.strictEqual(policy, DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL);
56
+ assert.strictEqual(shouldCleanupDockerContainer(policy, 0, false), true);
57
+ assert.strictEqual(shouldCleanupDockerContainer(policy, 0, true), false);
58
+ });
59
+
60
+ it('should honor explicit always-cleanup policy', () => {
61
+ const policy = getDockerContainerCleanupPolicy({
62
+ alwaysCleanupContainer: true,
63
+ });
64
+ assert.strictEqual(policy, DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS);
65
+ assert.strictEqual(shouldCleanupDockerContainer(policy, 7, false), true);
66
+ assert.strictEqual(shouldCleanupDockerContainer(policy, 0, true), true);
67
+ });
68
+
69
+ it('should make the detached watcher inspect OOMKilled before default cleanup', () => {
70
+ const script = buildDetachedDockerCompletionScript(
71
+ 'issue144-container',
72
+ DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
73
+ '/tmp/issue144.log'
74
+ );
75
+ assert.match(script, /\.State\.ExitCode.*\.State\.OOMKilled/);
76
+ assert.match(script, /__start_command_oom/);
77
+ assert.match(script, /Container kept for investigation/);
78
+ assert.match(script, /docker rm -f/);
79
+ assert.match(script, /issue144-container/);
80
+ });
81
+ });
82
+
36
83
  describe('auto-remove enabled', () => {
37
84
  it(
38
85
  'should automatically remove container when autoRemoveDockerContainer is true',
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ applyDefaultTimeout,
4
+ hasExplicitTimeout,
5
+ } from '../../scripts/run-js-tests.mjs';
6
+
7
+ describe('run-js-tests timeout defaults', () => {
8
+ test('adds a 30 second timeout when no timeout is supplied', () => {
9
+ expect(applyDefaultTimeout(['--coverage'])).toEqual([
10
+ '--timeout',
11
+ '30000',
12
+ '--coverage',
13
+ ]);
14
+ });
15
+
16
+ test('preserves an explicit long timeout flag', () => {
17
+ expect(hasExplicitTimeout(['--timeout', '45000'])).toBe(true);
18
+ expect(applyDefaultTimeout(['--timeout', '45000'])).toEqual([
19
+ '--timeout',
20
+ '45000',
21
+ ]);
22
+ });
23
+
24
+ test('preserves an explicit inline timeout flag', () => {
25
+ expect(hasExplicitTimeout(['--timeout=45000'])).toBe(true);
26
+ expect(applyDefaultTimeout(['--timeout=45000'])).toEqual([
27
+ '--timeout=45000',
28
+ ]);
29
+ });
30
+
31
+ test('preserves Bun short timeout flag', () => {
32
+ expect(hasExplicitTimeout(['-t', '45000'])).toBe(true);
33
+ expect(applyDefaultTimeout(['-t', '45000'])).toEqual(['-t', '45000']);
34
+ });
35
+ });
@@ -16,6 +16,7 @@ const {
16
16
  } = require('../src/lib/execution-store');
17
17
  const {
18
18
  queryStatus,
19
+ listExecutions,
19
20
  isDetachedSessionAlive,
20
21
  enrichDetachedStatus,
21
22
  attachCurrentTime,
@@ -55,6 +56,65 @@ function runCli(args, env = {}) {
55
56
  };
56
57
  }
57
58
 
59
+ function createExecutable(filePath, content) {
60
+ fs.writeFileSync(filePath, content, 'utf8');
61
+ fs.chmodSync(filePath, 0o755);
62
+ }
63
+
64
+ function withFakeDockerInspect(stateLine, fn) {
65
+ const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-docker-'));
66
+ const dockerPath = path.join(
67
+ fakeBin,
68
+ process.platform === 'win32' ? 'docker.cmd' : 'docker'
69
+ );
70
+ if (process.platform === 'win32') {
71
+ createExecutable(
72
+ dockerPath,
73
+ [
74
+ '@echo off',
75
+ 'if not "%1"=="inspect" exit /b 1',
76
+ 'echo %3 | findstr /C:"State.Pid" >nul',
77
+ 'if %errorlevel%==0 (',
78
+ ' echo fake-container-id 4321',
79
+ ' exit /b 0',
80
+ ')',
81
+ `echo ${stateLine}`,
82
+ 'exit /b 0',
83
+ '',
84
+ ].join('\r\n')
85
+ );
86
+ } else {
87
+ createExecutable(
88
+ dockerPath,
89
+ [
90
+ '#!/bin/sh',
91
+ '[ "$1" = "inspect" ] || exit 1',
92
+ 'case "$3" in',
93
+ ' *State.Pid*) echo "fake-container-id 4321" ;;',
94
+ ` *) echo "${stateLine}" ;;`,
95
+ 'esac',
96
+ '',
97
+ ].join('\n')
98
+ );
99
+ }
100
+
101
+ const originalPath = process.env.PATH;
102
+ const originalDockerBin = process.env.START_DOCKER_BIN;
103
+ process.env.PATH = `${fakeBin}${path.delimiter}${originalPath || ''}`;
104
+ process.env.START_DOCKER_BIN = dockerPath;
105
+ try {
106
+ return fn();
107
+ } finally {
108
+ process.env.PATH = originalPath;
109
+ if (originalDockerBin === undefined) {
110
+ delete process.env.START_DOCKER_BIN;
111
+ } else {
112
+ process.env.START_DOCKER_BIN = originalDockerBin;
113
+ }
114
+ fs.rmSync(fakeBin, { recursive: true, force: true });
115
+ }
116
+ }
117
+
58
118
  describe('Issue #101: --status with session name lookup', () => {
59
119
  let store;
60
120
 
@@ -552,6 +612,130 @@ describe('Issue #136: detached docker session liveness', () => {
552
612
  });
553
613
  });
554
614
 
615
+ describe('Issue #144: detached docker OOMKilled status signal', () => {
616
+ let store;
617
+
618
+ beforeEach(() => {
619
+ cleanupTestDir();
620
+ store = new ExecutionStore({
621
+ appFolder: TEST_APP_FOLDER,
622
+ useLinks: false,
623
+ });
624
+ });
625
+
626
+ afterEach(() => {
627
+ cleanupTestDir();
628
+ });
629
+
630
+ function saveDockerRecord() {
631
+ const record = new ExecutionRecord({
632
+ command: 'sh -c "exit 0"',
633
+ logPath: '/tmp/issue-144.log',
634
+ options: {
635
+ sessionName: 'issue144-oom',
636
+ isolated: 'docker',
637
+ isolationMode: 'detached',
638
+ },
639
+ });
640
+ store.save(record);
641
+ return record;
642
+ }
643
+
644
+ it('surfaces oomKilled in status output even when Docker exit code is 0', () => {
645
+ const record = saveDockerRecord();
646
+
647
+ withFakeDockerInspect('false 0 true', () => {
648
+ const jsonResult = queryStatus(store, record.uuid, 'json');
649
+ expect(jsonResult.success).toBe(true);
650
+ const parsed = JSON.parse(jsonResult.output);
651
+ expect(parsed.status).toBe('executed');
652
+ expect(parsed.exitCode).toBe(0);
653
+ expect(parsed.oomKilled).toBe(true);
654
+
655
+ const linksResult = queryStatus(store, record.uuid, 'links-notation');
656
+ expect(linksResult.success).toBe(true);
657
+ expect(linksResult.output).toContain(' oomKilled true');
658
+
659
+ const textResult = queryStatus(store, record.uuid, 'text');
660
+ expect(textResult.success).toBe(true);
661
+ expect(textResult.output).toContain('OOM Killed: true');
662
+ });
663
+ });
664
+
665
+ it('surfaces oomKilled in list output', () => {
666
+ saveDockerRecord();
667
+
668
+ withFakeDockerInspect('false 0 true', () => {
669
+ const result = listExecutions(store, 'json');
670
+ expect(result.success).toBe(true);
671
+ const parsed = JSON.parse(result.output);
672
+ expect(parsed.count).toBe(1);
673
+ expect(parsed.executions[0].status).toBe('executed');
674
+ expect(parsed.executions[0].exitCode).toBe(0);
675
+ expect(parsed.executions[0].oomKilled).toBe(true);
676
+ });
677
+ });
678
+ });
679
+
680
+ describe('Issue #148: detached docker OOMKilled terminal status', () => {
681
+ let store;
682
+
683
+ beforeEach(() => {
684
+ cleanupTestDir();
685
+ store = new ExecutionStore({
686
+ appFolder: TEST_APP_FOLDER,
687
+ useLinks: false,
688
+ });
689
+ });
690
+
691
+ afterEach(() => {
692
+ cleanupTestDir();
693
+ });
694
+
695
+ function saveDockerRecord() {
696
+ const record = new ExecutionRecord({
697
+ command: 'sh -c "allocate memory"',
698
+ logPath: '/tmp/issue-148.log',
699
+ options: {
700
+ sessionName: 'issue148-oom',
701
+ isolated: 'docker',
702
+ isolationMode: 'detached',
703
+ },
704
+ });
705
+ store.save(record);
706
+ return record;
707
+ }
708
+
709
+ it('treats oomKilled as terminal even while Docker still reports running', () => {
710
+ const record = saveDockerRecord();
711
+
712
+ withFakeDockerInspect('true 137 true', () => {
713
+ const result = queryStatus(store, record.uuid, 'json');
714
+ expect(result.success).toBe(true);
715
+ const parsed = JSON.parse(result.output);
716
+ expect(parsed.status).toBe('executed');
717
+ expect(parsed.exitCode).toBe(137);
718
+ expect(parsed.oomKilled).toBe(true);
719
+ expect(parsed.endTime).toBeTruthy();
720
+ expect(parsed.currentTime).toBeUndefined();
721
+ });
722
+ });
723
+
724
+ it('uses 137 when oomKilled is terminal but Docker has no terminal exit code yet', () => {
725
+ const record = saveDockerRecord();
726
+
727
+ withFakeDockerInspect('true 0 true', () => {
728
+ const result = queryStatus(store, record.uuid, 'json');
729
+ expect(result.success).toBe(true);
730
+ const parsed = JSON.parse(result.output);
731
+ expect(parsed.status).toBe('executed');
732
+ expect(parsed.exitCode).toBe(137);
733
+ expect(parsed.oomKilled).toBe(true);
734
+ expect(parsed.endTime).toBeTruthy();
735
+ });
736
+ });
737
+ });
738
+
555
739
  describe('Issue #105: attachCurrentTime for executing status', () => {
556
740
  it('should add currentTime to serialization when status is executing', () => {
557
741
  const record = new ExecutionRecord({