@vzn/vx 0.0.652 → 0.0.653
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 +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/exec/runner.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isOutOfFds,
|
|
14
14
|
killGraceMs,
|
|
15
15
|
OUT_OF_FDS_HINT,
|
|
16
|
+
POST_EXIT_DRAIN_MS,
|
|
16
17
|
testTimerMs,
|
|
17
18
|
helperEnv,
|
|
18
19
|
type SecretMask,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
import {
|
|
21
22
|
type Child,
|
|
22
23
|
closeSignalChannel,
|
|
24
|
+
groupAlive,
|
|
23
25
|
holdGroups,
|
|
24
26
|
killTree,
|
|
25
27
|
releaseGroup,
|
|
@@ -51,6 +53,9 @@ export interface RunResult {
|
|
|
51
53
|
* Distinguishes a timeout (a real `failed`) from a Ctrl-C shutdown
|
|
52
54
|
* SIGTERM (which the orchestrator reverts to `aborted`). */
|
|
53
55
|
timedOut?: boolean
|
|
56
|
+
/** The timeout's SIGKILL past the grace took a process of the task's
|
|
57
|
+
* group: the child, or one it left after dying of the SIGTERM. */
|
|
58
|
+
killedAfterGrace?: true
|
|
54
59
|
/** Total user+system CPU time for the child, in milliseconds. */
|
|
55
60
|
cpuMs?: number
|
|
56
61
|
/** Peak resident set size for the child, in bytes. */
|
|
@@ -58,9 +63,10 @@ export interface RunResult {
|
|
|
58
63
|
/** `Bun.spawn` threw: no shell ran, so its 127 is vx's and says nothing
|
|
59
64
|
* about the command (A-41). */
|
|
60
65
|
spawnFailed?: true
|
|
61
|
-
/** The shell exited while a process it started still
|
|
62
|
-
* stdout/stderr past the drain bound (`POST_EXIT_CUT_LINE`)
|
|
63
|
-
* may still write the task's outputs,
|
|
66
|
+
/** The shell exited while a process it started still ran: one holding
|
|
67
|
+
* stdout/stderr past the drain bound (`POST_EXIT_CUT_LINE`), or one left
|
|
68
|
+
* in the task's process group. It may still write the task's outputs,
|
|
69
|
+
* so the result is not saved. */
|
|
64
70
|
leftRunning?: true
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -182,7 +188,12 @@ export function shellQuote(arg: string): string {
|
|
|
182
188
|
* `<<X` in a comment or quotes opens no heredoc, and a quote in a body
|
|
183
189
|
* opens nothing (X-110).
|
|
184
190
|
*/
|
|
185
|
-
function shellShape(command: string): {
|
|
191
|
+
function shellShape(command: string): {
|
|
192
|
+
comment: number
|
|
193
|
+
body: boolean[]
|
|
194
|
+
subshell: boolean
|
|
195
|
+
quoted: boolean
|
|
196
|
+
} {
|
|
186
197
|
const lines = command.split('\n')
|
|
187
198
|
const body = lines.map(() => false)
|
|
188
199
|
const pending: Array<{ delim: string; tabs: boolean }> = []
|
|
@@ -261,9 +272,36 @@ function shellShape(command: string): { comment: number; body: boolean[]; subshe
|
|
|
261
272
|
}
|
|
262
273
|
atWordStart = /[\s;&|()<>]/.test(c)
|
|
263
274
|
}
|
|
264
|
-
return { comment, body, subshell }
|
|
275
|
+
return { comment, body, subshell, quoted: quote !== '' }
|
|
265
276
|
}
|
|
266
277
|
|
|
278
|
+
// One redirection at the end of a command: an fd, an operator, and a
|
|
279
|
+
// target word (quoted parts and `<(…)` / `>(…)` included).
|
|
280
|
+
const TRAILING_REDIRECT =
|
|
281
|
+
/(?:^|[\s)])(?:&>>?|\d*(?:<<<|>>|>&|<&|<>|>\||>|<))[ \t]*(?:[<>]\([^()]*\)|(?:[^\s;&|()<>'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+)$/
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Where trailing redirections start, when a compound command's closer
|
|
285
|
+
* stands before them (`(cmd) 2>&1`, `done < f`), or -1. One inside quotes
|
|
286
|
+
* or a comment is not peeled.
|
|
287
|
+
*/
|
|
288
|
+
function redirectsAfterCloser(command: string): number {
|
|
289
|
+
let head = command
|
|
290
|
+
for (;;) {
|
|
291
|
+
const m = TRAILING_REDIRECT.exec(head)
|
|
292
|
+
if (m === null) break
|
|
293
|
+
const start = m.index + (/^[\s)]/.test(m[0]) ? 1 : 0)
|
|
294
|
+
const rest = head.slice(0, start)
|
|
295
|
+
const shape = shellShape(rest)
|
|
296
|
+
if (shape.quoted || shape.comment >= 0 || /(?:^|[^\\])(?:\\\\)*\\$/.test(rest)) break
|
|
297
|
+
head = rest.trimEnd()
|
|
298
|
+
}
|
|
299
|
+
if (head.length === command.length) return -1
|
|
300
|
+
return shellShape(head).subshell || COMPOUND_CLOSER.test(head) ? head.length : -1
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const COMPOUND_CLOSER = /(?:^|[^\\])(?:\\\\)*[;&\n][ \t]*(fi|done|esac|\})$/
|
|
304
|
+
|
|
267
305
|
/**
|
|
268
306
|
* The command a task runs with the args after `--` appended, shell-quoted.
|
|
269
307
|
* They go before a trailing comment: appended after it, `echo args: # show`
|
|
@@ -300,10 +338,12 @@ export function withForwardArgs(command: string, args: readonly string[] | undef
|
|
|
300
338
|
// After a compound command's closer they were a syntax error
|
|
301
339
|
// (`(cmd) --fix`, `fi --fix`): they go to the last command inside it.
|
|
302
340
|
// A closer word counts only where a command starts, after a separator.
|
|
303
|
-
const closer = shape.subshell
|
|
304
|
-
? ')'
|
|
305
|
-
: /(?:^|[^\\])(?:\\\\)*[;&\n][ \t]*(fi|done|esac|\})$/.exec(trimmed)?.[1]
|
|
341
|
+
const closer = shape.subshell ? ')' : COMPOUND_CLOSER.exec(trimmed)?.[1]
|
|
306
342
|
const end = sep ?? closer
|
|
343
|
+
// Redirections after the closer (`(cmd) 2>&1`, `done < f`) hid it: the
|
|
344
|
+
// args go inside it and the redirections stay after.
|
|
345
|
+
const at = end === undefined ? redirectsAfterCloser(trimmed) : -1
|
|
346
|
+
if (at >= 0) return `${withForwardArgs(trimmed.slice(0, at), args)}${trimmed.slice(at)}`
|
|
307
347
|
if (end !== undefined) {
|
|
308
348
|
const head = trimmed.slice(0, -end.length).trimEnd()
|
|
309
349
|
return `${withForwardArgs(head, args)}${trimmed.slice(head.length)}`
|
|
@@ -504,7 +544,8 @@ function afterPendingExits(fn: () => void): ReturnType<typeof setTimeout> {
|
|
|
504
544
|
* classify the resulting SIGTERM as a real failure rather than a
|
|
505
545
|
* Ctrl-C abort — and `settle()`, awaited once the child has exited:
|
|
506
546
|
* it cancels the timers and, when the timeout fired, waits out the rest
|
|
507
|
-
* of the grace for the child's GROUP and SIGKILLs whoever is left
|
|
547
|
+
* of the grace for the child's GROUP and SIGKILLs whoever is left, and
|
|
548
|
+
* `sigkilled()` says whether that SIGKILL found anyone. The
|
|
508
549
|
* shell dying on the SIGTERM is not the tree dying: a backgrounded
|
|
509
550
|
* process that ignores it lived on under init after vx exited (nx#11782's
|
|
510
551
|
* sibling, reproduced on vx 2026-09-24). A no-op (never fires, nothing
|
|
@@ -513,16 +554,18 @@ function afterPendingExits(fn: () => void): ReturnType<typeof setTimeout> {
|
|
|
513
554
|
export function armTimeout(
|
|
514
555
|
proc: ReturnType<typeof Bun.spawn>,
|
|
515
556
|
timeoutMs: number | undefined,
|
|
516
|
-
): { timedOut: () => boolean; settle: () => Promise<void> } {
|
|
517
|
-
if (timeoutMs === undefined)
|
|
557
|
+
): { timedOut: () => boolean; sigkilled: () => boolean; settle: () => Promise<void> } {
|
|
558
|
+
if (timeoutMs === undefined)
|
|
559
|
+
return { timedOut: () => false, sigkilled: () => false, settle: async () => {} }
|
|
518
560
|
let firedAt: number | undefined
|
|
561
|
+
let sigkilled = false
|
|
519
562
|
let killTimer: ReturnType<typeof setTimeout> | undefined
|
|
520
563
|
const graceMs = killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)
|
|
521
564
|
let timer = setTimeout(() => {
|
|
522
565
|
timer = afterPendingExits(fire)
|
|
523
566
|
}, timeoutMs)
|
|
524
567
|
function fire(): void {
|
|
525
|
-
firedAt =
|
|
568
|
+
firedAt = performance.now()
|
|
526
569
|
killTree(proc, 'SIGTERM')
|
|
527
570
|
// Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
|
|
528
571
|
// (`trap '' TERM`) would otherwise defeat the timeout entirely and hang
|
|
@@ -530,35 +573,26 @@ export function armTimeout(
|
|
|
530
573
|
// timeout, so a wedged child hangs the whole run forever. Mirrors the
|
|
531
574
|
// end-of-run persistent-shutdown escalation. Unref'd so it never keeps
|
|
532
575
|
// the CLI alive.
|
|
533
|
-
killTimer = setTimeout(() =>
|
|
576
|
+
killTimer = setTimeout(() => {
|
|
577
|
+
sigkilled = true
|
|
578
|
+
killTree(proc, 'SIGKILL')
|
|
579
|
+
}, graceMs)
|
|
534
580
|
killTimer.unref?.()
|
|
535
581
|
}
|
|
536
582
|
return {
|
|
537
583
|
timedOut: () => firedAt !== undefined,
|
|
584
|
+
sigkilled: () => sigkilled,
|
|
538
585
|
settle: async () => {
|
|
539
586
|
clearTimeout(timer)
|
|
540
587
|
if (killTimer !== undefined) clearTimeout(killTimer)
|
|
541
588
|
if (firedAt === undefined) return
|
|
542
|
-
const left = await untilGroupsGone([proc], Math.max(0, firedAt + graceMs -
|
|
589
|
+
const left = await untilGroupsGone([proc], Math.max(0, firedAt + graceMs - performance.now()))
|
|
543
590
|
for (const child of left) killTree(child, 'SIGKILL')
|
|
591
|
+
if (left.length > 0) sigkilled = true
|
|
544
592
|
},
|
|
545
593
|
}
|
|
546
594
|
}
|
|
547
595
|
|
|
548
|
-
/**
|
|
549
|
-
* Grace after the direct child exits before we abort the stdout/stderr readers.
|
|
550
|
-
* The pipe reaches EOF only when EVERY write-end fd is closed, so a task that
|
|
551
|
-
* backgrounds a process inheriting fd 1/2 (`server & echo up` — `execWrap`
|
|
552
|
-
* leaves compound commands as `sh -c`, so `sh` exits while the grandchild holds
|
|
553
|
-
* the pipe) never EOFs. On a clean exit with no lingering writer the pipe EOFs
|
|
554
|
-
* at once, so the readers win this race immediately and normal tasks pay
|
|
555
|
-
* nothing; only a stuck reader waits out the grace. Residual buffered output is
|
|
556
|
-
* bounded by the OS pipe buffer (streamToString drains continuously DURING the
|
|
557
|
-
* run), so this is far longer than any real drain needs. What the leftover
|
|
558
|
-
* process writes after the bound is lost, and `POST_EXIT_CUT_LINE` says so.
|
|
559
|
-
*/
|
|
560
|
-
const POST_EXIT_DRAIN_MS = 250
|
|
561
|
-
|
|
562
596
|
/**
|
|
563
597
|
* The line a task's frame gets when the drain bound cut its pipes. The cut
|
|
564
598
|
* was silent: a backgrounded child's late output vanished from the frame and
|
|
@@ -746,6 +780,8 @@ export interface PersistentSpawn {
|
|
|
746
780
|
ready: Promise<void>
|
|
747
781
|
/** ms from spawn to ready, to the readiness timeout giving up, or to now. */
|
|
748
782
|
readyMs: () => number
|
|
783
|
+
/** Whether the readiness timeout fired: `ready` rejects only after the kill grace. */
|
|
784
|
+
gaveUp: () => boolean
|
|
749
785
|
}
|
|
750
786
|
|
|
751
787
|
// `capture` is a `runCommand` concept: a persistent task never returns a
|
|
@@ -765,6 +801,12 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
|
|
|
765
801
|
* resolves before the timer can fire).
|
|
766
802
|
*/
|
|
767
803
|
timeoutMs?: number
|
|
804
|
+
/**
|
|
805
|
+
* Called once, at ready: after `onStdout` / `onStderr` had the text up to
|
|
806
|
+
* the end of the ready line, before anything after it. What follows is
|
|
807
|
+
* the since-ready stream; a chunk holding both is split there.
|
|
808
|
+
*/
|
|
809
|
+
onReady?: () => void
|
|
768
810
|
/**
|
|
769
811
|
* Spawn it with fd 3 as its signal channel and route its polite signals
|
|
770
812
|
* there (`signalThrough`): a Linux sandboxed command, whose group signal
|
|
@@ -781,11 +823,14 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
|
|
|
781
823
|
* `onStderr` callbacks for the whole lifetime of the child.
|
|
782
824
|
*/
|
|
783
825
|
export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
784
|
-
const start =
|
|
826
|
+
const start = performance.now()
|
|
785
827
|
let readyAt: number | undefined
|
|
786
828
|
// When the readiness wait gave up: the wait's length, not the kill grace
|
|
787
829
|
// a TERM-trapping server runs out after it.
|
|
788
830
|
let gaveUpAt: number | undefined
|
|
831
|
+
// The shell exited non-zero before a match: a marker read after it, from
|
|
832
|
+
// a child it left running, cannot make an exited server ready.
|
|
833
|
+
let failedExit = false
|
|
789
834
|
|
|
790
835
|
// Pattern compiled once; thrown errors surface synchronously so the
|
|
791
836
|
// caller can wrap with a user-facing message.
|
|
@@ -839,13 +884,19 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
839
884
|
const message =
|
|
840
885
|
isExecutableMissing(err) && !existsSync(opts.cwd)
|
|
841
886
|
? `its working directory ${opts.cwd} does not exist`
|
|
842
|
-
: spawnErrorReason(
|
|
887
|
+
: spawnErrorReason(
|
|
888
|
+
err,
|
|
889
|
+
opts.signalChannel === true ? SANDBOXED_TASK : 'task',
|
|
890
|
+
opts.command,
|
|
891
|
+
opts.env,
|
|
892
|
+
)
|
|
843
893
|
return {
|
|
844
894
|
child: undefined,
|
|
845
895
|
ready: Promise.reject(
|
|
846
896
|
new PersistentReadyError(`failed to spawn persistent task: ${message}`, 'spawn'),
|
|
847
897
|
),
|
|
848
|
-
readyMs: () =>
|
|
898
|
+
readyMs: () => Math.round(performance.now() - start),
|
|
899
|
+
gaveUp: () => false,
|
|
849
900
|
}
|
|
850
901
|
} finally {
|
|
851
902
|
pipes.spawned()
|
|
@@ -860,10 +911,11 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
860
911
|
|
|
861
912
|
const markReady = (): void => {
|
|
862
913
|
// A marker printed on the way down, once the wait gave up, is not ready.
|
|
863
|
-
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
864
|
-
readyAt =
|
|
914
|
+
if (readyAt === undefined && gaveUpAt === undefined && !failedExit) {
|
|
915
|
+
readyAt = performance.now()
|
|
865
916
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
866
917
|
resolveReady()
|
|
918
|
+
opts.onReady?.()
|
|
867
919
|
}
|
|
868
920
|
}
|
|
869
921
|
|
|
@@ -884,45 +936,60 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
884
936
|
): Promise<void> => {
|
|
885
937
|
if (!stream || typeof stream === 'number') return
|
|
886
938
|
let fragment = ''
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
if (isStderr) opts.onStderr?.(
|
|
895
|
-
else opts.onStdout?.(
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}
|
|
909
|
-
fragment = fragment.slice(start)
|
|
910
|
-
if (readyLine(readyRe, fragment)) {
|
|
911
|
-
markReady()
|
|
939
|
+
// Chunks are forwarded, never retained. The one consumer of a
|
|
940
|
+
// persistent task's text is the logger, which keeps its OWN bounded
|
|
941
|
+
// tail fed from these same callbacks (registered at taskStart, so it
|
|
942
|
+
// covers the pre-ready window too) — a second copy here was read by
|
|
943
|
+
// nobody.
|
|
944
|
+
const forward = (text: string): void => {
|
|
945
|
+
if (text.length === 0) return
|
|
946
|
+
if (isStderr) opts.onStderr?.(text)
|
|
947
|
+
else opts.onStdout?.(text)
|
|
948
|
+
}
|
|
949
|
+
// Where in `chunk` the ready line ends (past its break, a CRLF's `\n`
|
|
950
|
+
// too), or -1 while not ready. The fragment holds no break, so every
|
|
951
|
+
// break found is in `chunk`.
|
|
952
|
+
const readyEnd = (re: RegExp, chunk: string): number => {
|
|
953
|
+
const base = fragment.length
|
|
954
|
+
fragment += chunk
|
|
955
|
+
let start = 0
|
|
956
|
+
for (let i = base; i < fragment.length; i++) {
|
|
957
|
+
const c = fragment.charCodeAt(i)
|
|
958
|
+
if (c !== 10 && c !== 13) continue
|
|
959
|
+
if (readyLine(re, fragment.slice(start, i))) {
|
|
912
960
|
fragment = ''
|
|
913
|
-
return
|
|
914
|
-
}
|
|
915
|
-
// Dropping complete lines assumes the stream HAS line breaks. Output
|
|
916
|
-
// with none is one endless line, so the fragment would become a
|
|
917
|
-
// third unbounded accumulator (measured 464 MiB in 6 s, and the
|
|
918
|
-
// per-chunk `test` over an ever-growing string makes it quadratic
|
|
919
|
-
// in CPU too). Keep the most RECENT window: a match may span the
|
|
920
|
-
// chunk boundary, and no readiness marker is anywhere near this
|
|
921
|
-
// long.
|
|
922
|
-
if (fragment.length > READY_MATCH_WINDOW_CHARS) {
|
|
923
|
-
fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
|
|
961
|
+
return i + 1 - base + (c === 13 && chunk.charCodeAt(i + 1 - base) === 10 ? 1 : 0)
|
|
924
962
|
}
|
|
963
|
+
start = i + 1
|
|
964
|
+
}
|
|
965
|
+
fragment = fragment.slice(start)
|
|
966
|
+
if (readyLine(re, fragment)) {
|
|
967
|
+
fragment = ''
|
|
968
|
+
return chunk.length
|
|
925
969
|
}
|
|
970
|
+
// Dropping complete lines assumes the stream HAS line breaks. Output
|
|
971
|
+
// with none is one endless line, so the fragment would become a
|
|
972
|
+
// third unbounded accumulator (measured 464 MiB in 6 s, and the
|
|
973
|
+
// per-chunk `test` over an ever-growing string makes it quadratic
|
|
974
|
+
// in CPU too). Keep the most RECENT window: a match may span the
|
|
975
|
+
// chunk boundary, and no readiness marker is anywhere near this
|
|
976
|
+
// long.
|
|
977
|
+
if (fragment.length > READY_MATCH_WINDOW_CHARS) {
|
|
978
|
+
fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
|
|
979
|
+
}
|
|
980
|
+
return -1
|
|
981
|
+
}
|
|
982
|
+
const handleChunk = (chunk: string): void => {
|
|
983
|
+
if (chunk.length === 0) return
|
|
984
|
+
if (!readyRe || readyAt !== undefined) return forward(chunk)
|
|
985
|
+
const end = readyEnd(readyRe, chunk)
|
|
986
|
+
if (end < 0) return forward(chunk)
|
|
987
|
+
// Split where the ready line ends: handed over whole, the lines after
|
|
988
|
+
// it in the same write read as pre-ready, the frame a ready server's
|
|
989
|
+
// row never prints (Vite's `Network:` line after `Local:`).
|
|
990
|
+
forward(chunk.slice(0, end))
|
|
991
|
+
markReady()
|
|
992
|
+
forward(chunk.slice(end))
|
|
926
993
|
}
|
|
927
994
|
const reader = stream.getReader()
|
|
928
995
|
const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
@@ -959,9 +1026,10 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
959
1026
|
// fires so a healthy server is never killed by a stale timer.
|
|
960
1027
|
let readyTimer: ReturnType<typeof setTimeout> | undefined
|
|
961
1028
|
if (readyRe && opts.timeoutMs !== undefined) {
|
|
1029
|
+
const limit = opts.timeoutMs
|
|
962
1030
|
const giveUp = async (): Promise<void> => {
|
|
963
1031
|
if (readyAt !== undefined) return
|
|
964
|
-
gaveUpAt =
|
|
1032
|
+
gaveUpAt = performance.now()
|
|
965
1033
|
// Listed on the group guard until the SIGKILL: the shell may die on
|
|
966
1034
|
// the SIGTERM and let the group go while the server runs out the
|
|
967
1035
|
// grace, and a vx killed inside it leaves the server to the guard
|
|
@@ -979,8 +1047,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
979
1047
|
}
|
|
980
1048
|
rejectReady(
|
|
981
1049
|
new PersistentReadyError(
|
|
982
|
-
`
|
|
983
|
-
`readyWhen pattern never matched; child killed`,
|
|
1050
|
+
`no line matched readyWhen /${readyRe.source}/ within ${asWritten(limit)}; killed`,
|
|
984
1051
|
'timeout',
|
|
985
1052
|
),
|
|
986
1053
|
)
|
|
@@ -996,8 +1063,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
996
1063
|
opts.liveChildren?.delete(child)
|
|
997
1064
|
releaseServerGroup(child)
|
|
998
1065
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
999
|
-
|
|
1000
|
-
// the
|
|
1066
|
+
if (code !== 0) failedExit = true
|
|
1067
|
+
// What the shell wrote reaches the frame before the failure line. The
|
|
1001
1068
|
// drain is bounded, as a task's is, for a grandchild holding the pipe.
|
|
1002
1069
|
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
1003
1070
|
await Promise.race([
|
|
@@ -1012,8 +1079,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
1012
1079
|
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
1013
1080
|
rejectReady(
|
|
1014
1081
|
new PersistentReadyError(
|
|
1015
|
-
`
|
|
1016
|
-
(readyRe ? `
|
|
1082
|
+
`exited (exit ${code ?? '?'})` +
|
|
1083
|
+
(readyRe ? ` before a line matched readyWhen /${readyRe.source}/` : ''),
|
|
1017
1084
|
'exited',
|
|
1018
1085
|
code ?? undefined,
|
|
1019
1086
|
),
|
|
@@ -1028,7 +1095,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
1028
1095
|
return {
|
|
1029
1096
|
child,
|
|
1030
1097
|
ready,
|
|
1031
|
-
readyMs: () => (readyAt ?? gaveUpAt ??
|
|
1098
|
+
readyMs: () => Math.round((readyAt ?? gaveUpAt ?? performance.now()) - start),
|
|
1099
|
+
gaveUp: () => gaveUpAt !== undefined,
|
|
1032
1100
|
}
|
|
1033
1101
|
}
|
|
1034
1102
|
|
|
@@ -1049,6 +1117,8 @@ export function spawnFailureText(
|
|
|
1049
1117
|
cwd: string,
|
|
1050
1118
|
what = 'task',
|
|
1051
1119
|
command?: string,
|
|
1120
|
+
/** The spawn's environment: an `E2BIG` it caused names it, not the command. */
|
|
1121
|
+
env?: NodeJS.ProcessEnv,
|
|
1052
1122
|
): string {
|
|
1053
1123
|
if (isExecutableMissing(err)) {
|
|
1054
1124
|
// Bun names `sh` in the ENOENT a missing directory gives too.
|
|
@@ -1056,28 +1126,47 @@ export function spawnFailureText(
|
|
|
1056
1126
|
? `[vx] vx runs each task with sh -c: failed to spawn 'sh' (working dir: ${cwd}). Install a POSIX sh and re-run.\n`
|
|
1057
1127
|
: `[vx] failed to spawn ${what}: its working directory ${cwd} does not exist\n`
|
|
1058
1128
|
}
|
|
1059
|
-
return `[vx] failed to spawn ${what}: ${spawnErrorReason(err, what, command)}\n`
|
|
1129
|
+
return `[vx] failed to spawn ${what}: ${spawnErrorReason(err, what, command, env)}\n`
|
|
1060
1130
|
}
|
|
1061
1131
|
|
|
1062
1132
|
/** Bun's text for a spawn that threw, and the way out when the limit hit is a known one. */
|
|
1063
|
-
function spawnErrorReason(
|
|
1133
|
+
function spawnErrorReason(
|
|
1134
|
+
err: unknown,
|
|
1135
|
+
what: string,
|
|
1136
|
+
command: string | undefined,
|
|
1137
|
+
env?: NodeJS.ProcessEnv,
|
|
1138
|
+
): string {
|
|
1064
1139
|
const message = err instanceof Error ? err.message : String(err)
|
|
1065
1140
|
const hint = isOutOfFds(err)
|
|
1066
1141
|
? ` — ${OUT_OF_FDS_HINT}`
|
|
1067
1142
|
: command !== undefined && (err as NodeJS.ErrnoException | undefined)?.code === 'E2BIG'
|
|
1068
|
-
? ` — ${argTooLongHint(command, what === SANDBOXED_TASK)}`
|
|
1143
|
+
? ` — ${argTooLongHint(command, what === SANDBOXED_TASK, env)}`
|
|
1069
1144
|
: ''
|
|
1070
1145
|
return `${message}${hint}`
|
|
1071
1146
|
}
|
|
1072
1147
|
|
|
1148
|
+
const MAX_ARG_STRLEN = 128 * 1024
|
|
1149
|
+
|
|
1073
1150
|
/**
|
|
1074
1151
|
* A task's command, the args after `--` folded in, is ONE argument to
|
|
1075
1152
|
* `sh -c`, and Linux caps one argument at 128 KiB (MAX_ARG_STRLEN), far
|
|
1076
1153
|
* below the argv total: a file list forwarded to a linter failed with a
|
|
1077
1154
|
* bare E2BIG, which reads as "too many files" and names no way out.
|
|
1078
1155
|
*/
|
|
1079
|
-
function argTooLongHint(command: string, wrapped: boolean): string {
|
|
1080
|
-
const
|
|
1156
|
+
function argTooLongHint(command: string, wrapped: boolean, env?: NodeJS.ProcessEnv): string {
|
|
1157
|
+
const bytes = Buffer.byteLength(command)
|
|
1158
|
+
const kib = Math.ceil(bytes / 1024)
|
|
1159
|
+
// The limit counts the environment too, and caps each entry as it does
|
|
1160
|
+
// one argument: a task's 4.4 MB `define` was blamed on its 1 KiB command.
|
|
1161
|
+
const vars = env === undefined ? null : envSize(env)
|
|
1162
|
+
if (vars !== null && (bytes <= MAX_ARG_STRLEN || vars.bytes > bytes)) {
|
|
1163
|
+
return (
|
|
1164
|
+
`the task environment is ${Math.ceil(vars.bytes / 1024)} KiB in ${vars.count} variables, ` +
|
|
1165
|
+
`largest ${vars.largest} at ${Math.ceil(vars.largestBytes / 1024)} KiB; Linux caps one ` +
|
|
1166
|
+
`entry at 128 KiB and the command with the environment at a quarter of the stack limit ` +
|
|
1167
|
+
`(often 2 MiB): pass large values through a file the tool reads`
|
|
1168
|
+
)
|
|
1169
|
+
}
|
|
1081
1170
|
const what = wrapped ? "with the args after -- and the sandbox's wrap" : 'with the args after --'
|
|
1082
1171
|
return (
|
|
1083
1172
|
`the command, ${what}, is ${kib} KiB, and one argument to sh -c may hold at ` +
|
|
@@ -1085,6 +1174,30 @@ function argTooLongHint(command: string, wrapped: boolean): string {
|
|
|
1085
1174
|
)
|
|
1086
1175
|
}
|
|
1087
1176
|
|
|
1177
|
+
/** What `execve` copies of an environment: each `name=value` and its NUL. */
|
|
1178
|
+
function envSize(env: NodeJS.ProcessEnv): {
|
|
1179
|
+
bytes: number
|
|
1180
|
+
count: number
|
|
1181
|
+
largest: string
|
|
1182
|
+
largestBytes: number
|
|
1183
|
+
} {
|
|
1184
|
+
let bytes = 0
|
|
1185
|
+
let count = 0
|
|
1186
|
+
let largest = ''
|
|
1187
|
+
let largestBytes = 0
|
|
1188
|
+
for (const [name, value] of Object.entries(env)) {
|
|
1189
|
+
if (value === undefined) continue
|
|
1190
|
+
const entry = Buffer.byteLength(name) + Buffer.byteLength(value) + 2
|
|
1191
|
+
bytes += entry
|
|
1192
|
+
count++
|
|
1193
|
+
if (entry > largestBytes) {
|
|
1194
|
+
largest = name
|
|
1195
|
+
largestBytes = entry
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return { bytes, count, largest, largestBytes }
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1088
1201
|
/** A stop's own sweep, for a child it missed: the polite signal, then SIGKILL past the grace. */
|
|
1089
1202
|
async function stopLate(proc: Child, signal: 'SIGINT' | 'SIGTERM'): Promise<void> {
|
|
1090
1203
|
const letGo = holdGroups([proc])
|
|
@@ -1098,7 +1211,9 @@ async function stopLate(proc: Child, signal: 'SIGINT' | 'SIGTERM'): Promise<void
|
|
|
1098
1211
|
}
|
|
1099
1212
|
|
|
1100
1213
|
export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
1101
|
-
|
|
1214
|
+
// Elapsed time, not wall time: an NTP step mid-task gave a negative or
|
|
1215
|
+
// hour-long duration (the executor check refuses a negative one).
|
|
1216
|
+
const start = performance.now()
|
|
1102
1217
|
const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
|
|
1103
1218
|
|
|
1104
1219
|
let proc: Child
|
|
@@ -1148,11 +1263,11 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1148
1263
|
)
|
|
1149
1264
|
} catch (err) {
|
|
1150
1265
|
pipes.close()
|
|
1151
|
-
const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand)
|
|
1266
|
+
const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand, opts.env)
|
|
1152
1267
|
opts.onStderr?.(stderr)
|
|
1153
1268
|
return {
|
|
1154
1269
|
exitCode: 127,
|
|
1155
|
-
durationMs:
|
|
1270
|
+
durationMs: Math.round(performance.now() - start),
|
|
1156
1271
|
stdout: '',
|
|
1157
1272
|
stderr,
|
|
1158
1273
|
spawnFailed: true,
|
|
@@ -1196,6 +1311,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1196
1311
|
const cutLine = ends.own(POST_EXIT_CUT_LINE)
|
|
1197
1312
|
if (cut) opts.onStderr?.(cutLine)
|
|
1198
1313
|
const stderr = cut ? streamed + cutLine : streamed
|
|
1314
|
+
// A leftover with its stdio sent elsewhere holds no pipe for the drain to
|
|
1315
|
+
// cut: its group, still not empty, is what says so. One kill(0) when the
|
|
1316
|
+
// group is empty, as it is after any task that waits for what it starts.
|
|
1317
|
+
const left = cut || groupAlive(proc)
|
|
1199
1318
|
opts.liveChildren?.delete(proc)
|
|
1200
1319
|
releaseGroup(proc)
|
|
1201
1320
|
const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
@@ -1210,7 +1329,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1210
1329
|
opts.onStderr?.(text)
|
|
1211
1330
|
return {
|
|
1212
1331
|
exitCode: 127,
|
|
1213
|
-
durationMs:
|
|
1332
|
+
durationMs: Math.round(performance.now() - start),
|
|
1214
1333
|
stdout,
|
|
1215
1334
|
stderr: stderr + text,
|
|
1216
1335
|
spawnFailed: true,
|
|
@@ -1221,12 +1340,13 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1221
1340
|
const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
|
|
1222
1341
|
return {
|
|
1223
1342
|
exitCode,
|
|
1224
|
-
durationMs:
|
|
1343
|
+
durationMs: Math.round(performance.now() - start),
|
|
1225
1344
|
stdout,
|
|
1226
1345
|
stderr,
|
|
1227
1346
|
...(proc.signalCode != null ? { signal: proc.signalCode } : {}),
|
|
1228
1347
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
1229
|
-
...(
|
|
1348
|
+
...(timeout.sigkilled() ? { killedAfterGrace: true } : {}),
|
|
1349
|
+
...(left ? { leftRunning: true } : {}),
|
|
1230
1350
|
...usageOf(proc, watch?.stop()),
|
|
1231
1351
|
}
|
|
1232
1352
|
}
|
|
@@ -1288,8 +1408,9 @@ function rssFloorFor(peak: number): number {
|
|
|
1288
1408
|
|
|
1289
1409
|
/**
|
|
1290
1410
|
* What a task's captured output keeps: the first `CAPTURE_HEAD_CHARS` and
|
|
1291
|
-
* the last `CAPTURE_TAIL_CHARS` characters
|
|
1292
|
-
*
|
|
1411
|
+
* the last `CAPTURE_TAIL_CHARS` characters (UTF-16 units: 8 MiB of ASCII),
|
|
1412
|
+
* the dropped middle counted in the UTF-8 bytes the task wrote and named
|
|
1413
|
+
* where it was. The live stream is never bounded — every byte still
|
|
1293
1414
|
* reaches the terminal as the task writes it — only the copy vx retains
|
|
1294
1415
|
* for the cache entry and its replay. Unbounded, a task printing 200 MB
|
|
1295
1416
|
* cost vx 620 MB of RSS on the miss AND on every hit, and its stdout sat
|
|
@@ -1328,10 +1449,22 @@ export function maskCaptured(text: string, secrets: SecretMask): string {
|
|
|
1328
1449
|
}
|
|
1329
1450
|
|
|
1330
1451
|
const DROPPED_LINE = /^\n\[vx\] [\d.]+ [KMGTP]?B of output not kept — /
|
|
1452
|
+
const SIZE = '[\\d.]+ [KMGTP]?B'
|
|
1453
|
+
const OWN_DROPPED_LINE = new RegExp(
|
|
1454
|
+
`\\n\\[vx\\] ([\\d.]+) ([KMGTP]?B) of output not kept — vx keeps the first ${SIZE} and the last ${SIZE} of a task's output\\n`,
|
|
1455
|
+
'y',
|
|
1456
|
+
)
|
|
1457
|
+
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* The dropped lines `chunks()` returned, known by the object, not the text:
|
|
1461
|
+
* a task's own chunk that read like one was copied past the mask.
|
|
1462
|
+
*/
|
|
1463
|
+
const droppedChunks = new WeakSet<CapturedChunk>()
|
|
1331
1464
|
|
|
1332
|
-
/** Whether `chunk` is the line a `BoundedCapture`
|
|
1465
|
+
/** Whether `chunk` is the line a `BoundedCapture` put where its dropped middle was (the object `chunks()` returned). */
|
|
1333
1466
|
export function isDroppedOutputChunk(chunk: CapturedChunk): boolean {
|
|
1334
|
-
return
|
|
1467
|
+
return droppedChunks.has(chunk)
|
|
1335
1468
|
}
|
|
1336
1469
|
|
|
1337
1470
|
const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
|
|
@@ -1354,7 +1487,9 @@ export class BoundedCapture {
|
|
|
1354
1487
|
private headLen = 0
|
|
1355
1488
|
private readonly tail: CapturedChunk[] = []
|
|
1356
1489
|
private tailLen = 0
|
|
1490
|
+
/** UTF-8 bytes: a count of UTF-16 units said a third of the bytes CJK text dropped. */
|
|
1357
1491
|
private dropped = 0
|
|
1492
|
+
private seen = 0
|
|
1358
1493
|
|
|
1359
1494
|
constructor(
|
|
1360
1495
|
private readonly headChars = CAPTURE_HEAD_CHARS,
|
|
@@ -1362,6 +1497,29 @@ export class BoundedCapture {
|
|
|
1362
1497
|
) {}
|
|
1363
1498
|
|
|
1364
1499
|
push(text: string, err = false): void {
|
|
1500
|
+
// A capture's own output replayed into another (a distributed miss
|
|
1501
|
+
// replays the worker's entry): its dropped line, where a capture puts
|
|
1502
|
+
// it, is counted, not kept. Kept as text, it fell into the new middle
|
|
1503
|
+
// and the entry said `98 B of output not kept` for 13 MB.
|
|
1504
|
+
const at = CAPTURE_HEAD_CHARS - this.seen
|
|
1505
|
+
this.seen += text.length
|
|
1506
|
+
if (!err && at >= 0 && at <= text.length) {
|
|
1507
|
+
// At the head's end, or a unit short of it (a surrogate pair's split).
|
|
1508
|
+
for (const i of [at, at - 1]) {
|
|
1509
|
+
if (i < 0) continue
|
|
1510
|
+
OWN_DROPPED_LINE.lastIndex = i
|
|
1511
|
+
const m = OWN_DROPPED_LINE.exec(text)
|
|
1512
|
+
if (m === null) continue
|
|
1513
|
+
this.dropped += Math.round(Number(m[1]) * 1024 ** UNITS.indexOf(m[2]!))
|
|
1514
|
+
this.add(text.slice(0, i), err)
|
|
1515
|
+
text = text.slice(i + m[0].length)
|
|
1516
|
+
break
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
this.add(text, err)
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
private add(text: string, err: boolean): void {
|
|
1365
1523
|
// Once the tail has begun the head is closed, even a unit short of its
|
|
1366
1524
|
// bound (the surrogate case below): a later short chunk fit there and
|
|
1367
1525
|
// was retained ahead of the chunk before it.
|
|
@@ -1387,12 +1545,12 @@ export class BoundedCapture {
|
|
|
1387
1545
|
if (first.text.length <= excess) {
|
|
1388
1546
|
this.tail.shift()
|
|
1389
1547
|
this.tailLen -= first.text.length
|
|
1390
|
-
this.dropped += first.text
|
|
1548
|
+
this.dropped += Buffer.byteLength(first.text)
|
|
1391
1549
|
} else {
|
|
1392
1550
|
const cut = isHighSurrogate(first.text.charCodeAt(excess - 1)) ? excess + 1 : excess
|
|
1551
|
+
this.dropped += Buffer.byteLength(first.text.slice(0, cut))
|
|
1393
1552
|
this.tail[0] = { text: first.text.slice(cut), err: first.err }
|
|
1394
1553
|
this.tailLen -= cut
|
|
1395
|
-
this.dropped += cut
|
|
1396
1554
|
}
|
|
1397
1555
|
}
|
|
1398
1556
|
}
|
|
@@ -1401,8 +1559,10 @@ export class BoundedCapture {
|
|
|
1401
1559
|
* The same output under tighter bounds. What this capture already
|
|
1402
1560
|
* dropped is counted in the new one's dropped line: pushed through
|
|
1403
1561
|
* `chunks()`, the old line fell into the new middle with its count.
|
|
1404
|
-
* `through` maps
|
|
1405
|
-
*
|
|
1562
|
+
* `through` maps the chunks before the new cut (the store's mask), so
|
|
1563
|
+
* the cut cannot split a value first: each side apart only when this
|
|
1564
|
+
* capture dropped text between them, else a value across the head's end
|
|
1565
|
+
* was matched by neither side.
|
|
1406
1566
|
*/
|
|
1407
1567
|
rebound(
|
|
1408
1568
|
headChars: number,
|
|
@@ -1410,24 +1570,21 @@ export class BoundedCapture {
|
|
|
1410
1570
|
through: (chunks: CapturedChunk[]) => CapturedChunk[] = (c) => c,
|
|
1411
1571
|
): BoundedCapture {
|
|
1412
1572
|
const to = new BoundedCapture(headChars, tailChars)
|
|
1413
|
-
|
|
1414
|
-
for (const c of through(
|
|
1573
|
+
const sides = this.dropped === 0 ? [[...this.head, ...this.tail]] : [this.head, this.tail]
|
|
1574
|
+
for (const side of sides) for (const c of through(side)) to.push(c.text, c.err)
|
|
1415
1575
|
to.dropped += this.dropped
|
|
1416
1576
|
return to
|
|
1417
1577
|
}
|
|
1418
1578
|
|
|
1419
1579
|
/** The kept chunks in arrival order, the dropped middle named on stdout. */
|
|
1420
1580
|
chunks(): CapturedChunk[] {
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
:
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
},
|
|
1429
|
-
...this.tail,
|
|
1430
|
-
]
|
|
1581
|
+
if (this.dropped === 0) return [...this.head, ...this.tail]
|
|
1582
|
+
const line = {
|
|
1583
|
+
text: droppedOutputLine(this.dropped, this.headChars, this.tailChars),
|
|
1584
|
+
err: false,
|
|
1585
|
+
}
|
|
1586
|
+
droppedChunks.add(line)
|
|
1587
|
+
return [...this.head, line, ...this.tail]
|
|
1431
1588
|
}
|
|
1432
1589
|
|
|
1433
1590
|
text(): string {
|
|
@@ -1553,3 +1710,8 @@ export function resourceUsageToCpuRss(
|
|
|
1553
1710
|
const floor = typeof floorBytes === 'number' ? floorBytes : floorBytes(peak)
|
|
1554
1711
|
return peak > floor + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
|
|
1555
1712
|
}
|
|
1713
|
+
|
|
1714
|
+
/** A limit as a config writes it: `'2s'` reads back `2s`, not `2000ms`. */
|
|
1715
|
+
function asWritten(ms: number): string {
|
|
1716
|
+
return ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`
|
|
1717
|
+
}
|