start-command 0.35.4 → 0.36.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 (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +10 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +43 -7
  5. package/src/lib/args-parser.js +12 -0
  6. package/src/lib/atomic-write.js +34 -0
  7. package/src/lib/attached-diagnostics.js +90 -0
  8. package/src/lib/cgroup-memory.js +43 -11
  9. package/src/lib/command-builder.js +40 -0
  10. package/src/lib/cpu-penalty-monitor.js +226 -0
  11. package/src/lib/cpu-penalty.js +175 -0
  12. package/src/lib/detached-finalize.js +16 -0
  13. package/src/lib/docker-cleanup.js +23 -2
  14. package/src/lib/docker-resource-limits.js +28 -0
  15. package/src/lib/docker-resource-options.js +180 -0
  16. package/src/lib/docker-runtime-args.js +15 -0
  17. package/src/lib/execution-recovery.js +145 -29
  18. package/src/lib/execution-resume-all.js +11 -0
  19. package/src/lib/execution-resume.js +142 -29
  20. package/src/lib/execution-store.js +25 -121
  21. package/src/lib/exit-evidence.js +123 -0
  22. package/src/lib/exit-reason.js +24 -6
  23. package/src/lib/isolation.js +28 -3
  24. package/src/lib/launch-owner.js +25 -0
  25. package/src/lib/launch-persistence.js +54 -0
  26. package/src/lib/query-commands.js +14 -3
  27. package/src/lib/resume-resources.js +88 -0
  28. package/src/lib/status-formatter.js +28 -0
  29. package/src/lib/store-lock.js +133 -0
  30. package/src/lib/usage.js +11 -0
  31. package/test/docker-runtime-options.js +6 -0
  32. package/test/execution-resume.js +9 -3
  33. package/test/exit-reason.js +6 -1
  34. package/test/isolation.js +5 -4
  35. package/test/regression-174.js +3 -1
  36. package/test/regression-180.js +17 -10
  37. package/test/regression-182.js +7 -3
  38. package/test/regression-185.js +4 -9
  39. package/test/regression-187.js +2 -1
  40. package/test/regression-189.js +224 -0
  41. package/test/regression-190.js +111 -0
  42. package/test/regression-191.js +152 -0
  43. package/test/regression-192.js +66 -0
  44. package/test/regression-193.js +241 -0
  45. package/test/regression-194.js +182 -0
@@ -0,0 +1,175 @@
1
+ /** Docker CPU hysteresis with timestamp-weighted, fully covered windows. */
2
+ const DEFAULTS = {
3
+ cpus: 2,
4
+ trigger: 95,
5
+ triggerWindowMs: 900000,
6
+ release: 65,
7
+ releaseWindowMs: 900000,
8
+ };
9
+ const FIELDS = {
10
+ '--cpu-penalty-cpus': 'cpus',
11
+ '--cpu-penalty-trigger': 'trigger',
12
+ '--cpu-penalty-trigger-window': 'triggerWindowMs',
13
+ '--cpu-penalty-release': 'release',
14
+ '--cpu-penalty-release-window': 'releaseWindowMs',
15
+ };
16
+ function duration(value) {
17
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value);
18
+ const n = m
19
+ ? Number(m[1]) * { ms: 1, s: 1000, m: 60000, h: 3600000 }[m[2]]
20
+ : NaN;
21
+ if (!Number.isSafeInteger(n) || n <= 0) {
22
+ throw Error(`Invalid CPU penalty duration: ${value}`);
23
+ }
24
+ return n;
25
+ }
26
+ function parse(args, index, options) {
27
+ const [flag, inline] = args[index].split('=', 2);
28
+ if (flag === '--cpu-penalty') {
29
+ if (inline !== undefined) {
30
+ throw Error('--cpu-penalty does not take a value');
31
+ }
32
+ options.cpuPenalty = true;
33
+ return 1;
34
+ }
35
+ const field = FIELDS[flag];
36
+ if (!field) {
37
+ return 0;
38
+ }
39
+ const value = inline ?? args[index + 1];
40
+ if (!value) {
41
+ throw Error(`${flag} requires a value`);
42
+ }
43
+ const number = field.endsWith('Ms')
44
+ ? duration(value)
45
+ : Number(field === 'cpus' ? value : value.replace(/%$/, ''));
46
+ if (
47
+ !(number > 0) ||
48
+ !Number.isFinite(number) ||
49
+ (field !== 'cpus' && !field.endsWith('Ms') && number > 100)
50
+ ) {
51
+ throw Error(`Invalid ${flag}: ${value}`);
52
+ }
53
+ options.cpuPenaltyConfig = {
54
+ ...DEFAULTS,
55
+ ...options.cpuPenaltyConfig,
56
+ [field]: number,
57
+ };
58
+ return inline === undefined ? 2 : 1;
59
+ }
60
+ function validate(options) {
61
+ if (!options.cpuPenalty && !options.cpuPenaltyConfig) {
62
+ return;
63
+ }
64
+ if (!options.cpuPenalty) {
65
+ throw Error('CPU penalty settings require --cpu-penalty');
66
+ }
67
+ if (!(options.isolatedStack || [options.isolated]).includes('docker')) {
68
+ throw Error('--cpu-penalty requires Docker isolation');
69
+ }
70
+ options.cpuPenaltyConfig = { ...DEFAULTS, ...options.cpuPenaltyConfig };
71
+ }
72
+ function initialState(baseCpus = null, now = Date.now(), saved = null) {
73
+ return {
74
+ phase: saved?.phase === 'penalized' ? 'penalized' : 'observing',
75
+ since: saved?.phase === 'penalized' ? saved.since : now,
76
+ limitCpus: saved?.phase === 'penalized' ? saved.limitCpus : baseCpus,
77
+ baseCpus,
78
+ penaltyCount: saved?.penaltyCount || 0,
79
+ penalizedMs: saved?.penalizedMs || 0,
80
+ samples: [],
81
+ lastAt: now,
82
+ capacity: null,
83
+ };
84
+ }
85
+ function average(samples, now, window) {
86
+ const start = now - window;
87
+ if (
88
+ samples.length < 2 ||
89
+ samples[0].at > start ||
90
+ samples.at(-1).at !== now
91
+ ) {
92
+ return null;
93
+ }
94
+ let weighted = 0;
95
+ for (let i = 1; i < samples.length; i++) {
96
+ const left = Math.max(start, samples[i - 1].at),
97
+ right = samples[i].at;
98
+ if (right > left) {
99
+ weighted += (right - left) * samples[i - 1].cores;
100
+ }
101
+ }
102
+ return weighted / window;
103
+ }
104
+ function evaluate(
105
+ state,
106
+ { now, cores, daemonCpus, maxGapMs },
107
+ config = DEFAULTS
108
+ ) {
109
+ const s = { ...state, samples: [...state.samples] };
110
+ if (s.phase === 'penalized') {
111
+ s.penalizedMs += Math.max(0, now - s.lastAt);
112
+ }
113
+ s.lastAt = now;
114
+ const capacity = Number.isFinite(daemonCpus)
115
+ ? Math.min(s.baseCpus || Infinity, daemonCpus)
116
+ : NaN;
117
+ if (
118
+ !(cores >= 0) ||
119
+ !Number.isFinite(cores) ||
120
+ !(capacity > 0) ||
121
+ now <= (s.samples.at(-1)?.at ?? -Infinity) ||
122
+ (s.samples.length && now - s.samples.at(-1).at > maxGapMs) ||
123
+ s.capacity !== capacity
124
+ ) {
125
+ s.samples = [];
126
+ }
127
+ s.capacity = capacity;
128
+ if (!(cores >= 0) || !Number.isFinite(cores) || !(capacity > 0)) {
129
+ return { state: s, action: null };
130
+ }
131
+ s.samples.push({ at: now, cores });
132
+ const window =
133
+ s.phase === 'penalized' ? config.releaseWindowMs : config.triggerWindowMs;
134
+ while (s.samples.length > 2 && s.samples[1].at <= now - window) {
135
+ s.samples.shift();
136
+ }
137
+ const avg = average(s.samples, now, window);
138
+ let action = null;
139
+ if (
140
+ s.phase === 'observing' &&
141
+ config.cpus < capacity &&
142
+ avg !== null &&
143
+ avg >= (config.trigger / 100) * capacity
144
+ ) {
145
+ action = { kind: 'apply', cpus: config.cpus, average: avg, capacity };
146
+ s.phase = 'penalized';
147
+ s.since = now;
148
+ s.limitCpus = config.cpus;
149
+ s.penaltyCount++;
150
+ } else if (
151
+ s.phase === 'penalized' &&
152
+ (config.cpus >= capacity ||
153
+ (now - s.since >= config.releaseWindowMs &&
154
+ avg !== null &&
155
+ avg < (config.release / 100) * config.cpus))
156
+ ) {
157
+ action = { kind: 'lift', cpus: capacity, average: avg, capacity };
158
+ s.phase = 'observing';
159
+ s.since = now;
160
+ s.limitCpus = capacity;
161
+ }
162
+ if (action) {
163
+ s.samples = [];
164
+ }
165
+ return { state: s, action };
166
+ }
167
+ module.exports = {
168
+ DEFAULTS,
169
+ parse,
170
+ validate,
171
+ duration,
172
+ initialState,
173
+ average,
174
+ evaluate,
175
+ };
@@ -146,6 +146,21 @@ function finalizeDetachedExecution(options = {}) {
146
146
  record.cgroupMemory = cgroupMemory;
147
147
  }
