start-command 0.37.0 → 0.37.2

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,17 @@
1
1
  # start-command
2
2
 
3
+ ## 0.37.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 65314a3: Attribute exit 137 to recent Docker OOM events when the last cgroup sample misses the final kill. Preserve daemon restart and ordinary-exit safeguards, apply attributed evidence to recovery memory limits, and recompute each archived attempt's status diagnostics independently.
8
+
9
+ ## 0.37.1
10
+
11
+ ### Patch Changes
12
+
13
+ - c67c92f: Observe Docker CPU limits applied after launch, restore the remembered base on lift and resume, and recover CPU penalty monitors after crashes, zombie/reused-PID locks and transient tick failures.
14
+
3
15
  ## 0.37.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
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": {
@@ -2,10 +2,20 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { spawnSync } = require('child_process');
5
- const { initialState, evaluate } = require('./cpu-penalty');
5
+ const {
6
+ initialState,
7
+ evaluate,
8
+ observeBase,
9
+ transitionLog,
10
+ } = require('./cpu-penalty');
6
11
  const { LockManager } = require('./store-lock');
7
12
  const { shellQuote } = require('./isolation-log-utils');
8
- const { cpuCount, cpuUpdateArgs } = require('./docker-resource-limits');
13
+ const {
14
+ cpuCount,
15
+ cpuUpdateArgs,
16
+ normalizeResourceLimits,
17
+ readDockerResourceLimits,
18
+ } = require('./docker-resource-limits');
9
19
  function statePath(record) {
10
20
  return `${record.logPath}.cpu-penalty.json`;
11
21
  }
@@ -40,9 +50,21 @@ function publicState(state) {
40
50
  }
41
51
  function run(command, args) {
42
52
  const result = spawnSync(command, args, { encoding: 'utf8' });
43
- return { success: result.status === 0, stdout: result.stdout || '' };
53
+ return {
54
+ success: result.status === 0,
55
+ stdout: result.stdout || '',
56
+ stderr: result.stderr || '',
57
+ };
44
58
  }
