perf(ci): run the verifier's bundled-dependency installs concurrently (#15868)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Every pull request runs the `Build` check. Its last step is the
public npm install verifier, `scripts/verify-grok-npm-install.mjs`,
which stages the eighteen public packages and installs them in a clean
consumer.
> - The verifier stages the packages in one serial loop. Three packages
bundle dependencies (`@paperclipai/server`,
`@paperclipai/adapter-utils`, `@paperclipai/db`). Each one runs a
host-side `npm install` of its bundled closure in-process, one after the
other, and nothing in the loop overlaps anything else. The staging phase
took 102-116s on recent `Build` jobs.
> - `Build` at 433-530s is the slowest check in most PR runs once the
server shards drop, so its staging phase sets the wall-clock time of the
run.
> - The three installs write only their own directories and read nothing
from each other. There is no reason to run them one after the other.
> - This pull request starts the three installs together as child
processes and copies the other fifteen packages while they run. Packing
stays serial and in order.
> - The benefit is a shorter `Build` job, and a timing line that reports
each install, so the saving is measured on this PR's own run instead of
estimated.

## Linked Issues or Issue Description

Refs #15861. This branch is stacked on the branch of #15861 because the
same execution branch holds that open PR. The diff here is this PR's
three commits. After #15861 merges, this PR is retargeted to `master`
and rebased.

**What existing behavior does this improve?**

The `Build` PR check. Its public npm install verifier stages the
eighteen public packages in one serial loop.

**Subsystem affected**

CI and release scripts (`scripts/verify-grok-npm-install.mjs`,
`scripts/prepare-bundled-package.mjs`, `scripts/prefetch-child.mjs`,
`scripts/prefetch-supervisor.mjs`).

**Current behavior**

The staging loop calls `prepareBundledPackage` in-process for the three
packages that bundle dependencies. Each call runs a host-side `npm
install` of that package's bundled closure, applies the repo patches,
and rewrites the manifest, before the loop moves to the next package.
The timing line reports the whole staging phase as one number, 102-116s
on recent runs. The three installs are the only network work in that
phase.

**Proposed behavior**

Stage the three bundled packages' sources first, start their installs at
the same time as children of the script's own CLI entry (the same path
`release.sh` uses), copy the other fifteen packages while the installs
run, join the installs, and then pack every package in dependency order
as before. Report `stage_sources_ms`, `stage_bundled_ms`,
`stage_pack_ms`, `bundled_wait_ms` and one `stage_bundled_<package>_ms`
per install on the timing line.

**Reason and benefit**

The staging phase shortens by the two shorter installs. Measured on this
PR's first run (below): the three installs sum to 25.9s and take 16.4s
of wall time together, so the overlap removes about 9.5s from the phase,
and the timing line now shows that packing (35.8s) and the
standalone-package build before the loop (about 27s) are the larger
remaining costs. The original estimate of 30-50s was too high because it
assumed the installs were about half of the phase; they are a quarter.

**Breaking changes**

None. The packed tarballs are produced by the same script, in the same
order, and are checked the same way.

## What Changed

- `scripts/stage-bundled-packages.mjs` (new): `startBundledInstalls`
starts one child per bundled package through the prefetch helper from
#15849, with the child's output in a log file. `bundledInstallTimings`
reads each child's wall time for the timing line. `bundledPackageSlug`
maps a package name to its log and timing key.
- `scripts/verify-grok-npm-install.mjs`: the staging loop is split. The
bundled packages' sources are staged first and their installs start
together. The other packages are copied while the installs run. The
install handles are pushed into the `prefetches` list at once, so an
earlier failure stops any install still running before the scratch root
is removed. The join comes before the first `npm pack`. The pack loop
walks every package in `needed` order and checks each tarball as before.
`prepareBundledPackage` is no longer imported.
- Timing line: `stage_sources_ms`, `stage_bundled_ms`, `stage_pack_ms`,
`bundled_wait_ms` and `stage_bundled_server_ms`,
`stage_bundled_adapter_utils_ms`, `stage_bundled_db_ms` are added.
`stage_packages_ms` and the download keys are unchanged, so the before
and after numbers compare directly.
- `scripts/__tests__/stage-bundled-packages.test.mjs` (new): five tests
with local Node children and no network. The slug mapping; the children
run the CLI entry `release.sh` runs and never an in-process call; two
installs run at the same time, each with its own source and target, and
their logs and timing keys are correct (each child exits only after the
other has started, so serial children would fail); a failed install
rejects with its own log tail while the other is still running and is
then stopped; and stopping the installs after one fails also stops the
stand-in npm process the other wrapper is blocked on.
- `scripts/__tests__/grok-public-install-sandbox.test.mjs`: one guard
test on the verifier source. The bundled sources are staged before the
installs start, the installs are registered for cleanup before the other
packages are copied, the join comes before the pack loop, the pack loop
is in order, there is still exactly one package pack loop, and the
timing keys are reported.
- `package.json`: the new test file is in `test:release-registry`.

### Review fix (second commit): stop a prefetch's whole process tree

Greptile pointed out that `stopPrefetches` signalled only the Node
wrapper of each install, while the wrapper is blocked in
`execFileSync('npm', ['install', ...])`. After one install failed, the
other wrapper died and its npm process kept writing into the scratch
root the verifier was about to remove.

- `scripts/prefetch-child.mjs`: on POSIX every prefetch child is started
`detached`, so it leads its own process group. `stopPrefetches` signals
the group (`process.kill(-pid, ...)`) and waits until no member of the
group exists, with `SIGKILL` after the grace period and a second grace
period before it gives up so cleanup cannot hang. On Windows the child
is signalled directly as before.
- `scripts/__tests__/prefetch-child.test.mjs`: two tests. A stopped
prefetch's grandchild is gone after `stopPrefetches` returns, and a
`SIGINT` to a separate parent process reaches the grandchild while the
parent still exits with `SIGINT`. Both are skipped on Windows.
- `scripts/__tests__/stage-bundled-packages.test.mjs`: one test, the
cancellation case Greptile asked for. The stand-in
`prepare-bundled-package` blocks in `execFileSync` on a stand-in npm
that announces its pid; after the other install fails, `stopPrefetches`
must leave that pid gone.

All three new tests fail against the previous helper (`grandchild <pid>
outlived stopPrefetches`, `outlived the parent's SIGINT`, `stand-in npm
<pid> outlived stopPrefetches`) and pass with the fix.

### Review fix (third commit): the parent runs no signal handler

