@autor3search/javascript 0.2.0 → 0.2.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/README.md +35 -22
- package/package.json +3 -2
- package/src/cli/cmd-eval.js +32 -5
- package/src/cli/cmd-status.js +10 -2
- package/src/cli/cmd-stop.js +29 -20
- package/src/doctor.js +8 -29
- package/src/pipeline.js +11 -0
- package/src/runner.js +15 -0
- package/src/state/lock.js +11 -5
- package/src/state/stop.js +51 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
[](https://nodejs.org)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
+
[autoresearch for your codebase](https://autor3search.dev/) — the same loop in seven languages, with every agent prompt in one place.
|
|
8
|
+
|
|
7
9
|
An autonomous performance-optimization harness for JavaScript repositories. You
|
|
8
10
|
point a coding agent at your repository, run four commands, hand the agent
|
|
9
11
|
`program.md`, and go to sleep. The agent proposes one performance change at a
|
|
@@ -31,7 +33,7 @@ Or run it without installing anything, which is the better option when you
|
|
|
31
33
|
want a specific version pinned for a run:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
|
-
npx @autor3search/javascript@0.2.
|
|
36
|
+
npx @autor3search/javascript@0.2.1 doctor
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
Node 20 or newer. The harness measures with Vitest, so the repository you
|
|
@@ -171,17 +173,21 @@ Three ways to end a run, in increasing order of force:
|
|
|
171
173
|
under way finishes, is measured and scored normally; the agent sees
|
|
172
174
|
`"stop_requested": true` on that verdict, applies it as usual (`KEEP`
|
|
173
175
|
stays, anything else resets), then exits the loop. Nothing is thrown away.
|
|
174
|
-
2. **`autor3search-javascript stop --force`** — writes
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
`"status": "ABORTED"`, exit
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
2. **`autor3search-javascript stop --force`** — writes a sticky forced-stop
|
|
177
|
+
marker. The running `eval` polls for it and cancels itself, and that
|
|
178
|
+
cancellation is what tears down the benchmark process tree, so no Vitest
|
|
179
|
+
worker is left burning CPU. The agent sees `"status": "ABORTED"`, exit
|
|
180
|
+
code 2, and no `results.tsv` row — nothing was measured, so nothing was
|
|
181
|
+
recorded. The marker is sticky on purpose: if nothing was running when you
|
|
182
|
+
forced the stop, it stands and aborts the *next* `eval` to start instead,
|
|
183
|
+
until you clear it with `stop --clear`. `stop --force` reports what HEAD
|
|
184
|
+
looks like afterward; it does not touch the repository for you.
|
|
180
185
|
3. **Ctrl+C** — the same abort path as `stop --force`, sent directly to a
|
|
181
186
|
foreground `eval`.
|
|
182
187
|
|
|
183
|
-
`autor3search-javascript stop --clear` cancels a pending stop request
|
|
184
|
-
loop continues — that is your decision, never
|
|
188
|
+
`autor3search-javascript stop --clear` cancels both a pending stop request and
|
|
189
|
+
a pending forced stop, so the loop continues — that is your decision, never
|
|
190
|
+
the agent's to make on its own.
|
|
185
191
|
|
|
186
192
|
## Commands
|
|
187
193
|
|
|
@@ -380,19 +386,26 @@ Two things to know if you are wiring this up on a fork or a new package:
|
|
|
380
386
|
|---|---|
|
|
381
387
|
| **Linux** | supported — CI runs the full suite on Node 20 and 22 |
|
|
382
388
|
| **macOS** | supported — CI runs the full suite on Node 20 and 22 |
|
|
383
|
-
| **Windows** |
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
389
|
+
| **Windows** | supported — CI runs the full suite on Node 20 and 22 |
|
|
390
|
+
|
|
391
|
+
All three behave identically, by design. `stop` and `stop --force` write marker
|
|
392
|
+
files into the run's state directory rather than signalling a process: the
|
|
393
|
+
running `eval` polls for a forced stop and cancels itself, and that cancellation
|
|
394
|
+
is what tears the benchmark process tree down. A signal-based forced stop could
|
|
395
|
+
not behave the same way everywhere — Windows has no process-to-process SIGTERM,
|
|
396
|
+
so `eval` would be terminated outright and would never record what it abandoned.
|
|
397
|
+
Ctrl+C still works everywhere, because a console interrupt is real on Windows
|
|
398
|
+
too.
|
|
399
|
+
|
|
400
|
+
Three small things differ on Windows, none of them behavioural. `doctor` has no
|
|
401
|
+
load average to read there, so it makes one fewer check and says so rather than
|
|
402
|
+
reporting the fabricated zero the platform hands it. The state directory's
|
|
403
|
+
ownership and permission check is skipped, because Windows reports synthetic
|
|
404
|
+
mode bits and has no owner to compare against. And one test is skipped — the
|
|
405
|
+
one that makes a directory unlistable with `chmod`, which has no Windows
|
|
406
|
+
analogue.
|
|
407
|
+
|
|
408
|
+
WSL reports as Linux and is unaffected. Node 20 or newer is required everywhere.
|
|
396
409
|
|
|
397
410
|
## Limitations
|
|
398
411
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autor3search/javascript",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Autonomous AI-driven performance optimization for any JavaScript repository",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/autor3search/javascript.git"
|
|
11
11
|
},
|
|
12
|
+
"homepage": "https://autor3search.dev",
|
|
12
13
|
"keywords": [
|
|
14
|
+
"autoresearch",
|
|
13
15
|
"benchmark",
|
|
14
16
|
"performance",
|
|
15
17
|
"optimization",
|
|
@@ -40,7 +42,6 @@
|
|
|
40
42
|
"devDependencies": {
|
|
41
43
|
"vitest": "^2.1.0"
|
|
42
44
|
},
|
|
43
|
-
"homepage": "https://github.com/autor3search/javascript#readme",
|
|
44
45
|
"bugs": {
|
|
45
46
|
"url": "https://github.com/autor3search/javascript/issues"
|
|
46
47
|
},
|
package/src/cli/cmd-eval.js
CHANGED
|
@@ -16,10 +16,13 @@ import { RUN_LOG_NAME, evalOnce } from '../pipeline.js'
|
|
|
16
16
|
import { RESULTS_PATH, appendRow, loadRows } from '../results.js'
|
|
17
17
|
import { BASELINE_FILE, WORKTREE_NAME, loadBaseline } from '../state/index.js'
|
|
18
18
|
import { claimEval } from '../state/lock.js'
|
|
19
|
-
import { stopRequested } from '../state/stop.js'
|
|
19
|
+
import { forceRequested, stopRequested } from '../state/stop.js'
|
|
20
20
|
import { REASON, STATUS, exitCode } from '../verdict.js'
|
|
21
21
|
import { expandSingleDashFlags, loadRepoConfig, resolveRun } from './context.js'
|
|
22
22
|
|
|
23
|
+
/** How often a running eval looks for a forced-stop marker. */
|
|
24
|
+
const FORCE_POLL_MS = 500
|
|
25
|
+
|
|
23
26
|
/**
|
|
24
27
|
* @param {string[]} args
|
|
25
28
|
* @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
|
|
@@ -72,9 +75,23 @@ export async function runEval(args, io) {
|
|
|
72
75
|
const onSignal = () => controller.abort()
|
|
73
76
|
process.on('SIGINT', onSignal)
|
|
74
77
|
process.on('SIGTERM', onSignal)
|
|
75
|
-
|
|
78
|
+
// The forced stop is a polled marker, not a signal — see state/stop.js for
|
|
79
|
+
// why. It is installed here, beside the handlers and BEFORE claimEval, for
|
|
80
|
+
// exactly the reason spelled out above: a marker already pending when this
|
|
81
|
+
// run starts must abort it at its first checkpoint, not after it has claimed
|
|
82
|
+
// the run and begun measuring.
|
|
83
|
+
const poll = setInterval(() => {
|
|
84
|
+
forceRequested(run.stateDir)
|
|
85
|
+
.then((forced) => {
|
|
86
|
+
if (forced) controller.abort()
|
|
87
|
+
})
|
|
88
|
+
.catch(() => {})
|
|
89
|
+
}, FORCE_POLL_MS)
|
|
90
|
+
poll.unref()
|
|
91
|
+
const detach = () => {
|
|
76
92
|
process.off('SIGINT', onSignal)
|
|
77
93
|
process.off('SIGTERM', onSignal)
|
|
94
|
+
clearInterval(poll)
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
let claim
|
|
@@ -83,7 +100,7 @@ export async function runEval(args, io) {
|
|
|
83
100
|
} catch (err) {
|
|
84
101
|
// dispatch() also runs in-process under test, so a leaked listener here
|
|
85
102
|
// would accumulate across runs rather than dying with the process.
|
|
86
|
-
|
|
103
|
+
detach()
|
|
87
104
|
io.err.write(`${err.message}\n`)
|
|
88
105
|
return 2
|
|
89
106
|
}
|
|
@@ -128,11 +145,21 @@ export async function runEval(args, io) {
|
|
|
128
145
|
if (controller.signal.aborted) {
|
|
129
146
|
// ABORTED is not a verdict: nothing was measured, so NO results.tsv row
|
|
130
147
|
// is written. The agent treats it as it would a FAIL.
|
|
148
|
+
//
|
|
149
|
+
// A sticky forced stop aborts every later eval on this tag too, so an
|
|
150
|
+
// abort that finds the marker still pending must say so and name the
|
|
151
|
+
// cure. It goes in `message` — which writeJson already emits and the
|
|
152
|
+
// human path already writes to io.err — rather than in a new line of
|
|
153
|
+
// output, because --json's contract is that stdout carries the verdict
|
|
154
|
+
// object and nothing else.
|
|
155
|
+
const forced = await forceRequested(run.stateDir).catch(() => false)
|
|
131
156
|
const aborted = {
|
|
132
157
|
status: STATUS.ABORTED,
|
|
133
158
|
reason: REASON.STOP_FORCED,
|
|
134
159
|
score: 0,
|
|
135
|
-
message:
|
|
160
|
+
message: forced
|
|
161
|
+
? 'the experiment was abandoned: a forced stop is pending for this tag — clear it with `stop --clear` before the next eval'
|
|
162
|
+
: 'the experiment was interrupted before it could be measured',
|
|
136
163
|
regressions: [],
|
|
137
164
|
warnings: [],
|
|
138
165
|
}
|
|
@@ -149,7 +176,7 @@ export async function runEval(args, io) {
|
|
|
149
176
|
}
|
|
150
177
|
throw err
|
|
151
178
|
} finally {
|
|
152
|
-
|
|
179
|
+
detach()
|
|
153
180
|
await closeLog(logStream)
|
|
154
181
|
await claim.release()
|
|
155
182
|
}
|
package/src/cli/cmd-status.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as gitx from '../gitx.js'
|
|
|
8
8
|
import { RESULTS_PATH, loadRows } from '../results.js'
|
|
9
9
|
import { BASELINE_FILE, WORKTREE_NAME, loadBaseline } from '../state/index.js'
|
|
10
10
|
import { evalRunning } from '../state/lock.js'
|
|
11
|
-
import { stopRequested } from '../state/stop.js'
|
|
11
|
+
import { forceRequested, stopRequested } from '../state/stop.js'
|
|
12
12
|
import { expandSingleDashFlags, resolveRun } from './context.js'
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -42,6 +42,7 @@ export async function runStatus(args, io) {
|
|
|
42
42
|
evalLine = `unknown (${err.message})`
|
|
43
43
|
}
|
|
44
44
|
const stopped = await stopRequested(run.stateDir)
|
|
45
|
+
const forced = await forceRequested(run.stateDir)
|
|
45
46
|
|
|
46
47
|
const field = (name, value) => io.out.write(`${name.padEnd(14)} ${value}\n`)
|
|
47
48
|
field('run tag', run.tag)
|
|
@@ -60,7 +61,14 @@ export async function runStatus(args, io) {
|
|
|
60
61
|
`${counts.crash} crash) — next is #${rows.length + 1}`,
|
|
61
62
|
)
|
|
62
63
|
field('eval', evalLine)
|
|
63
|
-
field(
|
|
64
|
+
field(
|
|
65
|
+
'stop',
|
|
66
|
+
forced
|
|
67
|
+
? 'FORCED — the next eval will abort at once; clear it with `autor3search-javascript stop --clear`'
|
|
68
|
+
: stopped
|
|
69
|
+
? 'requested — the agent will end the run after the current experiment'
|
|
70
|
+
: 'not requested',
|
|
71
|
+
)
|
|
64
72
|
|
|
65
73
|
io.out.write('\nto stop after the current experiment: autor3search-javascript stop\n')
|
|
66
74
|
io.out.write('to stop now, abandoning it: autor3search-javascript stop --force\n')
|
package/src/cli/cmd-stop.js
CHANGED
|
@@ -6,15 +6,15 @@
|
|
|
6
6
|
* and only then does the loop exit. Nothing is thrown away.
|
|
7
7
|
*
|
|
8
8
|
* `--force` is for when a long benchmark cannot be waited out. It writes the
|
|
9
|
-
* same request, then
|
|
10
|
-
* REPORTS what state that leaves the repository
|
|
11
|
-
* for you, because deciding what to do with a
|
|
12
|
-
* human's call.
|
|
9
|
+
* same request, then marks the run for the running eval to poll and abandon
|
|
10
|
+
* the experiment on its own. It REPORTS what state that leaves the repository
|
|
11
|
+
* in; it does not drop anything for you, because deciding what to do with a
|
|
12
|
+
* half-finished experiment is the human's call.
|
|
13
13
|
*/
|
|
14
14
|
import { parseArgs } from 'node:util'
|
|
15
15
|
import * as gitx from '../gitx.js'
|
|
16
16
|
import { evalRunning } from '../state/lock.js'
|
|
17
|
-
import { clearStop, requestStop } from '../state/stop.js'
|
|
17
|
+
import { clearStop, forceRequested, requestForceStop, requestStop } from '../state/stop.js'
|
|
18
18
|
import { expandSingleDashFlags, resolveRun } from './context.js'
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -50,35 +50,44 @@ export async function runStop(args, io) {
|
|
|
50
50
|
await requestStop(run.stateDir)
|
|
51
51
|
io.out.write(`stop requested for ${run.tag}: the agent will end the run after the current experiment\n`)
|
|
52
52
|
if (!values.force) {
|
|
53
|
-
|
|
53
|
+
// A plain `stop` only promises the current experiment survives when
|
|
54
|
+
// nothing is already asking to abandon it. A forced stop from EARLIER —
|
|
55
|
+
// `stop --force` issued when nothing was running, then never cleared —
|
|
56
|
+
// is still pending and sticky, and it will abort the next eval to start
|
|
57
|
+
// regardless of what this plain `stop` just requested.
|
|
58
|
+
if (await forceRequested(run.stateDir)) {
|
|
59
|
+
io.out.write('a forced stop from earlier is still pending, though, and will abort the next eval to start\n')
|
|
60
|
+
} else {
|
|
61
|
+
io.out.write('the experiment under way will still be measured, scored and applied\n')
|
|
62
|
+
}
|
|
54
63
|
io.out.write(`to cancel: autor3search-javascript stop -tag ${run.tag} --clear\n`)
|
|
55
64
|
return 0
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
67
|
+
await requestForceStop(run.stateDir)
|
|
68
|
+
|
|
69
|
+
// Nothing is signalled between processes: the running eval polls this marker
|
|
70
|
+
// and cancels itself. That behaves identically on every platform — Windows
|
|
71
|
+
// has no process-to-process SIGTERM — and it cannot reach an unrelated
|
|
72
|
+
// process the way signalling a pid read from a file can, if the OS has
|
|
73
|
+
// recycled that pid since.
|
|
74
|
+
//
|
|
75
|
+
// evalRunning is therefore reporting, not mechanism. A corrupt pid file
|
|
76
|
+
// costs a line of the report and nothing else; the marker already stands.
|
|
61
77
|
let pid = null
|
|
62
78
|
let running = false
|
|
63
79
|
try {
|
|
64
80
|
;({ pid, running } = await evalRunning(run.stateDir))
|
|
65
81
|
} catch (err) {
|
|
66
|
-
io.err.write(`could not read the eval lock (${err.message});
|
|
82
|
+
io.err.write(`could not read the eval lock (${err.message}); cannot say whether an eval is running\n`)
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
if (running) {
|
|
70
|
-
|
|
71
|
-
// process groups. Killing it outright would leave Vitest workers running,
|
|
72
|
-
// burning CPU and corrupting every later measurement on this machine.
|
|
73
|
-
try {
|
|
74
|
-
process.kill(pid, 'SIGTERM')
|
|
75
|
-
io.out.write(`signalled eval (pid ${pid}) to abandon the current experiment\n`)
|
|
76
|
-
} catch (err) {
|
|
77
|
-
io.err.write(`could not signal eval (pid ${pid}): ${err.message}\n`)
|
|
78
|
-
}
|
|
86
|
+
io.out.write(`eval (pid ${pid}) will abandon the current experiment within a second\n`)
|
|
79
87
|
} else {
|
|
80
|
-
io.out.write('no eval is running, so there was nothing to interrupt — the
|
|
88
|
+
io.out.write('no eval is running, so there was nothing to interrupt — the forced stop stands and will abort the next eval to start\n')
|
|
81
89
|
}
|
|
90
|
+
io.out.write(`to cancel it: autor3search-javascript stop -tag ${run.tag} --clear\n`)
|
|
82
91
|
|
|
83
92
|
// Report, do not act — whether or not anything was actually interrupted,
|
|
84
93
|
// this is what the repository looks like right now. The commit an
|
package/src/doctor.js
CHANGED
|
@@ -37,13 +37,7 @@ export async function check(dir) {
|
|
|
37
37
|
const absDir = resolvePath(dir)
|
|
38
38
|
|
|
39
39
|
const platformCheck =
|
|
40
|
-
platform() === 'darwin'
|
|
41
|
-
? ['power', checkDarwin]
|
|
42
|
-
: platform() === 'linux'
|
|
43
|
-
? ['cpu governor', checkLinux]
|
|
44
|
-
: platform() === 'win32'
|
|
45
|
-
? ['platform', checkWindows]
|
|
46
|
-
: null
|
|
40
|
+
platform() === 'darwin' ? ['power', checkDarwin] : platform() === 'linux' ? ['cpu governor', checkLinux] : null
|
|
47
41
|
|
|
48
42
|
const checks = [
|
|
49
43
|
['node', checkNode],
|
|
@@ -106,6 +100,13 @@ function checkCpu() {
|
|
|
106
100
|
}
|
|
107
101
|
|
|
108
102
|
function checkLoad() {
|
|
103
|
+
// os.loadavg() is not implemented on Windows: it returns a hardcoded
|
|
104
|
+
// [0, 0, 0]. Reporting that would tell a user under heavy load that their
|
|
105
|
+
// machine is perfectly idle — worse than saying nothing, in a tool that
|
|
106
|
+
// exists to predict whether measurement will be reliable.
|
|
107
|
+
if (platform() === 'win32') {
|
|
108
|
+
return { name: 'load', detail: 'no load average on this platform — check the machine is quiet yourself', severity: SEVERITY.NA }
|
|
109
|
+
}
|
|
109
110
|
const [one] = loadavg()
|
|
110
111
|
const perCore = one / Math.max(1, cpus().length)
|
|
111
112
|
return {
|
|
@@ -144,28 +145,6 @@ async function checkVitest(dir) {
|
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
/**
|
|
148
|
-
* Windows is not a supported platform, and the reason is the stop path.
|
|
149
|
-
*
|
|
150
|
-
* 448 of 458 tests pass there, so most of the harness works — but Node cannot
|
|
151
|
-
* deliver SIGINT to a child process group on Windows the way it does on POSIX,
|
|
152
|
-
* so an interrupted `eval` does not reach the ABORTED path: it exits with a
|
|
153
|
-
* null code instead of 2, and the run may leave a claim behind. An unattended
|
|
154
|
-
* harness that cannot be reliably stopped is the wrong thing to be quiet about,
|
|
155
|
-
* so this says it up front rather than at 3am.
|
|
156
|
-
*
|
|
157
|
-
* WSL reports linux and is unaffected.
|
|
158
|
-
*/
|
|
159
|
-
function checkWindows() {
|
|
160
|
-
return {
|
|
161
|
-
name: 'platform',
|
|
162
|
-
detail:
|
|
163
|
-
'Windows is not a supported platform: an interrupted eval does not reach the ABORTED path, so a run ' +
|
|
164
|
-
'may not stop cleanly and can leave its claim behind. Measurement itself works. Use WSL for a supported setup.',
|
|
165
|
-
severity: SEVERITY.WARN,
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
148
|
function checkHeapHint() {
|
|
170
149
|
// Reported up front so a run does not discover mid-flight that the hint is
|
|
171
150
|
// unavailable, which would otherwise look like the benchmark allocating
|
package/src/pipeline.js
CHANGED
|
@@ -97,6 +97,17 @@ const GATE_VERDICT = {
|
|
|
97
97
|
* @returns {Promise<{result: object, measurements: {time: object[], bytes: object[]|null}|null}>}
|
|
98
98
|
*/
|
|
99
99
|
export async function evalOnce(o) {
|
|
100
|
+
// A sticky forced stop that is already pending when a run starts must not
|
|
101
|
+
// cost a full gate run before it is honoured: the scope diff, config hash,
|
|
102
|
+
// freeze restore and the gates themselves are all real work, and none of
|
|
103
|
+
// it should happen for an experiment that is about to be thrown away
|
|
104
|
+
// unmeasured. Checked here, before anything below touches git or the
|
|
105
|
+
// filesystem, rather than only after the gates (line 263's neighbour) —
|
|
106
|
+
// that later check exists for an abort that arrives WHILE gating is under
|
|
107
|
+
// way; this one is for an abort that was already true before evalOnce was
|
|
108
|
+
// ever called.
|
|
109
|
+
if (o.signal?.aborted) throw new Error('aborted before the experiment began')
|
|
110
|
+
|
|
100
111
|
const timeoutMs = parseDuration(o.cfg.timeout)
|
|
101
112
|
|
|
102
113
|
// ---- 1. Scope --------------------------------------------------------
|
package/src/runner.js
CHANGED
|
@@ -183,6 +183,21 @@ class Capped {
|
|
|
183
183
|
* Signals a child's whole process group. A negative pid addresses the group.
|
|
184
184
|
* Failures are swallowed: the group is already gone in the common case, and a
|
|
185
185
|
* teardown that throws would mask the real result being reported.
|
|
186
|
+
*
|
|
187
|
+
* On Windows there are no process groups, so `process.kill(-pid, …)` throws and
|
|
188
|
+
* the fallback terminates only the direct child — yet grandchildren still die,
|
|
189
|
+
* and runner.test.js proves it on every platform. The reason is libuv: it
|
|
190
|
+
* assigns each non-detached child to a global job object with
|
|
191
|
+
* JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so when the direct child dies, its job
|
|
192
|
+
* handle closes and everything it spawned goes with it.
|
|
193
|
+
*
|
|
194
|
+
* That holds only because EVERY subprocess this harness starts is
|
|
195
|
+
* process.execPath — see adapters/bench/vitest.js and adapters/gates/*.js — so
|
|
196
|
+
* every intermediate is a Node process carrying such a job object. Two changes
|
|
197
|
+
* would silently break it and leave vitest's workers running on Windows,
|
|
198
|
+
* burning CPU and corrupting every later measurement: running a gate through
|
|
199
|
+
* `npm`/`npx` (a .cmd, so cmd.exe becomes the intermediate and there is no job
|
|
200
|
+
* object), or adding `detached: true` to an inner spawn.
|
|
186
201
|
*/
|
|
187
202
|
function killGroup(pid, signal) {
|
|
188
203
|
if (!pid) return
|
package/src/state/lock.js
CHANGED
|
@@ -137,15 +137,21 @@ async function readLock(lockPath) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
// pid <= 1 is refused, not just pid <= 0: kill(1, ...) targets init/launchd
|
|
140
|
-
// (every process on the system on some platforms), and kill(-1, ...)
|
|
141
|
-
//
|
|
142
|
-
// process
|
|
143
|
-
//
|
|
140
|
+
// (every process on the system on some platforms), and kill(-1, ...) means
|
|
141
|
+
// "every process the caller may signal". Nothing in src/cli/ hands a pid to
|
|
142
|
+
// process.kill any more — stop --force writes a marker, not a signal (see
|
|
143
|
+
// state/stop.js) — but alive() below still calls process.kill(pid, 0) as a
|
|
144
|
+
// liveness probe, and pid 1 or -1 would make that probe ALWAYS succeed. A
|
|
145
|
+
// lock is only reclaimed once BOTH the pid is dead and the heartbeat is
|
|
146
|
+
// cold (isStale, below); a pid this "alive" check can never call dead would
|
|
147
|
+
// leave that lock permanently unreclaimable, refusing every later eval on
|
|
148
|
+
// the tag forever. A pid file holding either value must never be read back
|
|
149
|
+
// and treated as one this process may trust.
|
|
144
150
|
if (pid <= 1) {
|
|
145
151
|
return {
|
|
146
152
|
pid: null,
|
|
147
153
|
heartbeat,
|
|
148
|
-
error: new Error(`${join(lockPath, 'pid')}: pid ${pid} is not a
|
|
154
|
+
error: new Error(`${join(lockPath, 'pid')}: pid ${pid} is not a pid this run will trust`),
|
|
149
155
|
}
|
|
150
156
|
}
|
|
151
157
|
return { pid, heartbeat, error: null }
|
package/src/state/stop.js
CHANGED
|
@@ -21,6 +21,26 @@ import { join } from 'node:path'
|
|
|
21
21
|
*/
|
|
22
22
|
export const STOP_REQUEST_FILE = 'stop.request'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Marks that the human has asked the run to abandon what it is measuring RIGHT
|
|
26
|
+
* NOW, rather than after the current experiment.
|
|
27
|
+
*
|
|
28
|
+
* A marker rather than a signal, because a signal cannot cross processes on
|
|
29
|
+
* every platform this harness supports. Windows has no process-to-process
|
|
30
|
+
* SIGTERM or SIGINT; Node implements `process.kill` there as TerminateProcess,
|
|
31
|
+
* so the target dies where it stands — its `finally` never runs, `eval` exits
|
|
32
|
+
* with a null code instead of 2, and its claim is left behind to go stale.
|
|
33
|
+
* Polling a file behaves identically everywhere. It also closes a race that
|
|
34
|
+
* exists on POSIX, where signalling a pid read from a file can reach an
|
|
35
|
+
* unrelated process the OS has since recycled that pid onto.
|
|
36
|
+
*
|
|
37
|
+
* STICKY, and deliberately so: an unattended agent loop starts a fresh `eval`
|
|
38
|
+
* seconds after the last one exits, so a marker consumed by the eval it aborts
|
|
39
|
+
* would be gone before the loop's next iteration read it — the human hits the
|
|
40
|
+
* brake and the run carries on regardless. Only `clearStop` removes it.
|
|
41
|
+
*/
|
|
42
|
+
export const STOP_FORCE_FILE = 'stop.force'
|
|
43
|
+
|
|
24
44
|
/**
|
|
25
45
|
* Asks the run in stateDir to end after the current experiment.
|
|
26
46
|
*
|
|
@@ -34,9 +54,39 @@ export async function requestStop(stateDir) {
|
|
|
34
54
|
await writeFile(join(stateDir, STOP_REQUEST_FILE), 'stop requested\n')
|
|
35
55
|
}
|
|
36
56
|
|
|
37
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Asks the eval running in stateDir to abandon its experiment at once.
|
|
59
|
+
*
|
|
60
|
+
* Creates stateDir for the same reason requestStop does: a human reaching for
|
|
61
|
+
* the brake should never be told the directory does not exist yet.
|
|
62
|
+
*/
|
|
63
|
+
export async function requestForceStop(stateDir) {
|
|
64
|
+
await ensureSecureDir(stateDir)
|
|
65
|
+
await writeFile(join(stateDir, STOP_FORCE_FILE), 'forced stop requested\n')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reports whether a forced stop is pending. Boolean rather than throwing, for
|
|
70
|
+
* the same reason stopRequested is: an unreadable sentinel and an absent one
|
|
71
|
+
* deserve the same answer, and a marker that cannot be read must never abort a
|
|
72
|
+
* run by itself.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Promise<boolean>}
|
|
75
|
+
*/
|
|
76
|
+
export async function forceRequested(stateDir) {
|
|
77
|
+
return stat(join(stateDir, STOP_FORCE_FILE)).then(
|
|
78
|
+
() => true,
|
|
79
|
+
() => false,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Cancels both pending requests — one brake, one release. Clearing one never
|
|
85
|
+
* made is not an error.
|
|
86
|
+
*/
|
|
38
87
|
export async function clearStop(stateDir) {
|
|
39
88
|
await rm(join(stateDir, STOP_REQUEST_FILE), { force: true })
|
|
89
|
+
await rm(join(stateDir, STOP_FORCE_FILE), { force: true })
|
|
40
90
|
}
|
|
41
91
|
|
|
42
92
|
/**
|