45
- async function monitor(uuid, name, attemptNumber, standalone) {
59
+ async function monitor(
60
+ uuid,
61
+ name,
62
+ attemptNumber,
63
+ standalone,
64
+ dependencies = {}
65
+ ) {
66
+ const filesystem = dependencies.fs || fs;
67
+ const runner = dependencies.run || run;
46
68
  const { ExecutionStore } = require('./execution-store');
47
69
  const store = uuid
48
70
  ? new ExecutionStore(
@@ -60,36 +82,40 @@ async function monitor(uuid, name, attemptNumber, standalone) {
60
82
  return;
61
83
  }
62
84
  const config = record.options.cpuPenaltyConfig;
63
- const lock = new LockManager(`${statePath(record)}.lock`);
85
+ if ((record.attempt?.number || 1) !== Number(attemptNumber)) {
86
+ return;
87
+ }
88
+ const lock = new LockManager(`${statePath(record)}.lock`, filesystem, true);
64
89
  if (!lock.acquire(100)) {
65
90
  return;
66
91
  }
67
92
  const docker = require('./docker-cleanup').getDockerCommand();
68
- const baseFlags =
69
- record.options.baseResourceLimits || record.options.resourceLimits || [];
93
+ let baseFlags = normalizeResourceLimits(
94
+ record.options.baseResourceLimits || record.options.resourceLimits
95
+ );
70
96
  const baseCpus = cpuCount(baseFlags);
71
97
  let state = initialState(baseCpus, Date.now(), readState(record));
72
98
  const interval = Math.min(
73
99
  30000,
74
100
  Math.max(50, Math.min(config.triggerWindowMs, config.releaseWindowMs) / 8)
75
101
  );
76
- const publish = () => {
102
+ const publish = (value = state) => {
77
103
  const file = statePath(record),
78
104
  tmp = `${file}.${process.pid}.tmp`;
79
105
  try {
80
- fs.writeFileSync(
106
+ filesystem.writeFileSync(
81
107
  tmp,
82
108
  JSON.stringify({
83
- ...publicState(state),
109
+ ...publicState(value),
84
110
  containerName: name,
85
111
  attemptNumber: Number(attemptNumber),
86
112
  updatedAt: Date.now(),
87
113
  })
88
114
  );
89
- fs.renameSync(tmp, file);
115
+ filesystem.renameSync(tmp, file);
90
116
  } finally {
91
117
  try {
92
- fs.unlinkSync(tmp);
118
+ filesystem.unlinkSync(tmp);
93
119
  } catch {
94
120
  /* Optional diagnostics are best effort. */
95
121
  }
@@ -97,95 +123,188 @@ async function monitor(uuid, name, attemptNumber, standalone) {
97
123
  };
98
124
  const log = (text) => {
99
125
  try {
100
- fs.appendFileSync(record.logPath, `${text}\n`);
126
+ filesystem.appendFileSync(record.logPath, `${text}\n`);
101
127
  } catch {
102
128
  /* Optional diagnostics are best effort. */
103
129
  }
104
130
  };
105
- const started = Date.now();
131
+ const debug = (error) => {
132
+ if (process.env.START_DEBUG === '1') {
133
+ const message = `[DEBUG] CPU monitor: ${error.stack || error}`;
134
+ console.error(message);
135
+ log(message);
136
+ }
137
+ };
138
+ let stopping = false,
139
+ wake;
140
+ const stop = () => {
141
+ stopping = true;
142
+ wake?.();
143
+ };
144
+ process.on('SIGTERM', stop);
145
+ process.on('SIGINT', stop);
106
146
  try {
107
- publish();
108
- while (true) {
109
- const current = store ? store.get(uuid) : record;
110
- if (
111
- !current ||
112
- current.options.sessionName !== name ||
113
- (current.attempt?.number || 1) !== Number(attemptNumber)
114
- ) {
115
- break;
116
- }
117
- const live = run(docker, ['inspect', '-f', '{{.State.Running}}', name]);
118
- if (live.success && live.stdout.trim() === 'false') {
119
- break;
120
- }
121
- if (!live.success && Date.now() - started > 30000) {
122
- break;
123
- }
124
- const info = run(docker, ['info', '--format', '{{json .}}']);
125
- const stats = run(docker, [
126
- 'stats',
127
- '--no-stream',
128
- '--format',
129
- '{{json .}}',
130
- ]);
131
- let daemonCpus = NaN,
132
- cores = NaN;
147
+ while (!stopping) {
133
148
  try {
134
- if (info.success) {
135
- daemonCpus = JSON.parse(info.stdout).NCPU;
149
+ const current = store ? store.get(uuid) : record;
150
+ if (
151
+ !current ||
152
+ current.options.sessionName !== name ||
153
+ (current.attempt?.number || 1) !== Number(attemptNumber)
154
+ ) {
155
+ break;
156
+ }
157
+ const live = runner(docker, [
158
+ 'inspect',
159
+ '-f',
160
+ '{{.State.Running}}',
161
+ name,
162
+ ]);
163
+ if (live.success && live.stdout.trim() === 'false') {
164
+ break;
165
+ }
166
+ if (
167
+ !live.success &&
168
+ /No such (object|container)/i.test(live.stderr || '')
169
+ ) {
170
+ break;
171
+ }
172
+ const observed = readDockerResourceLimits(name, runner);
173
+ if (observed !== null) {
174
+ baseFlags = observed;
175
+ state = observeBase(state, cpuCount(observed), Date.now());
136
176
  }
137
- if (stats.success) {
138
- const row = stats.stdout
139
- .trim()
140
- .split('\n')
141
- .map((s) => JSON.parse(s))
142
- .find((s) => s.Name === name);
143
- if (row && /^\d+(?:\.\d+)?%$/.test(row.CPUPerc)) {
144
- cores = Number(row.CPUPerc.slice(0, -1)) / 100;
177
+ const info = runner(docker, ['info', '--format', '{{json .}}']);
178
+ const stats = runner(docker, [
179
+ 'stats',
180
+ '--no-stream',
181
+ '--format',
182
+ '{{json .}}',
183
+ ]);
184
+ let daemonCpus = NaN,
185
+ cores = NaN;
186
+ try {
187
+ if (info.success) {
188
+ daemonCpus = JSON.parse(info.stdout).NCPU;
189
+ }
190
+ if (stats.success && live.success && observed !== null) {
191
+ const row = stats.stdout
192
+ .trim()
193
+ .split('\n')
194
+ .map((s) => JSON.parse(s))
195
+ .find((s) => s.Name === name);
196
+ if (row && /^\d+(?:\.\d+)?%$/.test(row.CPUPerc)) {
197
+ cores = Number(row.CPUPerc.slice(0, -1)) / 100;
198
+ }
145
199
  }
200
+ } catch (error) {
201
+ debug(error);
146
202
  }
147
- } catch {
148
- /* Optional diagnostics are best effort. */
203
+ debug(
204
+ `sample: phase=${state.phase} base=${state.baseCpus} limit=${state.limitCpus} cores=${cores} daemon=${daemonCpus}`
205
+ );
206
+ const decision = evaluate(
207
+ state,
208
+ {
209
+ now: Date.now(),
210
+ cores,
211
+ daemonCpus,
212
+ maxGapMs: Math.max(5000, interval * 3),
213
+ },
214
+ config
215
+ );
216
+ if (decision.action) {
217
+ const action = decision.action;
218
+ // Persist the base and intent before applying a cap. If this fails,
219
+ // leave Docker alone; a killed monitor can always recover its base.
220
+ if (action.kind === 'apply') {
221
+ publish(decision.state);
222
+ }
223
+ const update = runner(docker, [
224
+ 'update',
225
+ ...cpuUpdateArgs(baseFlags, action.cpus),
226
+ name,
227
+ ]);
228
+ if (update.success) {
229
+ state = decision.state;
230
+ log(transitionLog(action, config));
231
+ } else {
232
+ debug(update.stderr || 'docker update failed');
233
+ state = {
234
+ ...decision.state,
235
+ phase: state.phase,
236
+ since: state.since,
237
+ limitCpus: state.limitCpus,
238
+ penaltyCount: state.penaltyCount,
239
+ samples: [],
240
+ };
241
+ }
242
+ } else {
243
+ state = decision.state;
244
+ }
245
+ publish();
246
+ } catch (error) {
247
+ state.samples = [];
248
+ debug(`tick failed: ${error.stack || error}`);
149
249
  }
150
- const decision = evaluate(
151
- state,
152
- {
153
- now: Date.now(),
154
- cores,
155
- daemonCpus,
156
- maxGapMs: Math.max(5000, interval * 3),
157
- },
158
- config
159
- );
160
- if (decision.action) {
161
- const action = decision.action;
162
- const update = run(docker, [
163
- 'update',
164
- ...cpuUpdateArgs(baseFlags, action.cpus),
250
+ await new Promise((resolve) => {
251
+ const timer = globalThis.setTimeout(resolve, interval);
252
+ wake = () => {
253
+ globalThis.clearTimeout(timer);
254
+ resolve();
255
+ };
256
+ if (stopping) {
257
+ wake();
258
+ }
259
+ });
260
+ }
261
+ } finally {
262
+ try {
263
+ const current = store ? store.get(uuid) : record;
264
+ if (
265
+ state.phase === 'penalized' &&
266
+ current?.options.sessionName === name &&
267
+ (current.attempt?.number || 1) === Number(attemptNumber)
268
+ ) {
269
+ const live = runner(docker, [
270
+ 'inspect',
271
+ '-f',
272
+ '{{.State.Running}}',
165
273
  name,
166
274
  ]);
167
- if (update.success) {
168
- state = decision.state;
169
- log(
170
- `[start] CPU penalty ${action.kind === 'apply' ? 'applied' : 'lifted'}: ${action.cpus} CPUs (${action.average === null || action.average === undefined ? 'capacity changed' : `${action.average.toFixed(2)} of ${action.capacity} CPUs`})`
171
- );
172
- } else {
173
- state = {
174
- ...decision.state,
175
- phase: state.phase,
176
- since: state.since,
177
- limitCpus: state.limitCpus,
178
- penaltyCount: state.penaltyCount,
179
- samples: [],
180
- };
275
+ const observed = readDockerResourceLimits(name, runner);
276
+ const info = runner(docker, ['info', '--format', '{{json .}}']);
277
+ const capacity = info.success ? JSON.parse(info.stdout).NCPU : NaN;
278
+ if (
279
+ live.success &&
280
+ live.stdout.trim() === 'true' &&
281
+ observed !== null &&
282
+ cpuCount(observed) === state.limitCpus &&
283
+ capacity > 0
284
+ ) {
285
+ const cpus = Math.min(state.baseCpus || capacity, capacity);
286
+ const update = runner(docker, [
287
+ 'update',
288
+ ...cpuUpdateArgs(observed, cpus),
289
+ name,
290
+ ]);
291
+ if (update.success) {
292
+ state = {
293
+ ...state,
294
+ phase: 'observing',
295
+ limitCpus: cpus,
296
+ since: Date.now(),
297
+ };
298
+ log(`[start] CPU penalty lifted: ${cpus} CPUs (monitor stopped)`);
299
+ publish();
300
+ }
181
301
  }
182
- } else {
183
- state = decision.state;
184
302
  }
185
- publish();
186
- await new Promise((resolve) => globalThis.setTimeout(resolve, interval));
303
+ } catch (error) {
304
+ debug(error);
187
305
  }
188
- } finally {
306
+ process.off('SIGTERM', stop);
307
+ process.off('SIGINT', stop);
189
308
  lock.release();
190
309
  }
191
310
  }
@@ -204,7 +323,9 @@ function startSnippet(uuid, name, attempt = 1, options = null) {
204
323
  if (!uuid && !standalone) {
205
324
  return '';
206
325
  }
207
- return `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(uuid || '')} ${shellQuote(name)} ${Number(attempt)} ${shellQuote(standalone || '')} >/dev/null 2>&1 & __start_cpu_monitor=$!`;
326
+ const command = `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(uuid || '')} ${shellQuote(name)} ${Number(attempt)} ${shellQuote(standalone || '')}`;
327
+ // wait reaps a crashed child even in containers whose PID 1 never reaps.
328
+ return `( __start_cpu_child=; trap 'if [ -n "$__start_cpu_child" ]; then kill "$__start_cpu_child" 2>/dev/null; wait "$__start_cpu_child" 2>/dev/null; fi; exit 0' TERM INT HUP; while :; do ${command} >/dev/null 2>&1 & __start_cpu_child=$!; wait "$__start_cpu_child"; __start_cpu_code=$?; [ "$__start_cpu_code" -eq 0 ] && break; sleep 1 & __start_cpu_child=$!; wait "$__start_cpu_child"; done ) & __start_cpu_monitor=$!`;
208
329
  }
209
330
  function stopSnippet() {
210
331
  return 'if [ -n "$__start_cpu_monitor" ]; then kill "$__start_cpu_monitor" 2>/dev/null; wait "$__start_cpu_monitor" 2>/dev/null; fi';
@@ -219,6 +340,7 @@ module.exports = {
219
340
  };
220
341
  if (require.main === module) {
221
342
  monitor(...process.argv.slice(2)).catch((error) => {
343
+ process.exitCode = 1;
222
344
  if (process.env.START_DEBUG === '1') {
223
345
  console.error(`[DEBUG] CPU monitor: ${error.stack}`);
224
346
  }
@@ -70,10 +70,13 @@ function validate(options) {
70
70
  options.cpuPenaltyConfig = { ...DEFAULTS, ...options.cpuPenaltyConfig };
71
71
  }
72
72
  function initialState(baseCpus = null, now = Date.now(), saved = null) {
73
+ baseCpus =
74
+ saved && Object.hasOwn(saved, 'baseCpus') ? saved.baseCpus : baseCpus;
73
75
  return {
74
76
  phase: saved?.phase === 'penalized' ? 'penalized' : 'observing',
75
77
  since: saved?.phase === 'penalized' ? saved.since : now,
76
- limitCpus: saved?.phase === 'penalized' ? saved.limitCpus : baseCpus,
78
+ limitCpus:
79
+ saved && Object.hasOwn(saved, 'limitCpus') ? saved.limitCpus : baseCpus,
77
80
  baseCpus,
78
81
  penaltyCount: saved?.penaltyCount || 0,
79
82
  penalizedMs: saved?.penalizedMs || 0,
@@ -82,6 +85,30 @@ function initialState(baseCpus = null, now = Date.now(), saved = null) {
82
85
  capacity: null,
83
86
  };
84
87
  }
88
+ // A quota different from our cap is a supervisor override, not our penalty.
89
+ function observeBase(state, baseCpus, now) {
90
+ if (baseCpus === state.limitCpus) {
91
+ return state;
92
+ }
93
+ return {
94
+ ...state,
95
+ phase: 'observing',
96
+ baseCpus,
97
+ limitCpus: baseCpus,
98
+ since: now,
99
+ samples: [],
100
+ };
101
+ }
102
+ function transitionLog(action, config) {
103
+ const applying = action.kind === 'apply';
104
+ const window = applying ? config.triggerWindowMs : config.releaseWindowMs;
105
+ const duration = window % 60000 === 0 ? `${window / 60000}m` : `${window}ms`;
106
+ const measured =
107
+ action.average === null || action.average === undefined
108
+ ? 'capacity changed'
109
+ : `${action.average.toFixed(2)} of ${applying ? action.capacity : config.cpus} CPUs for ${duration}`;
110
+ return `[start] CPU penalty ${applying ? 'applied' : 'lifted'}: ${action.cpus} CPUs (${measured})`;
111
+ }
85
112
  function average(samples, now, window) {
86
113
  const start = now - window;
87
114
  if (
@@ -170,6 +197,8 @@ module.exports = {
170
197
  validate,
171
198
  duration,
172
199
  initialState,
200
+ observeBase,
201
+ transitionLog,
173
202
  average,
174
203
  evaluate,
175
204
  };
@@ -90,6 +90,9 @@ function archiveAttempt(record) {
90
90
  status: record.status,
91
91
  exitCode: record.exitCode,
92
92
  endTime: record.endTime,
93
+ ...(record.options?.exitEvidence
94
+ ? { exitEvidence: { ...record.options.exitEvidence } }
95
+ : {}),
93
96
  };
94
97
  for (const field of TERMINAL_FIELDS) {
95
98
  if (record[field] !== undefined) {
@@ -142,7 +145,31 @@ function readAttemptLogTail(record, bytes) {
142
145
  return readLogTail(record.logPath, bytes, record.attempt?.logOffset || 0);
143
146
  }
144
147
 
148
+ function enrichAttemptHistory(record, enrich, bytes) {
149
+ return (record.attemptHistory || []).map((attempt, index, history) => {
150
+ const next = history[index + 1];
151
+ const end = next ? next.logOffset : record.attempt?.logOffset;
152
+ const bounded =
153
+ Number.isSafeInteger(attempt.logOffset) &&
154
+ attempt.logOffset >= 0 &&
155
+ Number.isSafeInteger(end) &&
156
+ end >= attempt.logOffset;
157
+ const tail =
158
+ record.logPath && bounded
159
+ ? readLogTail(record.logPath, bytes, attempt.logOffset, end)
160
+ : null;
161
+ const input = {
162
+ ...attempt,
163
+ options: { exitEvidence: attempt.exitEvidence },
164
+ };
165
+ const output = enrich(input, tail);
166
+ delete output.options;
167
+ return output;
168
+ });
169
+ }
170
+
145
171
  module.exports = {
172
+ enrichAttemptHistory,
146
173
  activityPath,
147
174
  appendLifecycle,
148
175
  archiveAttempt,
@@ -323,14 +323,20 @@ function recoverKilledExecution(params = {}) {
323
323
  }
324
324
  }
325
325
  const evidence = require('./exit-evidence');
326
+ const logged = evidence.fromLog(
327
+ record.logPath
328
+ ? require('./execution-attempt').readAttemptLogTail(record, 65536)
329
+ : null
330
+ );
326
331
  const terminalOom =
327
- (params.oomKilled === true || params.oomKilled === 'true') &&
328
- evidence.recentOomDelta(params.cgroupMemory, params.finishedAt) &&
329
- !evidence.fromLog(
330
- record.logPath
331
- ? require('./execution-attempt').readAttemptLogTail(record, 65536)
332
- : null
333
- ).daemonRestart;
332
+ !logged.daemonRestart &&
333
+ (logged.mainOom ||
334
+ ((params.oomKilled === true || params.oomKilled === 'true') &&
335
+ evidence.recentOomDelta(params.cgroupMemory, params.finishedAt)));
336
+ record.options.exitEvidence = {
337
+ mainOom: terminalOom,
338
+ daemonRestart: logged.daemonRestart,
339
+ };
334
340
  let recoveryLimits = null;
335
341
  if (opts.onKillResumeMemory && terminalOom) {
336
342
  try {
@@ -2,6 +2,7 @@
2
2
  const { spawnSync } = require('child_process');
3
3
  const { shellQuote } = require('./isolation-log-utils');
4
4
  const MAIN_OOM = 'Exit evidence: main-oom (recent cgroup oom_kill delta)';
5
+ const DOCKER_OOM = 'Exit evidence: main-oom (recent Docker oom event)';
5
6
  const DAEMON_RESTART =
6
7
  'Exit evidence: docker-daemon-restart (Docker service journal)';
7
8
 
@@ -38,6 +39,22 @@ function daemonRestartEvidence(journal, containerId) {
38
39
  return Boolean(exited && forced);
39
40
  }
40
41
 
42
+ function recentDockerOom(events, finishedAt) {
43
+ const finished = Date.parse(finishedAt) / 1000;
44
+ return (
45
+ Number.isFinite(finished) &&
46
+ String(events || '')
47
+ .split('\n')
48
+ .some((line) => {
49
+ if (!/^\d+$/.test(line.trim())) {
50
+ return false;
51
+ }
52
+ const at = Number(line.trim());
53
+ return at > 0 && finished - at >= -1 && finished - at <= 3;
54
+ })
55
+ );
56
+ }
57
+
41
58
  function collectExitEvidence(
42
59
  { containerName, sample, finishedAt, exitCode, oomKilled },
43
60
  runner
@@ -52,31 +69,56 @@ function collectExitEvidence(
52
69
  const local =
53
70
  !process.env.DOCKER_HOST || process.env.DOCKER_HOST.startsWith('unix://');
54
71
  if (
55
- local &&
56
72
  Number(exitCode) === 137 &&
73
+ containerName &&
57
74
  Number.isFinite(Date.parse(finishedAt))
58
75
  ) {
59
76
  const docker = require('./docker-cleanup').getDockerCommand();
60
77
  const id = run(docker, ['inspect', '-f', '{{.Id}}', containerName]);
61
78
  if (id.success && /^[a-f0-9]{64}$/.test(id.stdout.trim())) {
62
79
  const end = Date.parse(finishedAt);
63
- const journal = run('journalctl', [
64
- '-u',
65
- 'docker.service',
66
- '--since',
67
- new Date(end - 60000).toISOString(),
68
- '--until',
69
- new Date(end + 1000).toISOString(),
70
- '--no-pager',
71
- '-o',
72
- 'cat',
73
- ]);
80
+ const journal = local
81
+ ? run('journalctl', [
82
+ '-u',
83
+ 'docker.service',
84
+ '--since',
85
+ new Date(end - 60000).toISOString(),
86
+ '--until',
87
+ new Date(end + 1000).toISOString(),
88
+ '--no-pager',
89
+ '-o',
90
+ 'cat',
91
+ ])
92
+ : { success: false, stdout: '' };
74
93
  if (
75
94
  journal.success &&
76
95
  daemonRestartEvidence(journal.stdout, id.stdout.trim())
77
96
  ) {
78
97
  return DAEMON_RESTART;
79
98
  }
99
+ // Query the daemon's bounded event history: works with DooD and remote
100
+ // sockets even after the final kill destroys the sampled cgroup.
101
+ const events = run(docker, [
102
+ 'events',
103
+ '--since',
104
+ new Date(end - 60000).toISOString(),
105
+ '--until',
106
+ new Date(end + 1000).toISOString(),
107
+ '--filter',
108
+ `container=${id.stdout.trim()}`,
109
+ '--filter',
110
+ 'event=oom',
111
+ '--format',
112
+ '{{.Time}}',
113
+ ]);
114
+ if (events.success && recentDockerOom(events.stdout, finishedAt)) {
115
+ return DOCKER_OOM;
116
+ }
117
+ if (process.env.START_DEBUG === '1') {
118
+ console.error(
119
+ `[DEBUG] Exit evidence: container=${id.stdout.trim()} finishedAt=${finishedAt} eventsAvailable=${events.success} oomEvents=${JSON.stringify(events.stdout)} sample=${JSON.stringify(sample || '')}`
120
+ );
121
+ }
80
122
  }
81
123
  }
82
124
  if (
@@ -92,17 +134,23 @@ function collectExitEvidence(
92
134
  function fromLog(log) {
93
135
  const lines = String(log || '').split('\n');
94
136
  const last = lines.filter((line) => line.startsWith('Exit evidence:')).at(-1);
95
- return { mainOom: last === MAIN_OOM, daemonRestart: last === DAEMON_RESTART };
137
+ return {
138
+ mainOom: last === MAIN_OOM || last === DOCKER_OOM,
139
+ daemonRestart: last === DAEMON_RESTART,
140
+ };
96
141
  }
97
142
 
98
143
  function buildExitEvidenceSnippet(containerName, quotedLogPath) {
99
- return `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(containerName)} "$__start_command_cgroup" "$__start_command_finished" "$__start_command_exit" "$__start_command_oom" >> ${quotedLogPath} 2>/dev/null`;
144
+ const stderr = process.env.START_DEBUG === '1' ? '2>&1' : '2>/dev/null';
145
+ return `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(containerName)} "$__start_command_cgroup" "$__start_command_finished" "$__start_command_exit" "$__start_command_oom" >> ${quotedLogPath} ${stderr}`;
100
146
  }
101
147
 
102
148
  module.exports = {
103
149
  MAIN_OOM,
150
+ DOCKER_OOM,
104
151
  DAEMON_RESTART,
105
152
  recentOomDelta,
153
+ recentDockerOom,
106
154
  daemonRestartEvidence,
107
155
  collectExitEvidence,
108
156
  fromLog,
@@ -206,6 +206,13 @@ function resolveMemoryExhaustion(input) {
206
206
  memoryExhaustedReason: `cgroup memory.events reported oom_kill=${oomKills}`,
207
207
  };
208
208
  }
209
+ if (isOomKillOfCommand(input)) {
210
+ return {
211
+ memoryExhausted: true,
212
+ memoryExhaustedReason:
213
+ 'Exit-time evidence reported a main-process OOM kill',
214
+ };
215
+ }
209
216
  return null;
210
217
  }
211
218