Greptile pointed out that the second commit forwarded the terminal's
signals to the detached groups through a JavaScript handler in the
parent. The verifier spends most of its time blocked in `execFileSync`
(the standalone builds, every `npm pack`), where no handler can run, and
installing the handler also replaced the parent's immediate default
termination. So a Ctrl-C during a synchronous phase did nothing until
that command returned, and the detached installs and downloads kept
running meanwhile. Confirmed with the new test against the second
commit's helper: the parent survived the `SIGINT`, exited with code 1
once the synchronous command had died, and its handler never ran.

- `scripts/prefetch-supervisor.mjs` (new): every prefetch command now
runs under this small Node script as the leader of its own process
group. The supervisor holds a pipe from the parent on stdin (which the
parent writes to only to request a stop where it cannot signal the
group; see the fourth commit). The kernel closes it when the parent
exits for any reason, including a signal delivered while the parent is
blocked, or `SIGKILL`, and the supervisor then stops its group
(`SIGTERM`, `SIGKILL` after the grace period) and says so in the log. It
passes group-wide signals on to the command and stays alive until the
command exits, so its own exit reports the command's exit code or
signal. Run by hand with a terminal or `/dev/null` on stdin it watches
nothing.
- `scripts/prefetch-child.mjs`: `startPrefetch` spawns the supervisor
with the command, with stdin as the pipe. The forwarding handlers are
gone; the parent installs no signal handler, so its default termination
stays immediate. `stopPrefetches` is unchanged.
- `scripts/__tests__/prefetch-child.test.mjs`: the first test asserts
the parent has no `SIGINT`/`SIGTERM`/`SIGHUP` listener after a prefetch
starts. The "signal to the parent" test now runs for `SIGINT` and
`SIGKILL` and checks the supervisor's log line. One new test gives a
separate parent its own process group, blocks it in `execFileSync` on a
20s command, and sends `SIGINT` to that group the way a terminal sends
Ctrl-C: the parent must die of `SIGINT` within two seconds and the
prefetch's grandchild must be gone. Against the previous helper the
parent exits with code 1 instead, the grandchild outlives `SIGKILL`, and
the handler-count assertion fails.

### Review fix (fourth commit): a deliberate stop on Windows goes
through the pipe

Greptile pointed out that on Windows, where `startPrefetch` now returns
the supervisor, `stopPrefetches` called `child.kill('SIGTERM')` on it.
Windows has no process groups and `kill` there ends a process outright,
so the supervisor died without running its handler and its command kept
running after cleanup returned.

- `scripts/prefetch-child.mjs`: without process groups, the graceful
stop no longer signals the supervisor. `stopPrefetches` writes a stop
request to the pipe the supervisor already watches for the parent's exit
(`stdin.end('stop\n')`). Only the `SIGKILL` escalation targets the
supervisor itself, after it has had its grace period. POSIX keeps the
group signal, so `stopPrefetches` and the existing stop and failure
tests are unchanged there.
- `scripts/prefetch-supervisor.mjs`: data on stdin is a stop request and
EOF is the parent's exit; both end the group the same way (`SIGTERM`,
`SIGKILL` after the grace period) and the log line names which one it
was.
- `scripts/__tests__/prefetch-child.test.mjs`: one new test writes the
stop request to a running prefetch's pipe instead of calling
`stopPrefetches`. The supervisor must exit with `SIGTERM`, the command's
grandchild must be gone, and the log must say `stop requested` and not
`parent exited`. The previous supervisor fails it on the log line, since
it treated the request as the parent's exit. The Windows kill itself
cannot be reproduced on the Linux runners, so the Windows branch of
`signalTree` is verified by that supervisor test plus reading its two
lines. The stand-in that ignores `SIGTERM` is now skipped on Windows,
where `kill` cannot be ignored.

## Verification