148
148
 
149
+ const evidence = require('./exit-evidence');
150
+ const logged = evidence.fromLog(
151
+ record.logPath
152
+ ? require('./execution-attempt').readAttemptLogTail(record, 65536)
153
+ : null
154
+ );
155
+ record.options.exitEvidence = {
156
+ daemonRestart: logged.daemonRestart,
157
+ mainOom:
158
+ !logged.daemonRestart &&
159
+ (logged.mainOom ||
160
+ (evidence.recentOomDelta(options.cgroupMemory, options.finishedAt) &&
161
+ record.oomKilled === true &&
162
+ (record.exitCode === 137 || record.exitCode < 0))),
163
+ };
149
164
  const exitReason =
150
165
  resolveReason(record) || (finishedAt ? null : WATCHER_LOST_CONTAINER);
151
166
  if (exitReason) {
@@ -197,6 +212,7 @@ function resolveReason(record) {
197
212
  logTail,
198
213
  oomKilled: record.oomKilled,
199
214
  cgroupMemory: record.cgroupMemory,
215
+ exitEvidence: record.options?.exitEvidence,
200
216
  });
201
217
  } catch {
202
218
  return null;
@@ -275,10 +275,13 @@ function buildAttachedDockerKeptMessage({
275
275
  oomKilled,
276
276
  logPath,
277
277
  }) {
278
+ const logTail = logPath
279
+ ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES)
280
+ : null;
278
281
  let message;
279
282
  if (oomKilled !== true) {
280
283
  message = `\nContainer kept because the command failed.`;
281
- } else if (isOomKillOfCommand({ exitCode, oomKilled })) {
284
+ } else if (isOomKillOfCommand({ exitCode, oomKilled, logTail })) {
282
285
  message = `\nContainer kept because Docker reports it was OOM-killed.`;
283
286
  } else {
284
287
  // The flag is container-wide (#180): a child was OOM-killed, the command
@@ -287,7 +290,7 @@ function buildAttachedDockerKeptMessage({
287
290
  }
288
291
  const memory = resolveMemoryExhaustion({
289
292
  exitCode,
290
- logTail: logPath ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES) : null,
293
+ logTail,
291
294
  oomKilled,
292
295
  });
293
296
  if (memory) {
@@ -360,7 +363,17 @@ function buildDetachedDockerCompletionScript(
360
363
  const quotedName = shellQuote(containerName);
361
364
  // The container's cgroup disappears with it, so its memory counters are
362
365
  // sampled while it runs (issue #182).
366
+ const cpuMonitor = require('./cpu-penalty-monitor');
363
367
  const parts = [buildCgroupSamplerStartSnippet(containerName)];
368
+ const cpuStart = cpuMonitor.startSnippet(
369
+ executionId,
370
+ containerName,
371
+ watcherOptions.attemptNumber || 1,
372
+ watcherOptions.cpuOptions
373
+ );
374
+ if (cpuStart) {
375
+ parts.push(cpuStart);
376
+ }
364
377
  // Everything that assumes the container has exited: cleanup, footer and
365
378
  // finalization. Guarded as a whole by `.State.Running` below.
366
379
  const exited = [];
@@ -393,8 +406,15 @@ function buildDetachedDockerCompletionScript(
393
406
  );
394
407
  }
395
408
  parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
409
+ parts.push(cpuMonitor.stopSnippet());
396
410
  parts.push(buildCgroupSamplerStopSnippet());
397
411
  parts.push(buildDockerStateSnippet(containerName));
412
+ parts.push(
413
+ require('./exit-evidence').buildExitEvidenceSnippet(
414
+ containerName,
415
+ quotedLogPath
416
+ )
417
+ );
398
418
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
399
419
  exited.push(remove);
400
420
  } else if (
@@ -414,6 +434,7 @@ function buildDetachedDockerCompletionScript(
414
434
  } else {
415
435
  parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
416
436
  parts.push(buildDockerWaitForExitSnippet(containerName));
437
+ parts.push(cpuMonitor.stopSnippet());
417
438
  parts.push(buildCgroupSamplerStopSnippet());
418
439
  parts.push(buildDockerStateSnippet(containerName));
419
440
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
@@ -200,8 +200,36 @@ function buildResourceLimitsStatusLine(value) {
200
200
  : null;
201
201
  }
202
202
 
203
+ /** Preserve Docker's CPU representation: an existing quota cannot become NanoCPUs in place. */
204
+ function cpuCount(limits) {
205
+ const get = (prefix) =>
206
+ positive(limits.find((s) => s.startsWith(prefix))?.slice(prefix.length));
207
+ return (
208
+ get('--cpus=') ||
209
+ (get('--cpu-quota=')
210
+ ? get('--cpu-quota=') / (get('--cpu-period=') || 100000)
211
+ : null)
212
+ );
213
+ }
214
+ function cpuUpdateArgs(limits, cpus) {
215
+ return !limits.some((s) => s.startsWith('--cpus=')) &&
216
+ limits.some((s) => /^--cpu-(quota|period)=/.test(s))
217
+ ? ['--cpu-period=100000', `--cpu-quota=${Math.ceil(cpus * 100000)}`]
218
+ : ['--cpus', String(cpus)];
219
+ }
220
+ function withCpuLimit(limits, cpus) {
221
+ const args = cpuUpdateArgs(limits, cpus);
222
+ return [
223
+ ...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
224
+ ...(args[0] === '--cpus' ? [`--cpus=${args[1]}`] : args),
225
+ ];
226
+ }
227
+
203
228
  module.exports = {
204
229
  DEFAULT_SHM_SIZE,
230
+ cpuCount,
231
+ cpuUpdateArgs,
232
+ withCpuLimit,
205
233
  buildResourceLimitsStatusLine,
206
234
  formatDockerBytes,
207
235
  formatDockerCpus,
@@ -0,0 +1,180 @@
1
+ /** Launch and recovery resource specifications, resolved against the daemon. */
2
+ const { getDockerCommand } = require('./docker-cleanup');
3
+ const { normalizeResourceLimits } = require('./docker-resource-limits');
4
+ const FIELDS = {
5
+ '--memory': 'memory',
6
+ '--memory-swap': 'memorySwap',
7
+ '--cpus': 'cpus',
8
+ '--on-kill-resume-memory': 'onKillResumeMemory',
9
+ };
10
+
11
+ function parseResourceSpec(value, cpu = false) {
12
+ const text = String(value).trim().toLowerCase();
13
+ const percent = /^(\d+(?:\.\d+)?)%(?:-(\d+(?:\.\d+)?)%)?$/.exec(text);
14
+ if (percent) {
15
+ const min = Number(percent[1]),
16
+ max = Number(percent[2] || percent[1]);
17
+ if (min > 0 && max <= 100 && min <= max) {
18
+ return { min, max, percent: true };
19
+ }
20
+ } else {
21
+ const absolute = /^(\d+(?:\.\d+)?)(b|k|m|g|t|kb|mb|gb|tb)?$/.exec(text);
22
+ if (absolute && (!cpu || !absolute[2])) {
23
+ const power = ['', 'k', 'm', 'g', 't'].indexOf(
24
+ (absolute[2] || '').replace('b', '')
25
+ );
26
+ const amount =
27
+ Number(absolute[1]) * (cpu ? 1 : 1024 ** Math.max(0, power));
28
+ if (
29
+ Number.isFinite(amount) &&
30
+ amount > 0 &&
31
+ amount <= Number.MAX_SAFE_INTEGER
32
+ ) {
33
+ return { amount };
34
+ }
35
+ }
36
+ }
37
+ throw new Error(
38
+ `Invalid resource limit: "${value}". Expected a positive ${cpu ? 'CPU count' : 'Docker size'}, N% or MIN%-MAX% within (0, 100].`
39
+ );
40
+ }
41
+
42
+ function parse(args, index, options) {
43
+ const [flag, inline] = args[index].split('=', 2);
44
+ if (!FIELDS[flag]) {
45
+ return 0;
46
+ }
47
+ const value = inline ?? args[index + 1];
48
+ if (!value || value.startsWith('-')) {
49
+ throw new Error(`Option ${flag} requires a resource limit`);
50
+ }
51
+ parseResourceSpec(value, flag === '--cpus');
52
+ options[FIELDS[flag]] = value;
53
+ return inline === undefined ? 2 : 1;
54
+ }
55
+
56
+ function validate(options) {
57
+ if (
58
+ !Object.values(FIELDS).some(
59
+ (field) => options[field] !== null && options[field] !== undefined
60
+ )
61
+ ) {
62
+ return;
63
+ }
64
+ if (
65
+ !options.resume &&
66
+ !(options.isolatedStack || [options.isolated]).includes('docker')
67
+ ) {
68
+ throw new Error('Resource options require Docker isolation or --resume');
69
+ }
70
+ if (options.memorySwap && !options.memory && !options.resume) {
71
+ throw new Error('--memory-swap requires --memory');
72
+ }
73
+ if (
74
+ options.onKillResumeMemory &&
75
+ !options.onKillResume &&
76
+ !options.recoveryCommand
77
+ ) {
78
+ throw new Error(
79
+ '--on-kill-resume-memory requires --on-kill-resume or --recovery-command'
80
+ );
81
+ }
82
+ }
83
+
84
+ function readDaemonCapacity(runner) {
85
+ const result = runner(getDockerCommand(), ['info', '--format', '{{json .}}']);
86
+ if (!result?.success) {
87
+ throw new Error('Cannot read resource capacity from Docker daemon');
88
+ }
89
+ const info = JSON.parse(result.stdout);
90
+ if (!(info.MemTotal > 0) || !(info.NCPU > 0)) {
91
+ throw new Error('Docker daemon did not report MemTotal/NCPU');
92
+ }
93
+ return { memory: info.MemTotal, cpus: info.NCPU };
94
+ }
95
+
96
+ function resolveResourceOptions(options, runner, random = Math.random) {
97
+ const specs = {};
98
+ for (const field of ['memory', 'memorySwap', 'cpus']) {
99
+ if (options[field] !== null && options[field] !== undefined) {
100
+ specs[field] = parseResourceSpec(options[field], field === 'cpus');
101
+ }
102
+ }
103
+ const capacity = Object.values(specs).some((s) => s.percent)
104
+ ? readDaemonCapacity(runner)
105
+ : null;
106
+ const resolved = {};
107
+ const limits = normalizeResourceLimits(options.resourceLimits);
108
+ for (const [field, spec] of Object.entries(specs)) {
109
+ const amount = spec.percent
110
+ ? (capacity[field === 'cpus' ? 'cpus' : 'memory'] *
111
+ (spec.min + (spec.max - spec.min) * random())) /
112
+ 100
113
+ : spec.amount;
114
+ resolved[field] =
115
+ field === 'cpus' ? Math.floor(amount * 1e9) / 1e9 : Math.floor(amount);
116
+ if (resolved[field] < (field === 'cpus' ? 0.01 : 6 * 1024 * 1024)) {
117
+ throw new Error(`Resolved ${field} is below Docker's minimum`);
118
+ }
119
+ }
120
+ if (
121
+ resolved.memory !== null &&
122
+ resolved.memory !== undefined &&
123
+ (resolved.memorySwap === null || resolved.memorySwap === undefined)
124
+ ) {
125
+ resolved.memorySwap = resolved.memory;
126
+ }
127
+ const existingMemory = limits
128
+ .find((flag) => flag.startsWith('--memory='))
129
+ ?.slice(9);
130
+ const memory =
131
+ resolved.memory ??
132
+ (existingMemory ? parseResourceSpec(existingMemory).amount : null);
133
+ if (
134
+ resolved.memorySwap !== null &&
135
+ resolved.memorySwap !== undefined &&
136
+ (memory === null || memory === undefined || resolved.memorySwap < memory)
137
+ ) {
138
+ throw new Error('memory-swap must be at least memory');
139
+ }
140
+ for (const [field, amount] of Object.entries(resolved)) {
141
+ const flag = field === 'memorySwap' ? '--memory-swap' : `--${field}`;
142
+ for (let i = limits.length - 1; i >= 0; i--) {
143
+ if (
144
+ limits[i].startsWith(`${flag}=`) ||
145
+ (field === 'cpus' && /^--cpu-(quota|period)=/.test(limits[i]))
146
+ ) {
147
+ limits.splice(i, 1);
148
+ }
149
+ }
150
+ limits.push(`${flag}=${amount}`);
151
+ }
152
+ return {
153
+ resourceLimits: limits,
154
+ resolvedLimits: resolved,
155
+ resourceLimitSpecs: Object.fromEntries(
156
+ Object.keys(specs).map((field) => [field, options[field]])
157
+ ),
158
+ };
159
+ }
160
+
161
+ function limitsLogLine(options) {
162
+ const values = options.resolvedLimits || {};
163
+ return Object.keys(values).length
164
+ ? `Limits: ${Object.entries(values)
165
+ .map(
166
+ ([field, value]) =>
167
+ `${field}=${value}${options.resourceLimitSpecs?.[field] ? ` (${options.resourceLimitSpecs[field]} -> ${value})` : ''}`
168
+ )
169
+ .join(' ')}`
170
+ : null;
171
+ }
172
+
173
+ module.exports = {
174
+ parse,
175
+ validate,
176
+ parseResourceSpec,
177
+ readDaemonCapacity,
178
+ resolveResourceOptions,
179
+ limitsLogLine,
180
+ };
@@ -97,6 +97,12 @@ function buildDockerRuntimeStatusLines(options = {}) {
97
97
  if (limitsLine) {
98
98
  lines.push(limitsLine);
99
99
  }
100
+ const limitsSummary = require('./docker-resource-options').limitsLogLine(
101
+ options
102
+ );
103
+ if (limitsSummary) {
104
+ lines.push(limitsSummary);
105
+ }
100
106
  const onKillResume = getOnKillResume(options);
101
107
  if (onKillResume) {
102
108
  const what = options.recoveryCommand || 'the original command';
@@ -131,6 +137,15 @@ function buildDockerRuntimeMetadata(options = {}) {
131
137
  ? options.networkAliases
132
138
  : null,
133
139
  resourceLimits: resourceLimits.length > 0 ? resourceLimits : null,
140
+ resolvedLimits: options.resolvedLimits || null,
141
+ resourceLimitSpecs: options.resourceLimitSpecs || null,
142
+ onKillResumeMemory: options.onKillResumeMemory || null,
143
+ ...(options.cpuPenaltyConfig
144
+ ? {
145
+ cpuPenaltyConfig: options.cpuPenaltyConfig,
146
+ baseResourceLimits: resourceLimits,
147
+ }
148
+ : {}),
134
149
  onKillResume: getOnKillResume(options),
135
150
  recoveryCommand: options.recoveryCommand || null,
136
151
  onKillResumeDelay: getOnKillResume(options)