start-command 0.37.9 → 0.39.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.
- package/CHANGELOG.md +26 -0
- package/README.md +7 -2
- package/bunfig.toml +4 -0
- package/eslint.config.mjs +1 -0
- package/package.json +4 -4
- package/src/bin/cli.js +62 -26
- package/src/lib/args-parser-queries.js +45 -1
- package/src/lib/args-parser.js +19 -0
- package/src/lib/cgroup-memory.js +95 -23
- package/src/lib/command-builder.js +4 -0
- package/src/lib/container-removal.js +4 -5
- package/src/lib/detached-finalize.js +1 -0
- package/src/lib/docker-command-handoff.js +34 -6
- package/src/lib/docker-init-option.js +65 -0
- package/src/lib/docker-inspect-batch.js +176 -0
- package/src/lib/docker-resource-options.js +21 -2
- package/src/lib/docker-runtime-args.js +19 -3
- package/src/lib/execution-attempt.js +19 -9
- package/src/lib/execution-chain.js +182 -0
- package/src/lib/execution-control.js +8 -6
- package/src/lib/execution-env.js +120 -0
- package/src/lib/execution-identity.js +180 -0
- package/src/lib/execution-recovery.js +21 -1
- package/src/lib/execution-resume.js +21 -1
- package/src/lib/execution-store.js +30 -23
- package/src/lib/exit-evidence.js +139 -7
- package/src/lib/isolation.js +21 -3
- package/src/lib/launch-identity-options.js +81 -0
- package/src/lib/launch-identity.js +411 -0
- package/src/lib/log-uploader.js +15 -1
- package/src/lib/output-blocks.js +11 -0
- package/src/lib/query-commands.js +18 -3
- package/src/lib/resume-resources.js +2 -2
- package/src/lib/screen-isolation.js +11 -2
- package/src/lib/spawn-helpers.js +33 -16
- package/src/lib/status-formatter.js +39 -131
- package/src/lib/status-probe.js +149 -0
- package/src/lib/store-decode-cache.js +141 -0
- package/src/lib/usage.js +11 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +2 -2
- package/test/cargo-lock-version.mjs +121 -0
- package/test/check-rust-coverage.mjs +71 -0
- package/test/check-web-archive.mjs +154 -0
- package/test/ci-workflow-invariants.js +204 -0
- package/test/command-builder-docker-cleanup.js +24 -0
- package/test/command-exit-codes.mjs +91 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/docker-snapshot-safety.js +39 -0
- package/test/execution-resume.js +2 -0
- package/test/fixtures/fake-npm.sh +64 -0
- package/test/fixtures/lychee-report.md +39 -0
- package/test/javascript-first-ci.js +69 -0
- package/test/links-workflow.mjs +127 -0
- package/test/lint-gate.mjs +56 -0
- package/test/npm-publish.mjs +181 -0
- package/test/recheck-broken-links.mjs +452 -0
- package/test/regression-176.js +2 -0
- package/test/regression-187.js +2 -0
- package/test/regression-191.js +4 -0
- package/test/regression-193.js +2 -0
- package/test/regression-198.js +2 -0
- package/test/regression-207.js +8 -2
- package/test/regression-224.js +2 -0
- package/test/regression-229.js +267 -0
- package/test/regression-230.js +234 -0
- package/test/regression-231.js +298 -0
- package/test/regression-236.js +250 -0
- package/test/regression-238.js +129 -0
- package/test/regression-241.js +254 -0
- package/test/regression-242.js +241 -0
- package/test/regression-243.js +493 -0
- package/test/regression-244.js +471 -0
- package/test/release-push-retry.mjs +172 -0
- package/test/run-js-tests.mjs +15 -3
- package/test/setup/private-snapshot-lock.js +17 -0
- package/test/shared-golden.js +44 -2
- package/test/transient-links.mjs +139 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.39.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- CI/CD no longer reports false results (#234). A rejected `npm publish` or any failing release command now fails the release instead of printing success, and the published version is confirmed on the registry: npm gets up to 20 minutes to show it, and a "cannot publish over" conflict is read as "already landed" rather than retried. A release push that loses the race for `main` is rebased and retried. Releases wait for every check their stage requires. A failing Rust test is reported as a failed test rather than as low coverage. `bun test -t <pattern>` keeps the 30s default timeout. Snapshot-resume tests in both languages use a private snapshot lock. The link checker fails on every broken live link, re-checks transient 429/5xx answers, persists its cache and uploads its report. ESLint warnings fail CI and the pre-commit hook. The changeset PR is opened with `RELEASE_PR_TOKEN` so its checks run. Step outputs reach `run:` blocks through `env`, tool versions are pinned, path-filtered workflows re-run when their scripts change, and the npm-publish tests no longer reach the real npm on macOS.
|
|
8
|
+
|
|
9
|
+
Support Docker `--memory-reservation` with sizes, daemon percentages and percentage ranges. Validate the soft cap against the effective hard memory limit, record it in status, and preserve or override it across resumes and recovery.
|
|
10
|
+
|
|
11
|
+
Reduce Docker cgroup sampler polling with a configurable five-second default, one namespace lookup per sampler, and local cgroup liveness checks. Set `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS` to a positive whole number of seconds to override the default for attached and detached runs.
|
|
12
|
+
|
|
13
|
+
Main pushes publish again (#240). Since #234 the Release job was skipped on every push to `main`: at job level the wildcard `needs.*.result` list also saw the deliberately skipped changeset check behind `test`/`coverage`. Release now compares each needed job by name, and the JavaScript Stage prints every term of the release condition and fails a main push whose release was skipped although all of its needs succeeded.
|
|
14
|
+
|
|
15
|
+
Attribute a Docker daemon restart without the host journal (#241). With a mounted `docker.sock` (Docker-outside-of-Docker) or a remote daemon, an exit-137 task is now reported as `killed (docker daemon restart)` when another container finished within the 15 s shutdown window with exit 137/143, or was started again after it, and was not OOM-killed. `exitEvidence.daemonRestartSource` records whether the host journal or the Docker API supported the attribution, and `--status` collects the evidence itself when the detached watcher died with the daemon before it could log any.
|
|
16
|
+
|
|
17
|
+
Sample Docker cgroup counters through one long-lived `docker exec -i` per run when the cgroup is only readable through the daemon (a mounted `docker.sock`). The host paces it with one tick per interval on stdin, so the container runs only shell builtins and the stream ends with the sampler; the per-round exec stays as a fallback. Docker is asked for `{{.State.Running}}` only after a round without a sample, and `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` disables sampling.
|
|
18
|
+
|
|
19
|
+
`$ --status` and `$ --list` cost far less with many records (#243). The store is decoded once per content within a process, and a JSON copy of the decoded records next to `executions.lino` (tagged with the store's SHA-256) lets the next process skip the decode entirely. One `docker inspect --type container` now answers every docker probe of a query instead of one or two processes per record, and records whose container is already marked removed are no longer probed. Output is unchanged; probes the batch cannot answer (an old or stubbed `docker`) run one by one as before.
|
|
20
|
+
|
|
21
|
+
`--init` / `--no-init` run Docker's bundled tini (`docker run --init`) as PID 1 for docker isolation, so orphaned processes of a long-running task are reaped instead of piling up as zombies (#244). The default stays off; `START_COMMAND_DOCKER_INIT=1` turns it on for every docker launch on a host, and `--no-init` overrides that. The choice is stored with the record, shown as an `init` row in the start block and in `--status`, and carried into `--resume` snapshots and stacked launches; records from before keep running without it. Exit codes and OOM evidence are unchanged, but `--stop` (`docker stop`) now ends a command without a SIGTERM handler with 143 at once instead of 137 after the grace period.
|
|
22
|
+
|
|
23
|
+
## 0.38.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- 8e62079: Executions now carry a stable identity a supervisor can rely on. A launch with an explicit `--session-id` that already exists is refused with exit 1 (`execution <uuid> already exists; use $ --resume <uuid> -- <command> to continue it, or pass --append-attempt`) instead of replacing the record and truncating its log; `--append-attempt` keeps the record, archives the previous attempt in `attemptHistory` and appends the new attempt to the same log (#229). Every backend (direct, `--isolated-user`, screen, tmux, docker, ssh, and docker resumes of stopped containers) exports `START_EXECUTION_ID`, `START_SESSION_NAME` and `START_ATTEMPT` to the command it runs (#230). `$ --alias <id> <name>` adds a name and `$ --rename <id> <name>` sets the display name of an execution, `--continues <id>` records the execution a fresh launch continues, `--status` reports `aliases` and the continuation `chain`, and `--upload-log` uploads the logs of the whole chain, oldest first (#231).
|
|
28
|
+
|
|
3
29
|
## 0.37.9
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -29,6 +29,9 @@ $ -i docker -d --label task.tool=codex -- my-task
|
|
|
29
29
|
$ --attach <id>
|
|
30
30
|
$ --resume <id> -- <command>
|
|
31
31
|
$ --resume-all
|
|
32
|
+
$ --session-id <uuid> --append-attempt -- <command>
|
|
33
|
+
$ --continues <id> -- <command>
|
|
34
|
+
$ --rename <id> <name>
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
|
|
@@ -70,9 +73,11 @@ Released into the public domain under the [Unlicense](../LICENSE).
|
|
|
70
73
|
|
|
71
74
|
### Docker resource and recovery controls
|
|
72
75
|
|
|
73
|
-
Launch with `--memory 64m`, `--memory-swap 64m` and `--cpus 1`, or use daemon
|
|
76
|
+
Launch with `--memory 64m`, `--memory-swap 64m`, `--memory-reservation 32m` and `--cpus 1`, or use daemon
|
|
74
77
|
percentages and uniform ranges such as `--memory '70%-80%'`. Memory-swap defaults
|
|
75
|
-
to memory.
|
|
78
|
+
to memory. Reservation is a soft cap and must be below the hard memory cap when
|
|
79
|
+
one is set. It accepts the same sizes and percentages, is shown in `--status`,
|
|
80
|
+
and survives resumes. `--cpu-penalty` enables delayed caps and reports state through status.
|
|
76
81
|
Manual `--resume <id> --memory 128m` updates before starting;
|
|
77
82
|
`--on-kill-resume-memory` changes memory only after a fresh qualifying OOM.
|
|
78
83
|
See the [full resource and recovery guide](../README.md#docker-launch-limits-recovery-limits-and-cpu-penalty)
|
package/bunfig.toml
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
# Bun configuration
|
|
2
2
|
# Tests use repository naming without conventional suffixes, so package scripts pass
|
|
3
3
|
# the test files explicitly instead of relying on Bun's default discovery.
|
|
4
|
+
|
|
5
|
+
[test]
|
|
6
|
+
# Every test process gets a private Docker snapshot lock (issue #234).
|
|
7
|
+
preload = ["./test/setup/private-snapshot-lock.js"]
|
package/eslint.config.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "start-command",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
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": {
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"test": "bun ../scripts/run-js-tests.mjs",
|
|
15
|
-
"lint": "eslint .",
|
|
15
|
+
"lint": "eslint . --max-warnings 0",
|
|
16
16
|
"lint:fix": "eslint . --fix",
|
|
17
17
|
"format": "prettier --write .",
|
|
18
18
|
"format:check": "prettier --check .",
|
|
19
19
|
"check:file-size": "bun ../scripts/check-file-size.mjs",
|
|
20
|
-
"lint:scripts": "cd .. && eslint .",
|
|
20
|
+
"lint:scripts": "cd .. && eslint . --max-warnings 0",
|
|
21
21
|
"format:check:scripts": "cd .. && prettier --check \"scripts/**/*.{js,mjs,cjs}\"",
|
|
22
22
|
"check": "bun run lint && bun run lint:scripts && bun run format:check && bun run format:check:scripts && bun run check:file-size",
|
|
23
23
|
"prepare": "cd .. && husky .husky || true",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
"lint-staged": {
|
|
60
60
|
"*.{js,mjs,cjs}": [
|
|
61
|
-
"eslint --fix --max-warnings
|
|
61
|
+
"eslint --fix --max-warnings 0 --no-warn-ignored",
|
|
62
62
|
"prettier --write",
|
|
63
63
|
"prettier --check"
|
|
64
64
|
],
|
package/src/bin/cli.js
CHANGED
|
@@ -18,7 +18,6 @@ const {
|
|
|
18
18
|
getTimestamp,
|
|
19
19
|
createLogHeader,
|
|
20
20
|
createLogFooter,
|
|
21
|
-
writeLogFile,
|
|
22
21
|
appendLogFile,
|
|
23
22
|
createLogPath,
|
|
24
23
|
getDefaultDockerImage,
|
|
@@ -41,6 +40,7 @@ const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
|
|
|
41
40
|
const { resolveChildExitCode } = require('../lib/exit-reason');
|
|
42
41
|
const { printUsage } = require('../lib/usage');
|
|
43
42
|
const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
|
|
43
|
+
const launchIdentity = require('../lib/launch-identity');
|
|
44
44
|
|
|
45
45
|
// A closed stdout/stderr pipe (`$ --version | head -1`) must neither crash
|
|
46
46
|
// start nor replace the command's exit code: drop the failed output and keep
|
|
@@ -245,6 +245,8 @@ const useCommandStream =
|
|
|
245
245
|
|
|
246
246
|
// Generate session ID if not provided (auto-generate UUID)
|
|
247
247
|
const sessionId = wrapperOptions.sessionId || generateUUID();
|
|
248
|
+
// How this launch uses its execution id (issue #229), set before dispatch
|
|
249
|
+
let launch = null;
|
|
248
250
|
|
|
249
251
|
// Main execution
|
|
250
252
|
(async () => {
|
|
@@ -256,6 +258,19 @@ const sessionId = wrapperOptions.sessionId || generateUUID();
|
|
|
256
258
|
process.exit(queryExitCode === null ? 0 : queryExitCode);
|
|
257
259
|
}
|
|
258
260
|
|
|
261
|
+
// An explicit --session-id must not overwrite a tracked execution (#229).
|
|
262
|
+
try {
|
|
263
|
+
launch = launchIdentity.prepareLaunch(
|
|
264
|
+
getExecutionStore(),
|
|
265
|
+
wrapperOptions,
|
|
266
|
+
sessionId,
|
|
267
|
+
createLogPath(wrapperOptions.isolated || 'direct', sessionId)
|
|
268
|
+
);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error(`Error: ${error.message}`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
259
274
|
if (wrapperOptions.isolated === 'docker') {
|
|
260
275
|
const {
|
|
261
276
|
resolveResourceOptions,
|
|
@@ -314,8 +329,8 @@ async function runWithIsolation(
|
|
|
314
329
|
effectiveImage = getDefaultDockerImage();
|
|
315
330
|
}
|
|
316
331
|
|
|
317
|
-
// Create log file path
|
|
318
|
-
const logFilePath =
|
|
332
|
+
// Create log file path (an appended attempt keeps the execution's log)
|
|
333
|
+
const logFilePath = launch.logPath;
|
|
319
334
|
|
|
320
335
|
// Get session name (will be generated by runIsolated if not provided)
|
|
321
336
|
const sessionName =
|
|
@@ -424,6 +439,7 @@ async function runWithIsolation(
|
|
|
424
439
|
useCommandStream,
|
|
425
440
|
},
|
|
426
441
|
});
|
|
442
|
+
launchIdentity.applyLaunch(executionRecord, launch);
|
|
427
443
|
}
|
|
428
444
|
|
|
429
445
|
// Print start block with session ID and isolation info
|
|
@@ -448,8 +464,13 @@ async function runWithIsolation(
|
|
|
448
464
|
currentExecutionRecord = executionRecord;
|
|
449
465
|
require('../lib/launch-owner').markLaunch(executionRecord);
|
|
450
466
|
try {
|
|
451
|
-
|
|
467
|
+
launchIdentity.claimLaunch(store, executionRecord, launch);
|
|
452
468
|
} catch (err) {
|
|
469
|
+
if (err instanceof launchIdentity.LaunchRefusedError) {
|
|
470
|
+
console.error(`Error: ${err.message}`);
|
|
471
|
+
currentExecutionRecord = null;
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
453
474
|
console.error(
|
|
454
475
|
JSON.stringify({
|
|
455
476
|
code: 'LAUNCH_RESERVATION_FAILED',
|
|
@@ -483,7 +504,12 @@ async function runWithIsolation(
|
|
|
483
504
|
);
|
|
484
505
|
}
|
|
485
506
|
|
|
486
|
-
|
|
507
|
+
launchIdentity.writeLaunchLog(
|
|
508
|
+
logFilePath,
|
|
509
|
+
logContent,
|
|
510
|
+
launch,
|
|
511
|
+
executionRecord
|
|
512
|
+
);
|
|
487
513
|
|
|
488
514
|
let result;
|
|
489
515
|
|
|
@@ -495,6 +521,7 @@ async function runWithIsolation(
|
|
|
495
521
|
...options,
|
|
496
522
|
session: sessionName,
|
|
497
523
|
sessionId,
|
|
524
|
+
attemptNumber: launch.attemptNumber,
|
|
498
525
|
image: effectiveImage,
|
|
499
526
|
endpoint: options.endpoint,
|
|
500
527
|
detached: mode === 'detached',
|
|
@@ -513,7 +540,10 @@ async function runWithIsolation(
|
|
|
513
540
|
});
|
|
514
541
|
} else if (createdUser) {
|
|
515
542
|
// Run directly as the created user (no isolation environment)
|
|
516
|
-
result = await runAsIsolatedUser(
|
|
543
|
+
result = await runAsIsolatedUser(
|
|
544
|
+
launchIdentity.isolatedUserCommand(cmd, sessionId, launch),
|
|
545
|
+
createdUser
|
|
546
|
+
);
|
|
517
547
|
} else {
|
|
518
548
|
// This shouldn't happen in isolation mode, but handle gracefully
|
|
519
549
|
result = { success: false, message: 'No isolation configuration provided' };
|
|
@@ -637,8 +667,8 @@ async function runDirect(cmd, sessionId) {
|
|
|
637
667
|
const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
|
|
638
668
|
const shellArgs = isWindows ? ['-Command', cmd] : ['-c', cmd];
|
|
639
669
|
|
|
640
|
-
// Setup logging
|
|
641
|
-
const logFilePath =
|
|
670
|
+
// Setup logging (an appended attempt keeps the execution's log)
|
|
671
|
+
const logFilePath = launch.logPath;
|
|
642
672
|
|
|
643
673
|
let logContent = '';
|
|
644
674
|
const startTime = getTimestamp();
|
|
@@ -666,6 +696,8 @@ async function runDirect(cmd, sessionId) {
|
|
|
666
696
|
runtimeVersion,
|
|
667
697
|
},
|
|
668
698
|
});
|
|
699
|
+
launchIdentity.applyLaunch(executionRecord, launch);
|
|
700
|
+
launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
|
|
669
701
|
// Set global reference for signal cleanup
|
|
670
702
|
currentExecutionRecord = executionRecord;
|
|
671
703
|
}
|
|
@@ -746,7 +778,13 @@ async function runDirect(cmd, sessionId) {
|
|
|
746
778
|
shell,
|
|
747
779
|
shellArgs,
|
|
748
780
|
logFilePath,
|
|
749
|
-
logContent
|
|
781
|
+
logContent: launchIdentity.launchLogContent(
|
|
782
|
+
logContent,
|
|
783
|
+
launch,
|
|
784
|
+
executionRecord
|
|
785
|
+
),
|
|
786
|
+
appendLog: launch.append,
|
|
787
|
+
env: launchIdentity.directLaunchEnv(sessionId, launch),
|
|
750
788
|
startTimeMs,
|
|
751
789
|
executionRecord,
|
|
752
790
|
store,
|
|
@@ -786,8 +824,8 @@ async function runDirectWithCommandStream(
|
|
|
786
824
|
const isWindows = process.platform === 'win32';
|
|
787
825
|
const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
|
|
788
826
|
|
|
789
|
-
// Setup logging
|
|
790
|
-
const logFilePath =
|
|
827
|
+
// Setup logging (an appended attempt keeps the execution's log)
|
|
828
|
+
const logFilePath = launch.logPath;
|
|
791
829
|
|
|
792
830
|
let logContent = '';
|
|
793
831
|
const startTime = getTimestamp();
|
|
@@ -816,6 +854,8 @@ async function runDirectWithCommandStream(
|
|
|
816
854
|
executionMode: 'command-stream',
|
|
817
855
|
},
|
|
818
856
|
});
|
|
857
|
+
launchIdentity.applyLaunch(executionRecord, launch);
|
|
858
|
+
launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
|
|
819
859
|
// Set global reference for signal cleanup
|
|
820
860
|
currentExecutionRecord = executionRecord;
|
|
821
861
|
}
|
|
@@ -853,27 +893,23 @@ async function runDirectWithCommandStream(
|
|
|
853
893
|
console.log('[command-stream] Using command-stream library');
|
|
854
894
|
console.log('');
|
|
855
895
|
|
|
856
|
-
// Save initial execution record (PID will be updated later if available)
|
|
857
|
-
if (executionRecord && store) {
|
|
858
|
-
try {
|
|
859
|
-
store.save(executionRecord);
|
|
860
|
-
} catch (err) {
|
|
861
|
-
if (config.verbose) {
|
|
862
|
-
console.error(
|
|
863
|
-
`[Tracking] Warning: Could not save execution record: ${err.message}`
|
|
864
|
-
);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
896
|
// Execute the command using command-stream with real-time output
|
|
870
897
|
// Using mirror: true to show output in real-time, capture: true to collect it
|
|
871
898
|
// Using raw() to avoid auto-escaping that might interfere with complex shell commands
|
|
872
|
-
const $cmd = $({
|
|
899
|
+
const $cmd = $({
|
|
900
|
+
mirror: true,
|
|
901
|
+
capture: true,
|
|
902
|
+
env: launchIdentity.directLaunchEnv(sessionId, launch),
|
|
903
|
+
});
|
|
873
904
|
|
|
874
905
|
let exitCode;
|
|
875
906
|
try {
|
|
876
|
-
|
|
907
|
+
launchIdentity.writeLaunchLog(
|
|
908
|
+
logFilePath,
|
|
909
|
+
logContent,
|
|
910
|
+
launch,
|
|
911
|
+
executionRecord
|
|
912
|
+
);
|
|
877
913
|
// Use raw() to pass the command without auto-escaping
|
|
878
914
|
// This is important for complex commands with pipes, redirects, etc.
|
|
879
915
|
const result = await $cmd`${raw(cmd)}`;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Control modes: --upload-log, --stop, --terminate, --attach [--read-only],
|
|
10
10
|
* --resume [-- <command>], --resume-all,
|
|
11
11
|
* --cleanup [<uuid-or-session-name>]
|
|
12
|
+
* Naming modes: --alias <id> <name>, --rename <id> <name> (issue #231)
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -39,6 +40,9 @@ function createQueryOptionDefaults() {
|
|
|
39
40
|
cleanup: false, // Clean up stale "executing" records
|
|
40
41
|
cleanupDryRun: false, // Show what would be cleaned without cleaning
|
|
41
42
|
cleanupTarget: null, // UUID/session name whose containers and images --cleanup removes
|
|
43
|
+
alias: null, // Execution that --alias adds a name to
|
|
44
|
+
rename: null, // Execution that --rename renames
|
|
45
|
+
newName: null, // Name given by --alias/--rename
|
|
42
46
|
};
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -120,6 +124,35 @@ function hasValue(value) {
|
|
|
120
124
|
return value !== null && value !== undefined;
|
|
121
125
|
}
|
|
122
126
|
|
|
127
|
+
/**
|
|
128
|
+
* `--alias <id> <name>` and `--rename <id> <name>` take two arguments.
|
|
129
|
+
*/
|
|
130
|
+
const NAMING_OPTIONS = {
|
|
131
|
+
'--alias': 'alias',
|
|
132
|
+
'--rename': 'rename',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function parseNamingOption(args, index, options) {
|
|
136
|
+
const key = NAMING_OPTIONS[args[index]];
|
|
137
|
+
if (!key) {
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
const [identifier, name] = args.slice(index + 1, index + 3);
|
|
141
|
+
if (
|
|
142
|
+
identifier === undefined ||
|
|
143
|
+
name === undefined ||
|
|
144
|
+
identifier.startsWith('-') ||
|
|
145
|
+
name.startsWith('-')
|
|
146
|
+
) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Option ${args[index]} requires a UUID or session name and a new name`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
options[key] = identifier;
|
|
152
|
+
options.newName = name;
|
|
153
|
+
return 3;
|
|
154
|
+
}
|
|
155
|
+
|
|
123
156
|
/**
|
|
124
157
|
* Parse a single query/control option.
|
|
125
158
|
* @param {string[]} args - Arguments array
|
|
@@ -135,6 +168,11 @@ function parseQueryOption(args, index, options) {
|
|
|
135
168
|
return cleanupConsumed;
|
|
136
169
|
}
|
|
137
170
|
|
|
171
|
+
const namingConsumed = parseNamingOption(args, index, options);
|
|
172
|
+
if (namingConsumed > 0) {
|
|
173
|
+
return namingConsumed;
|
|
174
|
+
}
|
|
175
|
+
|
|
138
176
|
const flag = FLAG_OPTIONS[arg];
|
|
139
177
|
if (flag) {
|
|
140
178
|
flag(options);
|
|
@@ -216,6 +254,12 @@ function activeQueryModes(options) {
|
|
|
216
254
|
if (options.cleanup) {
|
|
217
255
|
modes.push('--cleanup');
|
|
218
256
|
}
|
|
257
|
+
if (hasValue(options.alias)) {
|
|
258
|
+
modes.push('--alias');
|
|
259
|
+
}
|
|
260
|
+
if (hasValue(options.rename)) {
|
|
261
|
+
modes.push('--rename');
|
|
262
|
+
}
|
|
219
263
|
return modes;
|
|
220
264
|
}
|
|
221
265
|
|
|
@@ -248,7 +292,7 @@ function validateQueryOptions(options) {
|
|
|
248
292
|
|
|
249
293
|
if (modes.length > 1) {
|
|
250
294
|
throw new Error(
|
|
251
|
-
'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --
|
|
295
|
+
'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename in the same invocation'
|
|
252
296
|
);
|
|
253
297
|
}
|
|
254
298
|
|
package/src/lib/args-parser.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* --mount <mount-spec> Docker --mount spec (repeatable, docker only)
|
|
15
15
|
* --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
16
16
|
* --privileged Run docker container in privileged mode (docker only)
|
|
17
|
+
* --init, --no-init Run an init process (tini) as PID 1 to reap orphans (docker only; default START_COMMAND_DOCKER_INIT)
|
|
17
18
|
* --network <name> Connect docker container to a named network (repeatable, docker only)
|
|
18
19
|
* --network-alias <alias> Add an alias to the first network (repeatable, docker only)
|
|
19
20
|
* --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
* --resume-all Re-attach or reconcile every execution still marked running
|
|
40
41
|
* --cleanup [<uuid-or-session>] Clean up stale "executing" records, or one execution's containers and snapshot images
|
|
41
42
|
* --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
|
|
43
|
+
* --alias <id> <name>, --rename <id> <name>, --append-attempt, --continues <id>: see args-parser-queries.js, launch-identity-options.js
|
|
42
44
|
*/
|
|
43
45
|
|
|
44
46
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
@@ -46,7 +48,9 @@ const dockerNetworkOptions = require('./docker-network-options');
|
|
|
46
48
|
const dockerRecoveryOptions = require('./docker-recovery-options');
|
|
47
49
|
const cpuPenalty = require('./cpu-penalty');
|
|
48
50
|
const dockerResourceOptions = require('./docker-resource-options');
|
|
51
|
+
const dockerInitOption = require('./docker-init-option');
|
|
49
52
|
const snapshotResumeOptions = require('./snapshot-resume-options');
|
|
53
|
+
const launchIdentityOptions = require('./launch-identity-options');
|
|
50
54
|
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
51
55
|
const { buildCommandString } = require('./shell-utils');
|
|
52
56
|
const {
|
|
@@ -197,6 +201,7 @@ function parseArgs(args) {
|
|
|
197
201
|
labels: [], // Caller attribution, applied to every Docker level
|
|
198
202
|
help: false,
|
|
199
203
|
privileged: false, // Run docker container in privileged mode
|
|
204
|
+
init: null, // docker run --init; null until resolved from START_COMMAND_DOCKER_INIT (issue #244)
|
|
200
205
|
network: null, // First Docker network name (compatibility accessor)
|
|
201
206
|
networks: [], // Ordered Docker network names
|
|
202
207
|
networkAliases: [], // Docker network-scoped aliases
|
|
@@ -217,6 +222,7 @@ function parseArgs(args) {
|
|
|
217
222
|
useCommandStream: false, // Use command-stream library for command execution
|
|
218
223
|
...createQueryOptionDefaults(),
|
|
219
224
|
...snapshotResumeOptions.createSnapshotOptionDefaults(),
|
|
225
|
+
...launchIdentityOptions.createLaunchIdentityOptionDefaults(),
|
|
220
226
|
};
|
|
221
227
|
|
|
222
228
|
let commandArgs = [];
|
|
@@ -427,6 +433,11 @@ function parseOption(args, index, options) {
|
|
|
427
433
|
return 1;
|
|
428
434
|
}
|
|
429
435
|
|
|
436
|
+
// --init / --no-init (for docker, issue #244)
|
|
437
|
+
if (dockerInitOption.parse(args, index, options)) {
|
|
438
|
+
return 1;
|
|
439
|
+
}
|
|
440
|
+
|
|
430
441
|
const networkOption = dockerNetworkOptions.parse(args, index, options);
|
|
431
442
|
if (networkOption) {
|
|
432
443
|
return networkOption;
|
|
@@ -580,6 +591,12 @@ function parseOption(args, index, options) {
|
|
|
580
591
|
return queryConsumed;
|
|
581
592
|
}
|
|
582
593
|
|
|
594
|
+
// --append-attempt, --continues <id> (issues #229, #231)
|
|
595
|
+
const identityConsumed = launchIdentityOptions.parse(args, index, options);
|
|
596
|
+
if (identityConsumed > 0) {
|
|
597
|
+
return identityConsumed;
|
|
598
|
+
}
|
|
599
|
+
|
|
583
600
|
// Not a recognized wrapper option
|
|
584
601
|
return 0;
|
|
585
602
|
}
|
|
@@ -794,6 +811,7 @@ function validateOptions(options) {
|
|
|
794
811
|
|
|
795
812
|
dockerRecoveryOptions.validate(options);
|
|
796
813
|
dockerResourceOptions.validate(options);
|
|
814
|
+
dockerInitOption.validate(options);
|
|
797
815
|
cpuPenalty.validate(options);
|
|
798
816
|
|
|
799
817
|
// Session name is only valid with isolation
|
|
@@ -830,6 +848,7 @@ function validateOptions(options) {
|
|
|
830
848
|
|
|
831
849
|
snapshotResumeOptions.validate(options);
|
|
832
850
|
validateQueryOptions(options);
|
|
851
|
+
launchIdentityOptions.validate(options);
|
|
833
852
|
|
|
834
853
|
// Validate shell option
|
|
835
854
|
if (options.shell !== null && options.shell !== undefined) {
|
package/src/lib/cgroup-memory.js
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
const { shellQuote } = require('./isolation-log-utils');
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
const CGROUP_SAMPLE_INTERVAL_SECONDS =
|
|
30
|
+
/** Default seconds between samples; the watcher environment may override it. */
|
|
31
|
+
const CGROUP_SAMPLE_INTERVAL_SECONDS = 5;
|
|
32
32
|
|
|
33
33
|
/** Shell variables used by the sampler snippets. */
|
|
34
34
|
const CGROUP_SHELL_VARS = {
|
|
@@ -74,10 +74,50 @@ function buildCgroupFunctionsSnippet() {
|
|
|
74
74
|
].join('; ');
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* In-container loop of the long-lived sampler exec (issue #242): `$1` is the
|
|
79
|
+
* cgroup directory. The host paces it with one newline per interval on stdin
|
|
80
|
+
* (`docker exec -i`), so it runs only shell builtins: the task's own CPU
|
|
81
|
+
* limit pays for no `cat`, `sed` or `sleep` processes. Stdin reaches EOF when
|
|
82
|
+
* 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.
|
|
84
|
+
* @returns {string} Shell script for `sh -c`
|
|
85
|
+
*/
|
|
86
|
+
function buildCgroupStreamScript() {
|
|
87
|
+
return [
|
|
88
|
+
'while read -r __scx_tick; do __scx_oom=; __scx_kill=; __scx_max=-; __scx_peak=-',
|
|
89
|
+
'[ -r "$1/memory.events" ] || exit 3',
|
|
90
|
+
'while read -r __scx_key __scx_value; do case "$__scx_key" in ' +
|
|
91
|
+
'oom) __scx_oom=$__scx_value;; oom_kill) __scx_kill=$__scx_value;; esac; ' +
|
|
92
|
+
'done < "$1/memory.events"',
|
|
93
|
+
'case "$__scx_oom:$__scx_kill" in *[!0-9:]*|:*|*:) exit 3;; esac',
|
|
94
|
+
'[ ! -r "$1/memory.max" ] || read -r __scx_max < "$1/memory.max"',
|
|
95
|
+
'[ ! -r "$1/memory.peak" ] || read -r __scx_peak < "$1/memory.peak"',
|
|
96
|
+
'printf \'%s %s %s %s %s\\n\' "${__scx_max:--}" "${__scx_peak:--}" ' +
|
|
97
|
+
'"$__scx_oom" "$__scx_kill" "$1" || exit 0; done',
|
|
98
|
+
].join('; ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `$0` of the long-lived exec, visible in `ps` inside the task container. */
|
|
102
|
+
const CGROUP_STREAM_NAME = 'start-command-cgroup-stream';
|
|
103
|
+
|
|
104
|
+
/** Interval value that turns the sampler off (issue #242). */
|
|
105
|
+
const CGROUP_SAMPLING_OFF_REASON =
|
|
106
|
+
'sampling disabled by START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off';
|
|
107
|
+
|
|
77
108
|
/**
|
|
78
109
|
* Shell fragment starting the background sampler. It writes each sample to a
|
|
79
110
|
* temp file with `mv`, so the watcher never reads a half-written line, and
|
|
80
|
-
* ends by itself once the
|
|
111
|
+
* ends by itself once the container is gone.
|
|
112
|
+
*
|
|
113
|
+
* Where the cgroup is readable locally it reads it directly. Otherwise, with
|
|
114
|
+
* a private cgroup namespace (a mounted docker.sock), one long-lived
|
|
115
|
+
* `docker exec -i` answers one host tick per interval (issue #242); a
|
|
116
|
+
* successful read proves the container runs, so Docker is asked for liveness
|
|
117
|
+
* only after a failed round or when the stream ends. Without a usable stream it falls back
|
|
118
|
+
* 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.
|
|
81
121
|
* @param {string} containerName - Container to sample
|
|
82
122
|
* @returns {string} Shell command
|
|
83
123
|
*/
|
|
@@ -85,32 +125,61 @@ function buildCgroupSamplerStartSnippet(containerName) {
|
|
|
85
125
|
const v = CGROUP_SHELL_VARS;
|
|
86
126
|
const name = shellQuote(containerName);
|
|
87
127
|
const remoteRead = '__start_command_cgroup_read /sys/fs/cgroup';
|
|
128
|
+
const unavailable = (detail) =>
|
|
129
|
+
`printf '%s\\n' "local cgroup unavailable; ${detail}" > "$${v.file}.reason"`;
|
|
130
|
+
const execReason = unavailable(
|
|
131
|
+
'Docker exec exit=$__scs_exit: ${__scs_detail:-private cgroup v2 counters unreadable}'
|
|
132
|
+
);
|
|
88
133
|
return [
|
|
89
134
|
buildCgroupFunctionsSnippet(),
|
|
90
135
|
`${v.file}="\${TMPDIR:-/tmp}/start-command-cgroup.$$"`,
|
|
91
136
|
`rm -f "$${v.file}" "$${v.file}.tmp" "$${v.file}.reason"`,
|
|
92
137
|
`__start_command_memory_limit=$(docker inspect -f '{{.HostConfig.Memory}}' ${name} 2>/dev/null)`,
|
|
93
138
|
`__start_command_sample_name=${name}`,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
`
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'if [ -n "$__scs_previous" ] && [ "$
|
|
109
|
-
'
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
`
|
|
113
|
-
`
|
|
139
|
+
`( export START_COMMAND_CGROUP_SAMPLING=1; ` +
|
|
140
|
+
// Validate in the watcher, so attached and detached launches inherit the
|
|
141
|
+
// same setting. Never allow a malformed/zero sleep to create a busy loop.
|
|
142
|
+
`__scs_interval=\${START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS:-${CGROUP_SAMPLE_INTERVAL_SECONDS}}; ` +
|
|
143
|
+
`case "$__scs_interval" in [Oo][Ff][Ff]) printf '%s\\n' '${CGROUP_SAMPLING_OFF_REASON}' > "$${v.file}.reason"; exit 0;; ` +
|
|
144
|
+
`*[!0-9]*|'') __scs_interval=${CGROUP_SAMPLE_INTERVAL_SECONDS};; esac; ` +
|
|
145
|
+
`[ "$__scs_interval" -gt 0 ] 2>/dev/null || __scs_interval=${CGROUP_SAMPLE_INTERVAL_SECONDS}; ` +
|
|
146
|
+
// Store one `<max> <peak> <oom> <oom_kill>` line. The previous sample is
|
|
147
|
+
// read back from the file, so the stream's reader subshell can record
|
|
148
|
+
// too. The first counter is a baseline, including after watcher restart;
|
|
149
|
+
// only changes between adjacent samples supply exit-time evidence.
|
|
150
|
+
`__scs_record() { set -f; set -- $1; set +f; case "$3:$4" in *[!0-9:]*|:*|*:) return 1;; esac; ` +
|
|
151
|
+
`__scs_now=$(date +%s); __scs_previous=''; __scs_changed=0; __scs_previous_at=0; ` +
|
|
152
|
+
`[ ! -r "$${v.file}" ] || read -r __scs_p __scs_p __scs_p __scs_previous __scs_changed __scs_previous_at __scs_p < "$${v.file}"; ` +
|
|
153
|
+
'if [ -n "$__scs_previous" ] && [ "$4" -gt "$__scs_previous" ] 2>/dev/null && [ "$((__scs_now-__scs_previous_at))" -le 3 ]; then __scs_changed=$__scs_now; fi; ' +
|
|
154
|
+
`printf '%s %s %s %s %s %s %s\\n' "$1" "$2" "$3" "$4" "$__scs_changed" "$__scs_now" "$__scs_dir" > "$${v.file}.tmp" && mv -f "$${v.file}.tmp" "$${v.file}"; }; ` +
|
|
155
|
+
`__scs_dir=$(__start_command_cgroup_dir ${name}); ` +
|
|
156
|
+
// The namespace is fixed at container creation, including when reads fail.
|
|
157
|
+
`__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 ` +
|
|
159
|
+
'__scs_line=$(__start_command_cgroup_read "$__scs_dir") && __scs_record "$__scs_line" && __scs_ok=1; ' +
|
|
160
|
+
`elif [ "$__scs_ns" != private ]; then ${unavailable('container cgroup namespace=${__scs_ns:-unavailable}, private required')}; break; ` +
|
|
161
|
+
`elif [ "$__scs_stream" = 1 ]; then ` +
|
|
162
|
+
// The reader ends with the stream; stopping the sampler stops it too.
|
|
163
|
+
`trap 'kill "$__scs_reader" 2>/dev/null; wait "$__scs_reader" 2>/dev/null; exit 0' TERM; ` +
|
|
164
|
+
// The ticker runs in the background, so a failed exec is reported at
|
|
165
|
+
// once; it ends on its first write after the exec is gone.
|
|
166
|
+
`{ { while :; do echo || exit 0; sleep "$__scs_interval" || exit 0; done 2>/dev/null & } | ` +
|
|
167
|
+
`docker exec -i ${name} sh -c ${shellQuote(buildCgroupStreamScript())} ${CGROUP_STREAM_NAME} /sys/fs/cgroup 2>&1; printf '__scs_exit %s\\n' "$?"; } | ` +
|
|
168
|
+
`{ __scs_got=''; __scs_exit=''; __scs_detail=''; while IFS= read -r __scs_line; do case "$__scs_line" in ` +
|
|
169
|
+
`'__scs_exit '*) __scs_exit=\${__scs_line#__scs_exit };; ` +
|
|
170
|
+
`*) if __scs_record "$__scs_line"; then __scs_got=1; elif [ -z "$__scs_detail" ]; then __scs_detail=$(printf '%s' "$__scs_line" | cut -c 1-240); fi;; esac; done; ` +
|
|
171
|
+
`[ -z "$__scs_got" ] || exit 0; ${execReason}; ` +
|
|
172
|
+
`case "$__scs_exit" in ''|0|*[!0-9]*) exit 1;; esac; exit "$__scs_exit"; } & ` +
|
|
173
|
+
`__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; ` +
|
|
176
|
+
`else __scs_remote=$(docker exec ${name} sh -c ${shellQuote(`${buildCgroupFunctionsSnippet()}; ${remoteRead}`)} 2>&1); __scs_exit=$?; ` +
|
|
177
|
+
'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; ` +
|
|
179
|
+
// A successful read needs no Docker liveness probe, and the completion
|
|
180
|
+
// watcher still stops us after docker wait.
|
|
181
|
+
`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}=$!`,
|
|
114
183
|
].join('; ');
|
|
115
184
|
}
|
|
116
185
|
|
|
@@ -305,13 +374,16 @@ function formatCgroupMemory(memory) {
|
|
|
305
374
|
|
|
306
375
|
module.exports = {
|
|
307
376
|
CGROUP_SAMPLE_INTERVAL_SECONDS,
|
|
377
|
+
CGROUP_SAMPLING_OFF_REASON,
|
|
308
378
|
CGROUP_SHELL_VARS,
|
|
379
|
+
CGROUP_STREAM_NAME,
|
|
309
380
|
OOM_SCOPE,
|
|
310
381
|
OOM_SCOPE_NOTES,
|
|
311
382
|
buildCgroupFunctionsSnippet,
|
|
312
383
|
buildCgroupMemoryLogSnippet,
|
|
313
384
|
buildCgroupSamplerStartSnippet,
|
|
314
385
|
buildCgroupSamplerStopSnippet,
|
|
386
|
+
buildCgroupStreamScript,
|
|
315
387
|
describeCgroupOomScope,
|
|
316
388
|
formatCgroupMemory,
|
|
317
389
|
formatCgroupMemoryLogLine,
|