- `node --test scripts/__tests__/stage-bundled-packages.test.mjs
scripts/__tests__/grok-public-install-sandbox.test.mjs
scripts/__tests__/prefetch-child.test.mjs`: 30 tests pass (was 19 before
this PR, 24 after the first commit, 27 after the second, 29 after the
third). The three files pass three consecutive runs.
- `node --check` passes on all four scripts.
- The verifier itself needs Docker and a network, which this workspace
does not have. This PR's own `Build` job runs the branch's verifier.
Measured on all four commits' runs
([38132868803](https://github.com/paperclipai/paperclip/actions/runs/38132868803):
`Build` 428s, `pnpm build` 112s, verifier step 214s;
[38136912842](https://github.com/paperclipai/paperclip/actions/runs/38136912842):
`Build` 447s;
[38139871529](https://github.com/paperclipai/paperclip/actions/runs/38139871529):
`Build` 432s;
[38142762538](https://github.com/paperclipai/paperclip/actions/runs/38142762538):
`Build` 457s, `pnpm build` 123s, verifier step 231s):

| Key | Run 1 | Run 2 | Run 3 | Run 4 | Before |
| --- | --- | --- | --- | --- | --- |
| `stage_packages_ms` | 80.4s | 84.9s | 81.5s | 89.6s | 102-116s on
master runs 38112764439, 38111191543, 38110932348, 38110914319,
38110897745, 38127611057; 84.5s on #15849's run on a runner as fast as
these |
| `stage_sources_ms` | 1.4s | 0.7s | 0.9s | 0.9s | (inside the number
above) |
| `stage_bundled_server_ms` / `adapter_utils` / `db` | 16.3s / 5.0s /
4.6s | 14.9s / 3.6s / 3.3s | 16.4s / 5.2s / 4.7s | 17.1s / 4.4s / 3.8s |
ran one after another, 21.8-25.9s together |
| `stage_bundled_ms` (wall, all three) | 16.4s | 15.0s | 16.5s | 17.2s |
21.8-25.9s |
| `bundled_wait_ms` | 15.3s | 14.7s | 16.1s | 16.7s | — |
| `stage_pack_ms` | 35.8s | 37.1s | 35.6s | 37.9s | (inside the number
above) |
| `consumer_install_ms` | 94.4s | 97.8s | 95.7s | 99.8s | 97-134s |
| `image_prefetch_ms` / `image_wait_ms` | 33.7s / 0ms | 31.8s / 0ms |
34.7s / 0ms | 33.7s / 0ms | unchanged |
| `grok_prefetch_ms` / `grok_wait_ms` | 2.1s / 0ms | 2.1s / 0ms | 2.0s /
0ms | 2.1s / 0ms | unchanged |

The overlap removed about 7-10s from the staging phase on each of the
four runs (21.8-25.9s of installs in 15.0-17.2s of wall time). The third
and fourth commits changed no timing: both waits stay at 0ms and the
staging phase sits inside the band of the first two runs. The rest of
the phase is the standalone-package build and the server UI dist prep
before the loop (about 27-32s) and the eighteen serial `npm pack` plus
`tar -tzf` checks (36-37s).

- `docker-runner-check.yml` runs the same script after `pnpm build` and
needs no change.

## Risks

- Three `npm install` processes share the host npm cache at the same
time. npm's cache (`cacache`) is designed for concurrent writers, and
the three installs have disjoint target directories. If a shared-cache
race appears, the failing install's log tail is in the error and the fix
is to serialize again. The first CI run showed none.
- The install output moves from the CI log into per-install log files in
the scratch root. A failure reports the last 2000 characters of the
failing install's log; a successful install's output is discarded with
the scratch root.
- Memory: three concurrent installs on the `Build` runner instead of
one. The closures are small (the server's eight bundled packages are the
largest), so this stays well under the runner's memory.
- Process groups: a detached child no longer receives the terminal's
signals on its own. The supervisor stops its group when the parent is
gone, which it learns from the kernel closing the stdin pipe, so this
holds for a Ctrl-C while the parent is blocked in synchronous work and
for `SIGKILL` alike. The parent keeps Node's default signal handling. If
the supervisor itself is killed with `SIGKILL` before its command, the
command is orphaned and finishes on its own (each install ends within
seconds; the image pull within a minute), the same as any orphaned
grandchild before this PR.
- One extra Node process per prefetch (five per verifier run). Each
costs about 40ms to start, inside the time the parent spends staging
anyway.
- Low risk otherwise. Packing, the tarball checks, the consumer install,
and every probe are unchanged.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Fable 5.1 (Anthropic), model ID `claude-fable-5-1`, run as a
Paperclip agent through the Claude Code local adapter with tool use
(shell, file edits, GitHub CLI). Reasoning mode: extended thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green on all four commits
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(on the fourth commit Greptile reviewed 8 files and added no comments;
all three earlier threads are resolved)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Bender (Fable) <bender-fable@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 11:19:44 -07:00
1 parent 85f4205de3
commit 026bdcd046
8 files changed
+448 -26

No files matched your search

+1 -1
View File
@@ -61,7 +61,7 @@
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs", "smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh", "smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs", "test:release-registry": "node --test scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs", "storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts", "test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack", "test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
@@ -110,6 +110,31 @@ test('the image and Grok downloads start before staging and join without weakeni
assert.equal(source.match(/rmSync\(root/g).length, 1); assert.equal(source.match(/rmSync\(root/g).length, 1);
}); });
test('the bundled-dependency installs run together while the other packages are copied, and pack in order', () => {
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
const position = fragment => { const index = source.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; };
// The installs are children of the script's own CLI entry through the helper
// covered by stage-bundled-packages.test.mjs, never in-process calls.
assert.doesNotMatch(source, /prepareBundledPackage/);
assert.ok(source.includes("import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs'"));
// Only the bundled packages' sources are staged before the installs start;
// the installs are registered for cleanup at once, before anything else can
// fail; the other packages are copied while the installs run; the join comes
// before the first pack; and the pack loop walks every package in order.
const bundledSources = position('for (const entry of entries.filter(entry => entry.bundled)) stageSource(entry)');
const start = position("startBundledInstalls(entries.filter(entry => entry.bundled), { logDir: prefetched, cwd: repo })");
const registered = position('prefetches.push(...installs.map(({ handle }) => handle))');
const otherSources = position('for (const entry of entries.filter(entry => !entry.bundled))');
const join = position("await awaited('bundled', Promise.all(installs.map(({ handle }) => handle.done)))");
const packLoop = position('for (const { target } of entries)');
const firstPack = position("run('npm', ['pack', '--ignore-scripts', '--pack-destination', root], target)");
assert.ok(bundledSources < start && start < registered && registered < otherSources && otherSources < join && join < packLoop && packLoop < firstPack);
assert.equal(source.match(/run\('npm', \['pack'/g).length, 2, 'one package pack loop plus the lifecycle sentinel');
// The timing line reports the split staging phase and each install.
for (const key of ['stage_sources_ms', 'stage_bundled_ms', 'stage_pack_ms']) assert.ok(source.includes(`timing.${key} =`));
assert.ok(source.includes('...bundledInstallTimings(installs)'));
});
test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => { test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'installed-codex-probe-'))); const root = realpathSync(mkdtempSync(join(tmpdir(), 'installed-codex-probe-')));
try { try {
+95 -2
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -25,6 +25,8 @@ const untilLogHas = async (path, text) => {
test('a successful prefetch resolves, keeps its output, and is left alone by stop', async () => withScratch(async root => { test('a successful prefetch resolves, keeps its output, and is left alone by stop', async () => withScratch(async root => {
const handle = start(root, 'ok', "setTimeout(() => process.stdout.write('fetched\\n'), 150)"); const handle = start(root, 'ok', "setTimeout(() => process.stdout.write('fetched\\n'), 150)");
// The parent keeps its default, immediate termination: no handler of ours.
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) assert.equal(process.listenerCount(signal), 0, `${signal} handler installed in the parent`);
await handle.done; await handle.done;
assert.equal(handle.child.exitCode, 0); assert.equal(handle.child.exitCode, 0);
assert.equal(readFileSync(join(root, 'ok.log'), 'utf8'), 'fetched\n'); assert.equal(readFileSync(join(root, 'ok.log'), 'utf8'), 'fetched\n');
@@ -66,7 +68,7 @@ test('stopping a running prefetch waits for it to exit so its directory can be r
await assert.rejects(handle.done, /slow prefetch failed \(SIGTERM\): started/); await assert.rejects(handle.done, /slow prefetch failed \(SIGTERM\): started/);
})); }));
test('a prefetch that ignores SIGTERM is killed after the grace period', async () => withScratch(async root => { test('a prefetch that ignores SIGTERM is killed after the grace period', { skip: process.platform === 'win32' && 'SIGTERM cannot be ignored on Windows' }, async () => withScratch(async root => {
const handle = start(root, 'stubborn', "process.on('SIGTERM', () => {}); process.stdout.write('ignoring\\n'); setInterval(() => {}, 1000)"); const handle = start(root, 'stubborn', "process.on('SIGTERM', () => {}); process.stdout.write('ignoring\\n'); setInterval(() => {}, 1000)");
await untilLogHas(join(root, 'stubborn.log'), 'ignoring'); await untilLogHas(join(root, 'stubborn.log'), 'ignoring');
const stopping = Date.now(); const stopping = Date.now();
@@ -75,6 +77,97 @@ test('a prefetch that ignores SIGTERM is killed after the grace period', async (
assert.equal(handle.child.signalCode, 'SIGKILL'); assert.equal(handle.child.signalCode, 'SIGKILL');
})); }));
// A process is gone once the kernel no longer knows it, or once it is a zombie
// that only waits to be reaped: either way it can no longer write anywhere.
const gone = pid => {
try { process.kill(pid, 0); } catch (error) { return error.code === 'ESRCH'; }
try { return /^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return false; }
};
const untilGone = async pid => {
for (let attempt = 0; attempt < 200; attempt++) {
if (gone(pid)) return true;
await new Promise(resolve => setTimeout(resolve, 25));
}
return false;
};
// The child starts a process of its own that announces its pid and then runs
// on, the way the bundled-dependency installs block in `npm install`.
const GRANDCHILD_SCRIPT = `
const { spawn } = require('node:child_process');
const grandchild = spawn(process.execPath, ['-e', "process.stdout.write('grandchild ' + process.pid + '\\\\n'); setInterval(() => {}, 1000)"], { stdio: 'inherit' });
grandchild.on('exit', () => process.exit(0));
`;
const grandchildPid = async log => { await untilLogHas(log, 'grandchild '); return Number(readFileSync(log, 'utf8').match(/grandchild (\d+)/)[1]); };
test('stopping a prefetch also stops the processes it started', { skip: process.platform === 'win32' && 'process groups are POSIX only' }, async () => withScratch(async root => {
const handle = start(root, 'tree', GRANDCHILD_SCRIPT);
const pid = await grandchildPid(join(root, 'tree.log'));
assert.ok(!gone(pid), 'the grandchild runs before the stop');
await stopPrefetches([handle]);
assert.ok(await untilGone(pid), `grandchild ${pid} outlived stopPrefetches`);
assert.notEqual(handle.child.signalCode ?? handle.child.exitCode, null);
}));
test('a stop request written to the pipe ends the command and the processes it started, with no signal to the supervisor', { skip: process.platform === 'win32' && 'the grandchild check reads /proc' }, async () => withScratch(async root => {
// This is the stop path on a platform without process groups: there `kill`
// would end the supervisor outright and leave its command running, so
// `stopPrefetches` writes a request instead. The supervisor answers it
// the way it answers the parent's exit, and its own exit still reports the
// command's fate.
const handle = start(root, 'tree', GRANDCHILD_SCRIPT);
const pid = await grandchildPid(join(root, 'tree.log'));
handle.child.stdin.end('stop\n');
await assert.rejects(handle.done, /tree prefetch failed \(SIGTERM\)/);
assert.ok(await untilGone(pid), `grandchild ${pid} outlived the stop request`);
assert.match(readFileSync(join(root, 'tree.log'), 'utf8'), /stop requested; stopping/);
assert.doesNotMatch(readFileSync(join(root, 'tree.log'), 'utf8'), /parent exited/);
await stopPrefetches([handle]);
}));
// A separate parent process stands in for the verifier: it starts a prefetch
// whose child starts a grandchild, then does `work` (a module-scope statement).
const parentScript = (root, work) => `
import { execFileSync } from 'node:child_process';
import { startPrefetch } from ${JSON.stringify(new URL('../prefetch-child.mjs', import.meta.url).href)};
startPrefetch({ name: 'tree', cmd: process.execPath, args: ['-e', ${JSON.stringify(GRANDCHILD_SCRIPT)}], cwd: ${JSON.stringify(root)}, env: process.env, logPath: ${JSON.stringify(join(root, 'tree.log'))} });
${work}
`;
for (const signal of ['SIGINT', 'SIGKILL']) {
test(`${signal} to the parent ends the processes a prefetch started, and the parent dies of it at once`, { skip: process.platform === 'win32' && 'process groups are POSIX only' }, async () => withScratch(async root => {
// The parent runs no handler, so it dies of the signal immediately, and the
// prefetch tree stops because the parent is gone, not because it was told:
// SIGKILL leaves the parent no chance to tell anyone anything.
const parent = spawn(process.execPath, ['--input-type=module', '-e', parentScript(root, 'setInterval(() => {}, 1000);')], { cwd: root, stdio: 'ignore' });
const exited = new Promise(resolve => parent.once('exit', (code, signal) => resolve({ code, signal })));
const pid = await grandchildPid(join(root, 'tree.log'));
parent.kill(signal);
assert.deepEqual(await exited, { code: null, signal });
assert.ok(await untilGone(pid), `grandchild ${pid} outlived the parent's ${signal}`);
assert.match(readFileSync(join(root, 'tree.log'), 'utf8'), /parent exited; stopping/);
}));
}
test('Ctrl-C while the parent is blocked in synchronous work stops it at once, and the prefetch tree with it', { skip: process.platform === 'win32' && 'process groups are POSIX only' }, async () => withScratch(async root => {
// The verifier runs `execFileSync` for the standalone builds while prefetches
// run, and a JavaScript signal handler cannot run until that returns. The
// parent gets its own group here so the signal can be sent the way a
// terminal sends Ctrl-C: to the foreground group, which is the parent and
// its synchronous command, and not the detached prefetch tree.
const parent = spawn(process.execPath, ['--input-type=module', '-e', parentScript(root, "execFileSync(process.execPath, ['-e', 'setTimeout(() => {}, 20000)'], { stdio: 'ignore' });")], { cwd: root, stdio: 'ignore', detached: true });
try {
const exited = new Promise(resolve => parent.once('exit', (code, signal) => resolve({ code, signal })));
const pid = await grandchildPid(join(root, 'tree.log'));
const signalled = Date.now();
process.kill(-parent.pid, 'SIGINT');
assert.deepEqual(await exited, { code: null, signal: 'SIGINT' });
assert.ok(Date.now() - signalled < 2000, `the parent took ${Date.now() - signalled}ms to die: it waited for the synchronous command`);
assert.ok(await untilGone(pid), `grandchild ${pid} outlived the parent's Ctrl-C`);
} finally {
try { process.kill(-parent.pid, 'SIGKILL'); } catch {}
}
}));
test('an unawaited failure never becomes an unhandled rejection', () => { test('an unawaited failure never becomes an unhandled rejection', () => {
const script = ` const script = `
import { startPrefetch } from ${JSON.stringify(new URL('../prefetch-child.mjs', import.meta.url).href)}; import { startPrefetch } from ${JSON.stringify(new URL('../prefetch-child.mjs', import.meta.url).href)};
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { stopPrefetches } from '../prefetch-child.mjs';
import { PREPARE_BUNDLED_PACKAGE_SCRIPT, bundledInstallTimings, bundledPackageSlug, startBundledInstalls } from '../stage-bundled-packages.mjs';
// A small local script stands in for `prepare-bundled-package.mjs`; no test
// here needs npm or a network.
const withScratch = async body => {
const root = mkdtempSync(join(tmpdir(), 'stage-bundled-packages-'));
try { await body(root); } finally { rmSync(root, { recursive: true, force: true }); }
};
const entriesFor = (root, names) => names.map((name, index) => ({ name, stagedSource: join(root, `source-${index}`), target: join(root, `package-${index}`) }));
const fakeScript = (root, body) => { const script = join(root, 'fake-prepare.mjs'); writeFileSync(script, body); return script; };
test('package names map to stable log and timing slugs', () => {
assert.equal(bundledPackageSlug('@paperclipai/server'), 'server');
assert.equal(bundledPackageSlug('@paperclipai/adapter-utils'), 'adapter_utils');
assert.equal(bundledPackageSlug('@paperclipai/db'), 'db');
});
test('the children run the CLI entry release.sh uses, never an in-process call', () => {
assert.ok(existsSync(PREPARE_BUNDLED_PACKAGE_SCRIPT));
const source = readFileSync(PREPARE_BUNDLED_PACKAGE_SCRIPT, 'utf8');
assert.ok(source.includes('if (process.argv[1] === fileURLToPath(import.meta.url))'));
assert.ok(source.includes('prepareBundledPackage(resolve(sourceDir), resolve(destinationDir))'));
const release = readFileSync(new URL('../release.sh', import.meta.url), 'utf8');
assert.match(release, /node "\$REPO_ROOT\/scripts\/prepare-bundled-package\.mjs" "\$REPO_ROOT\/\$pkg_dir" "\$publish_dir"/);
});
test('the installs run at the same time, each with its own source and target', async () => withScratch(async root => {
// Each child records its arguments, announces itself, and exits only once
// the other child has announced itself too. Serial children would deadlock
// here and the five-second guard would turn that into a failure.
const script = fakeScript(root, `
import { existsSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
const [source, target] = process.argv.slice(2);
const root = join(target, '..');
writeFileSync(join(root, 'started-' + basename(target)), '');
console.log('install ' + source + ' -> ' + target);
const other = basename(target) === 'package-0' ? 'package-1' : 'package-0';
const deadline = Date.now() + 5000;
while (!existsSync(join(root, 'started-' + other))) {
if (Date.now() > deadline) { console.error('the other install never started'); process.exit(3); }
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
}
writeFileSync(join(target, 'package.json'), '{}');
`);
const entries = entriesFor(root, ['@paperclipai/server', '@paperclipai/db']);
for (const { target } of entries) mkdirSync(target);
const installs = startBundledInstalls(entries, { logDir: root, cwd: root, script });
assert.deepEqual(installs.map(({ slug }) => slug), ['server', 'db']);
await Promise.all(installs.map(({ handle }) => handle.done));
for (const [index, { stagedSource, target, slug, handle }] of installs.entries()) {
assert.equal(handle.child.exitCode, 0);
assert.equal(readFileSync(join(root, `bundled-${slug}.log`), 'utf8'), `install ${stagedSource} -> ${target}\n`);
assert.equal(target, join(root, `package-${index}`));
assert.ok(existsSync(join(target, 'package.json')));
}
const timings = bundledInstallTimings(installs);
assert.deepEqual(Object.keys(timings), ['stage_bundled_server_ms', 'stage_bundled_db_ms']);
for (const value of Object.values(timings)) assert.ok(Number.isInteger(value) && value >= 0);
}));
test('stopping the installs after one fails also stops the npm process the other started', { skip: process.platform === 'win32' && 'process groups are POSIX only' }, async () => withScratch(async root => {
// The real wrapper blocks in `execFileSync('npm', ['install', ...])`; here
// the stand-in npm announces its pid and runs on, and the wrapper blocks on
// it the same way. Stopping only the wrapper would leave it writing.
const script = fakeScript(root, `
import { execFileSync } from 'node:child_process';
const [, target] = process.argv.slice(2);
if (target.endsWith('package-1')) { console.error('bundled install exploded'); process.exit(2); }
execFileSync(process.execPath, ['-e', "process.stdout.write('npm ' + process.pid + '\\\\n'); setInterval(() => {}, 1000)"], { stdio: 'inherit' });
`);
const installs = startBundledInstalls(entriesFor(root, ['@paperclipai/server', '@paperclipai/db']), { logDir: root, cwd: root, script });
await assert.rejects(Promise.all(installs.map(({ handle }) => handle.done)), /bundled-db prefetch failed \(exit 2\)/);
const log = join(root, 'bundled-server.log');
const logHasPid = () => existsSync(log) && /npm \d+/.test(readFileSync(log, 'utf8'));
for (let attempt = 0; attempt < 200 && !logHasPid(); attempt++) await new Promise(resolve => setTimeout(resolve, 25));
const npmPid = Number(readFileSync(log, 'utf8').match(/npm (\d+)/)[1]);
const alive = pid => { try { process.kill(pid, 0); return !/^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch (error) { return error.code !== 'ESRCH'; } };
assert.ok(alive(npmPid), 'the stand-in npm runs before the stop');
await stopPrefetches(installs.map(({ handle }) => handle));
let stopped = false;
for (let attempt = 0; attempt < 200 && !(stopped = !alive(npmPid)); attempt++) await new Promise(resolve => setTimeout(resolve, 25));
assert.ok(stopped, `stand-in npm ${npmPid} outlived stopPrefetches`);
assert.notEqual(installs[0].handle.child.signalCode ?? installs[0].handle.child.exitCode, null);
}));
test('a failed install rejects with the tail of its own log and leaves the others to be stopped', async () => withScratch(async root => {
const script = fakeScript(root, `
const [, target] = process.argv.slice(2);
if (target.endsWith('package-1')) { console.error('bundled install exploded'); process.exit(2); }
setTimeout(() => {}, 30_000);
`);
const installs = startBundledInstalls(entriesFor(root, ['@paperclipai/server', '@paperclipai/adapter-utils']), { logDir: root, cwd: root, script });
await assert.rejects(Promise.all(installs.map(({ handle }) => handle.done)), /bundled-adapter_utils prefetch failed \(exit 2\): bundled install exploded/);
assert.equal(installs[0].handle.child.exitCode, null, 'the other install is still running when the failure surfaces');
await stopPrefetches(installs.map(({ handle }) => handle));
assert.notEqual(installs[0].handle.child.signalCode ?? installs[0].handle.child.exitCode, null);
}));
+68 -10
View File
@@ -2,23 +2,72 @@
// output in a log file, join it where the result is needed, and stop it before // output in a log file, join it where the result is needed, and stop it before
// the scratch directory it writes into is removed. No shell, no inherited // the scratch directory it writes into is removed. No shell, no inherited
// terminal, and a failure is reported where the caller awaits it. // terminal, and a failure is reported where the caller awaits it.
//
// A child may start processes of its own: the bundled-dependency installs are
// a Node wrapper blocked in `npm install`. Signalling only the wrapper would
// leave npm writing into the scratch root while it is removed, so on POSIX
// each command runs under `prefetch-supervisor.mjs` as the leader of its own
// process group, and stopping signals and waits for the whole group. A group
// of its own no longer receives the terminal's Ctrl-C, and the parent cannot
// forward it: the verifier spends most of its time blocked in `execFileSync`,
// where no JavaScript signal handler can run, and installing one would also
// replace the parent's immediate default termination. Instead the supervisor
// holds a pipe from the parent and stops its group when the pipe closes, which
// the kernel does when the parent exits for any reason. The parent installs
// no signal handlers.
//
// Windows has no process groups and `kill` there ends a process outright, so a
// signalled supervisor could pass nothing on and its command would run on
// after cleanup returned. There a deliberate stop goes through the same pipe
// as a written request, which the supervisor answers by ending its command,
// and only the SIGKILL escalation targets the supervisor itself.
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { closeSync, openSync, readFileSync, statSync } from 'node:fs'; import { closeSync, openSync, readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
export const PREFETCH_SUPERVISOR_SCRIPT = fileURLToPath(new URL('./prefetch-supervisor.mjs', import.meta.url));
const GROUPS = process.platform !== 'win32';
const running = child => child.pid !== undefined && child.exitCode === null && child.signalCode === null; const running = child => child.pid !== undefined && child.exitCode === null && child.signalCode === null;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Returns { name, child, done, durationMs }. `done` resolves on exit code 0 // Signals the child's whole process group (POSIX). Without groups the
// and otherwise rejects with the tail of the log; the rejection is observed // supervisor is asked to stop its command through the pipe it watches, since
// here so an unawaited failure never surfaces as an unhandled rejection. // `kill` would end the supervisor at once and leave the command running; only
// SIGKILL is sent to the supervisor itself, after it has had its chance.
const signalTree = (child, signal) => {
if (child.pid === undefined) return;
if (!GROUPS) {
if (!running(child)) return;
if (signal === 'SIGKILL') child.kill(signal);
else if (child.stdin?.writable) child.stdin.end('stop\n');
return;
}
try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== 'ESRCH') throw error; }
};
// True while any member of the child's group exists. On a platform without
// groups only the child itself is known.
const treeAlive = child => {
if (child.pid === undefined) return false;
if (!GROUPS) return running(child);
try { process.kill(-child.pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
};
// Returns { name, child, done, durationMs }. `child` is the supervisor, whose
// exit code or signal is the command's. `done` resolves on exit code 0 and
// otherwise rejects with the tail of the log; the rejection is observed here
// so an unawaited failure never surfaces as an unhandled rejection.
export function startPrefetch({ name, cmd, args, cwd, env, logPath }) { export function startPrefetch({ name, cmd, args, cwd, env, logPath }) {
const startedAt = Date.now(); const startedAt = Date.now();
const log = openSync(logPath, 'w'); const log = openSync(logPath, 'w');
let child; let child;
try { try {
child = spawn(cmd, args, { cwd, env, stdio: ['ignore', log, log] }); // stdin is the pipe the supervisor watches; nothing is ever written to it.
child = spawn(process.execPath, [PREFETCH_SUPERVISOR_SCRIPT, cmd, ...args], { cwd, env, stdio: ['pipe', log, log], detached: GROUPS });
} finally { } finally {
closeSync(log); // the child holds its own descriptor closeSync(log); // the child holds its own descriptor
} }
child.stdin?.on('error', () => {});
const handle = { name, child, done: null, durationMs: null }; const handle = { name, child, done: null, durationMs: null };
let settled = false; let settled = false;
handle.done = new Promise((settle, reject) => { handle.done = new Promise((settle, reject) => {
@@ -48,13 +97,22 @@ export function startPrefetch({ name, cmd, args, cwd, env, logPath }) {
return handle; return handle;
} }
// Stops every prefetch that is still running and resolves only once all of // Stops every prefetch that is still running, together with any process it
// them have exited, so the caller can remove their working directory without // started, and resolves only once all of them have exited, so the caller can
// racing a write. A child that ignores SIGTERM is killed after `graceMs`. // remove their working directory without racing a write. A tree that ignores
// SIGTERM is killed after `graceMs`; one that still exists `graceMs` after
// that is given up on so cleanup cannot hang.
export async function stopPrefetches(prefetches, { graceMs = 5000 } = {}) { export async function stopPrefetches(prefetches, { graceMs = 5000 } = {}) {
await Promise.all(prefetches.map(async ({ child, done }) => { await Promise.all(prefetches.map(async ({ child, done }) => {
if (running(child)) child.kill('SIGTERM'); const started = Date.now();
const escalate = setTimeout(() => { if (running(child)) child.kill('SIGKILL'); }, graceMs); let termed = false, killed = false;
try { await done; } catch {} finally { clearTimeout(escalate); } while (treeAlive(child)) {
const elapsed = Date.now() - started;
if (!termed) { signalTree(child, 'SIGTERM'); termed = true; }
else if (!killed && elapsed >= graceMs) { signalTree(child, 'SIGKILL'); killed = true; }
else if (killed && elapsed >= 2 * graceMs) break;
await sleep(25);
}
try { await done; } catch {}
})); }));
} }
+78
View File
@@ -0,0 +1,78 @@
// Child side of prefetch-child.mjs: runs one prefetch command as the leader of
// its own process group and stops the whole group when the parent that started
// it is gone.
//
// The parent holds the write end of this process's stdin. The kernel closes
// that end when the parent exits for any reason: a Ctrl-C delivered while the
// parent is blocked in synchronous work, a plain `kill`, or SIGKILL. Reading
// EOF here is therefore the only parent-death signal needed, and the parent
// runs no signal handler of its own, so its default termination stays
// immediate. The parent writes to the pipe for one reason only: to ask for a
// deliberate stop where it cannot signal the group, which is Windows, where
// `kill` ends a process outright and a signalled supervisor could not pass
// anything on. Group-wide signals the parent sends through `stopPrefetches`
// on POSIX reach this process too; it passes them on to the command and stays
// alive until the command exits, so its own exit reports the command's
// outcome (exit code, or the same signal).
import { spawn } from 'node:child_process';
import { fstatSync } from 'node:fs';
import { constants } from 'node:os';
const GROUPS = process.platform !== 'win32';
const GRACE_MS = Number(process.env.PAPERCLIP_PREFETCH_GRACE_MS) || 5000;
const [cmd, ...args] = process.argv.slice(2);
if (!cmd) {
process.stderr.write('prefetch supervisor: usage: prefetch-supervisor.mjs <command> [args...]\n');
process.exit(64);
}
const child = spawn(cmd, args, { stdio: ['ignore', 'inherit', 'inherit'] });
const signalChild = signal => { try { child.kill(signal); } catch {} };
// On POSIX this process leads the group (the parent starts it detached), so
// the group id is its own pid. Without groups only the command is known.
const signalGroup = signal => {
if (!GROUPS) return signalChild(signal);
try { process.kill(-process.pid, signal); } catch (error) { if (error.code === 'ESRCH') signalChild(signal); }
};
// A signal to the group arrives here as well as at the command; passing it on
// is harmless when the command already has it, and covers a signal sent to
// this process alone. The parent owns the SIGKILL escalation in that case.
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => signalChild(signal));
let stopping = false;
const stopGroup = reason => {
if (stopping) return;
stopping = true;
process.stderr.write(`prefetch supervisor: ${reason}; stopping ${cmd}\n`);
signalGroup('SIGTERM');
setTimeout(() => signalGroup('SIGKILL'), GRACE_MS).unref();
};
// Only a pipe can carry a stop request or report the parent's exit (Node
// creates its stdio pipes as socket pairs). Run by hand with a terminal or
// /dev/null on stdin, there is no parent to watch and nothing is read.
let stdinIsPipe = false;
try { const stdin = fstatSync(0); stdinIsPipe = stdin.isFIFO() || stdin.isSocket(); } catch {}
if (stdinIsPipe) {
process.stdin.on('data', () => stopGroup('stop requested'));
const parentGone = () => stopGroup('parent exited');
process.stdin.on('end', parentGone);
process.stdin.on('close', parentGone);
process.stdin.on('error', parentGone);
process.stdin.resume();
}
child.on('error', error => {
process.stderr.write(`prefetch supervisor: ${error.message}\n`);
process.exit(127);
});
child.on('exit', (code, signal) => {
if (signal !== null) {
// Die of the same signal, so the parent sees the command's fate on this
// process. Dropping the listener restores the default disposition first.
process.removeAllListeners(signal);
try { process.kill(process.pid, signal); } catch {}
process.exit(128 + (constants.signals[signal] ?? 1));
}
process.exit(code ?? 1);
});
+32
View File
@@ -0,0 +1,32 @@
// The public-install verifier stages three packages that bundle dependencies
// (`@paperclipai/server`, `@paperclipai/adapter-utils`, `@paperclipai/db`).
// Each one is a host-side `npm install` of that package's bundled closure into
// its own pack directory, and none reads what another produces, so they run as
// concurrent children instead of one after another inside the staging loop.
// Each child is the CLI entry of `prepare-bundled-package.mjs`, the same path
// release.sh runs, with its output in a log file that a failure reports.
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startPrefetch } from './prefetch-child.mjs';
export const PREPARE_BUNDLED_PACKAGE_SCRIPT = fileURLToPath(new URL('./prepare-bundled-package.mjs', import.meta.url));
// `@paperclipai/adapter-utils` -> `adapter_utils`, for log names and timing keys.
export const bundledPackageSlug = name => name.replace(/^@paperclipai\//, '').replace(/[^a-z0-9]+/gi, '_');
// Starts one child per entry (`{ name, stagedSource, target }`) at once and
// returns the entries with their `slug` and prefetch `handle`. The caller joins
// `handle.done` where the packed output is needed and registers the handles
// for cleanup, so an earlier failure stops every install still running.
export function startBundledInstalls(entries, { logDir, cwd, env = process.env, script = PREPARE_BUNDLED_PACKAGE_SCRIPT }) {
return entries.map(entry => {
const slug = bundledPackageSlug(entry.name);
const handle = startPrefetch({ name: `bundled-${slug}`, cmd: process.execPath, args: [script, entry.stagedSource, entry.target], cwd, env, logPath: join(logDir, `bundled-${slug}.log`) });
return { ...entry, slug, handle };
});
}
// Per-package wall time for the timing line. The helper measures from the
// start to the child's last output; the parent is idle while it waits for the
// installs, so the exit is observed as it happens.
export const bundledInstallTimings = installs => Object.fromEntries(installs.map(({ slug, handle }) => [`stage_bundled_${slug}_ms`, handle.durationMs]));
+45 -13
View File
@@ -8,10 +8,11 @@ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, re
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path'; import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs'; import { materializePublishManifest } from './prepare-bundled-package.mjs';
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs'; import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs';
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'; import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs'; import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs';
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host'); assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host');
const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-')); const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-'));
@@ -28,13 +29,18 @@ const releaseVersion = `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`;
// `native/grok` path that the post-install assertion inspects. // `native/grok` path that the post-install assertion inspects.
const prefetched = join(root, 'prefetch'); mkdirSync(prefetched); const prefetched = join(root, 'prefetch'); mkdirSync(prefetched);
const stagedGrok = join(prefetched, 'grok'); const stagedGrok = join(prefetched, 'grok');
// Every background child, downloads and bundled-dependency installs alike, is
// stopped in `finally`; `downloads` is the subset the timing line reports as
// prefetches.
const prefetches = []; const prefetches = [];
const downloads = [];
const timing = {}; const timing = {};
// A prefetch failure surfaces where its result is awaited, never as an // A prefetch failure surfaces where its result is awaited, never as an
// unhandled rejection that would mask an earlier staging error. // unhandled rejection that would mask an earlier staging error.
const prefetch = (name, cmd, args) => { const prefetch = (name, cmd, args) => {
const handle = startPrefetch({ name, cmd, args, cwd: root, env, logPath: join(prefetched, `${name}.log`) }); const handle = startPrefetch({ name, cmd, args, cwd: root, env, logPath: join(prefetched, `${name}.log`) });
prefetches.push(handle); prefetches.push(handle);
downloads.push(handle);
return handle.done; return handle.done;
}; };
let phaseStarted = performance.now(); let phaseStarted = performance.now();
@@ -61,11 +67,21 @@ try {
// Source manifests remain untouched, including independently versioned SDKs. // Source manifests remain untouched, including independently versioned SDKs.
run(process.execPath, [join(repo, 'scripts/build-standalone-public-packages.mjs')], repo); run(process.execPath, [join(repo, 'scripts/build-standalone-public-packages.mjs')], repo);
run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo); run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo);
const tarballs = []; // Each package's published files are staged into `source-<index>` and its
for (const [index, name] of [...needed].entries()) { // pack input into `package-<index>`, with <index> in dependency order so the
// tarball list keeps matching `needed`. The packages that bundle dependencies
// each need a host-side `npm install` of their bundled closure; those installs
// write only their own `package-<index>` and read nothing another package
// produces, so they start first and run together while the other packages
// are copied, instead of one after another inside one loop. Packing stays
// serial and in order, because each tarball is checked as it is produced.
const entries = [...needed].map((name, index) => {
const { dir, manifest } = packages.get(name); const { dir, manifest } = packages.get(name);
const target = join(root, `package-${index}`); mkdirSync(target); const bundled = (manifest.bundleDependencies ?? manifest.bundledDependencies ?? []).length > 0;
const stagedSource = join(root, `source-${index}`); mkdirSync(stagedSource); return { name, dir, manifest, bundled, target: join(root, `package-${index}`), stagedSource: join(root, `source-${index}`) };
});
const stageSource = ({ dir, manifest, stagedSource }) => {
mkdirSync(stagedSource);
for (const file of manifest.files ?? ['dist']) { for (const file of manifest.files ?? ['dist']) {
// release.sh stages runtime skills into these public packages before pack. // release.sh stages runtime skills into these public packages before pack.
const releaseSkills = file === 'skills' && ['server', 'packages/adapters/claude-local', 'packages/adapters/codex-local'].includes(dir); const releaseSkills = file === 'skills' && ['server', 'packages/adapters/claude-local', 'packages/adapters/codex-local'].includes(dir);
@@ -76,12 +92,25 @@ try {
} }
const releaseManifest = { ...manifest, version: releaseVersion }; const releaseManifest = { ...manifest, version: releaseVersion };
writeFileSync(join(stagedSource, 'package.json'), JSON.stringify(releaseManifest)); writeFileSync(join(stagedSource, 'package.json'), JSON.stringify(releaseManifest));
if ((manifest.bundleDependencies ?? manifest.bundledDependencies ?? []).length) { return releaseManifest;
prepareBundledPackage(stagedSource, target); };
} else { const sourcesStarted = performance.now();
cpSync(stagedSource, target, { recursive: true }); for (const entry of entries.filter(entry => entry.bundled)) stageSource(entry);
writeFileSync(join(target, 'package.json'), JSON.stringify(materializePublishManifest(releaseManifest))); const installsStarted = performance.now();
} const installs = startBundledInstalls(entries.filter(entry => entry.bundled), { logDir: prefetched, cwd: repo });
prefetches.push(...installs.map(({ handle }) => handle));
for (const entry of entries.filter(entry => !entry.bundled)) {
const releaseManifest = stageSource(entry);
mkdirSync(entry.target);
cpSync(entry.stagedSource, entry.target, { recursive: true });
writeFileSync(join(entry.target, 'package.json'), JSON.stringify(materializePublishManifest(releaseManifest)));
}
timing.stage_sources_ms = Math.round(performance.now() - sourcesStarted);
await awaited('bundled', Promise.all(installs.map(({ handle }) => handle.done)));
timing.stage_bundled_ms = Math.round(performance.now() - installsStarted);
const packStarted = performance.now();
const tarballs = [];
for (const { target } of entries) {
run('npm', ['pack', '--ignore-scripts', '--pack-destination', root], target); run('npm', ['pack', '--ignore-scripts', '--pack-destination', root], target);
const packed = readdirSync(root).filter(f => f.endsWith('.tgz') && !tarballs.includes(join(root, f))); const packed = readdirSync(root).filter(f => f.endsWith('.tgz') && !tarballs.includes(join(root, f)));
assert.equal(packed.length, 1); assert.equal(packed.length, 1);
@@ -89,6 +118,7 @@ try {
assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).toString().trim().split('\n')); assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).toString().trim().split('\n'));
tarballs.push(tarball); tarballs.push(tarball);
} }
timing.stage_pack_ms = Math.round(performance.now() - packStarted);
const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 }); const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 });
const consumer = join(root, 'consumer'); mkdirSync(consumer); const consumer = join(root, 'consumer'); mkdirSync(consumer);
const cache = join(root, 'cache'); mkdirSync(cache); const cache = join(root, 'cache'); mkdirSync(cache);
@@ -184,8 +214,10 @@ try {
// without re-running it. `*_prefetch_ms` runs from a download's start to its // without re-running it. `*_prefetch_ms` runs from a download's start to its
// last output, and `*_wait_ms` is how long the main flow waited for a // last output, and `*_wait_ms` is how long the main flow waited for a
// prefetch that had not finished yet; zero means the overlap hid it entirely. // prefetch that had not finished yet; zero means the overlap hid it entirely.
const downloads = Object.fromEntries(prefetches.map(({ name, durationMs }) => [`${name}_prefetch_ms`, durationMs])); // `stage_sources_ms`, `stage_bundled_ms` and `stage_pack_ms` split the
console.log(JSON.stringify({ schema: 'paperclip.public-npm-install.timing.v1', ...timing, ...downloads })); // staging phase, and `stage_bundled_<package>_ms` is each concurrent install.
const downloaded = Object.fromEntries(downloads.map(({ name, durationMs }) => [`${name}_prefetch_ms`, durationMs]));
console.log(JSON.stringify({ schema: 'paperclip.public-npm-install.timing.v1', ...timing, ...downloaded, ...bundledInstallTimings(installs) }));
if (process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR) { if (process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR) {
const retained = retainRunnerQualificationPackages({ repo, output: process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR, sourceRevision, releaseVersion, env, const retained = retainRunnerQualificationPackages({ repo, output: process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR, sourceRevision, releaseVersion, env,
publicArchives: [...needed].map((name, index) => ({ name, file: tarballs[index] })), publicArchives: [...needed].map((name, index) => ({ name, file: tarballs[index] })),