-
Notifications
You must be signed in to change notification settings - Fork 28
Expand file tree
/
Copy pathpnprServer.js
More file actions
306 lines (290 loc) · 12.4 KB
/
Copy pathpnprServer.js
File metadata and controls
306 lines (290 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
'use strict'
import fs from 'fs'
import net from 'net'
import path from 'path'
import spawn from 'cross-spawn'
import { createEnv, applyPnprServerRegistry, pnpmWorkspaceYaml, UPDATED_DEPENDENCIES } from './benchmarkFixture.js'
/**
* Takes a port the operating system says is free.
*
* Starting pnpr on a fixed port meant colliding with one left behind by an
* earlier run of the benchmark in the same job, and the collision did not look
* like one: the new server failed to bind and exited, the old one answered the
* health check, and the run carried on against a registry whose tarball URLs
* pointed at a proxy that had been shut down with the run that created it.
*/
export function reservePort () {
return new Promise((resolve, reject) => {
const probe = net.createServer()
probe.on('error', reject)
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address()
probe.close(() => resolve(port))
})
})
}
const USERNAME = 'pnpm-io-benchmark'
const PASSWORD = 'benchmark'
/**
* pnpr is a pnpm-compatible registry server. The benchmark runs every package
* manager against it so they all face the same registry, and additionally lets
* pnpm offload dependency resolution to it.
*/
export function installPnpr (managersDir) {
// Like pnpm 12 and Bun, the binary arrives through an install script.
const result = spawn.sync('pnpm', ['add', '@pnpm/pnpr@next', '--allow-build=@pnpm/pnpr'], {
cwd: managersDir,
stdio: 'inherit',
})
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`Installing pnpr failed with status code ${result.status}`)
}
}
export function pnprVersion (managersDir) {
const { status, stdout, stderr } = spawn.sync('pnpr', ['--version'], {
cwd: managersDir,
env: createEnv(managersDir),
})
if (status !== 0) {
throw new Error(`Couldn't detect the version of pnpr. ${stderr?.toString() ?? ''}`)
}
return stdout.toString().trim().replace(/^\D+/, '')
}
/**
* Starts a pnpr that proxies the public registry. Its cache is kept for the
* whole run so that every manager is measured against the same warm
* registry rather than against however much of npmjs happened to be cached
* when its turn came.
*/
export async function startPnpr ({ managersDir, dir, port, publicUrl, logLevel = 'error' }) {
fs.mkdirSync(dir, { recursive: true })
const configPath = path.join(dir, 'pnpr.yaml')
fs.writeFileSync(configPath, [
`storage: ${path.join(dir, 'storage')}`,
'auth:',
' htpasswd:',
` file: ${path.join(dir, 'htpasswd')}`,
// The benchmark registers its own resolver user on startup.
' max_users: 1',
'registries:',
' npmjs:',
' type: upstream',
' url: https://registry.npmjs.org/',
' public: true',
'defaultRegistry: npmjs',
'resolver:',
' enabled: true',
// The accelerated scenario asks the server to resolve against this
// address — its own un-proxied one — so the resolver's metadata fetches
// stay on loopback instead of crossing the emulated link and coming back
// in through the front door (see `applyPnprServerRegistry`). The server
// resolves against whatever registry the client sends but only if that
// registry is declared, so the address has to be listed as a route here.
'routes:',
' public:',
` - registry: http://127.0.0.1:${port}`,
'log:',
' type: stdout',
' format: pretty',
` level: ${logLevel}`,
'',
].join('\n'))
// pnpr's output goes to a file rather than a pipe. Installs are measured with
// a synchronous spawn, so nothing in this process drains a pipe while one is
// running: the buffer would fill, pnpr would block writing to it, and the
// registry would stop answering midway through a scenario.
const logPath = path.join(dir, 'pnpr.log')
const logFd = fs.openSync(logPath, 'a')
const proc = spawn('pnpr', [
'-c', configPath,
'--listen', `127.0.0.1:${port}`,
// pnpr rewrites the tarball URLs it serves to this address. It has to be
// the address clients actually use, or every tarball would be fetched on a
// link the benchmark isn't emulating — and npm refuses outright to fetch a
// tarball from a host other than the registry it was told about.
...(publicUrl ? ['--public-url', publicUrl] : []),
// Packuments are fetched from npmjs once and then treated as authoritative,
// so no scenario pays for a revalidation another scenario already paid for.
'--packument-ttl-secs', '31536000',
], {
cwd: managersDir,
env: createEnv(managersDir),
stdio: ['ignore', logFd, logFd],
})
fs.closeSync(logFd)
proc.on('error', (err) => { throw err })
const readLog = () => {
try {
return fs.readFileSync(logPath, 'utf8')
} catch {
return ''
}
}
let exit = null
proc.on('exit', (code, signal) => { exit = { code, signal } })
const url = `http://127.0.0.1:${port}`
await waitForPnpr(url, proc, readLog)
return {
url,
log: readLog,
/** A registry that died mid-run makes everything measured after it worthless. */
assertAlive: () => {
if (!exit) return
throw new Error(
`pnpr exited (code ${exit.code}, signal ${exit.signal}). Its log:\n${readLog().slice(-4000)}`
)
},
// A registry that outlives its run is what made the second benchmark of a
// job fail: it held the port, answered the next run's health check, and
// served tarball URLs pointing at a proxy that no longer existed. Make sure
// it is gone rather than merely asked to leave.
stop: () => {
if (exit) return
proc.kill()
setTimeout(() => { if (!exit) proc.kill('SIGKILL') }, 2_000).unref()
},
}
}
async function waitForPnpr (url, proc, getStderr) {
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
if (proc.exitCode !== null) {
throw new Error(`pnpr exited with code ${proc.exitCode}. ${getStderr()}`)
}
try {
const res = await fetch(`${url}/-/ping`)
if (res.ok) {
// Something answering is not the same as our server answering. A server
// left behind by an earlier run replies at once, well before the process
// started here has had time to discover it cannot have the port and
// exit, so the reply alone proves nothing — give it long enough to fail
// and then insist it is still running.
await new Promise((resolve) => setTimeout(resolve, 500))
if (proc.exitCode !== null) {
throw new Error(
`pnpr exited with code ${proc.exitCode} yet ${url} still answers, so another ` +
`server holds that port and the benchmark would run against it. ${getStderr()}`
)
}
return
}
} catch (err) {
if (err.message?.startsWith('pnpr exited')) throw err
// Not listening yet.
}
await new Promise((resolve) => setTimeout(resolve, 200))
}
throw new Error(`pnpr did not start listening on ${url}. ${getStderr()}`)
}
/**
* pnpr serves packages anonymously but requires authentication to resolve, so
* the accelerated scenario needs a token.
*/
export async function mintToken (url) {
const res = await fetch(`${url}/-/user/org.couchdb.user:${USERNAME}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: USERNAME, password: PASSWORD }),
})
if (!res.ok) {
throw new Error(`Couldn't register a pnpr user: ${res.status} ${res.statusText}`)
}
const { token } = await res.json()
if (!token) throw new Error('pnpr returned no token')
return token
}
/**
* Fills pnpr's cache by installing the fixture once, untimed and without the
* emulated link in the way. Without this the first manager measured would pay
* for pulling every package from npmjs into pnpr and the rest would not.
*
* It installs twice: once with the fixture as it is, and once with the
* dependencies the `updatedDependencies` scenario adds. That scenario
* installs a different graph than the fixture's, and a registry warmed only
* for the fixture would make the first manager's measured update run the one
* that pulls the whole updated graph out of npmjs.
*/
export function populateCache ({ pm, managersDir, dir, registry, fixtureDir }) {
const cwd = path.join(dir, 'populate')
fs.rmSync(cwd, { recursive: true, force: true })
fs.mkdirSync(cwd, { recursive: true })
fs.copyFileSync(path.join(fixtureDir, 'package.json'), path.join(cwd, 'package.json'))
fs.writeFileSync(path.join(cwd, '.npmrc'), `registry=${registry}\n`)
// The same workspace manifest the measured pnpm scenarios install under,
// so the populate pass resolves the same universe of versions they will.
// Declaring a workspace root also stops pnpm walking up into the manager
// directory above, whose own lockfile is for pnpr's dependencies and has
// nothing to do with the fixture.
fs.writeFileSync(path.join(cwd, 'pnpm-workspace.yaml'), pnpmWorkspaceYaml())
const install = (label) => {
const result = spawn.sync(pm.name, [...pm.args, '--no-frozen-lockfile'], {
cwd,
env: createEnv(managersDir),
stdio: 'inherit',
})
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`Populating the pnpr cache (${label}) failed with status code ${result.status}`)
}
}
install('fixture')
const packageJsonPath = path.join(cwd, 'package.json')
const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
manifest.dependencies = { ...manifest.dependencies, ...UPDATED_DEPENDENCIES }
fs.writeFileSync(packageJsonPath, JSON.stringify(manifest))
install('updated dependencies')
}
/**
* pnpm resolves on its own whenever it can't use the server, and says so only
* in its output — an unusable server leaves no trace in pnpr's storage, which
* the registry traffic fills either way. That failure would silently turn the
* accelerated row into a copy of the plain pnpm row, so it is checked here
* with one untimed install rather than during a measured one.
*/
export function verifyResolverIsUsed ({ pm, managersDir, dir, registry, authToken, pnprServer, pnprServerRegistry, fixtureDir, serverLog }) {
const cwd = path.join(dir, 'verify')
fs.rmSync(cwd, { recursive: true, force: true })
fs.mkdirSync(cwd, { recursive: true })
fs.copyFileSync(path.join(fixtureDir, 'package.json'), path.join(cwd, 'package.json'))
// The resolver answers on a link of its own, so it is a different host than
// the registry and needs the credential declared against its own address.
const hosts = new Set([registry, pnprServer].map((url) => new URL(url).host))
const auth = [...hosts].map((host) => `//${host}/:_authToken=${authToken}\n`).join('')
fs.writeFileSync(path.join(cwd, '.npmrc'), `registry=${registry}\n${auth}`)
fs.writeFileSync(path.join(cwd, 'pnpm-workspace.yaml'), pnpmWorkspaceYaml({ pnprServer }))
const resolveCalls = () => (serverLog().match(/uri=\/-\/pnpr\/v0\/resolve/g) ?? []).length
const before = resolveCalls()
const env = createEnv(managersDir)
if (pnprServerRegistry) {
// Verified under the same override the measured scenario runs with, so
// what this proves is the configuration that is actually measured.
applyPnprServerRegistry(env, pnprServerRegistry)
}
if (pm.rustEngine) {
// Same store and cache redirection the measured scenarios get: pnpm 12
// keeps its store at $PNPM_HOME/store and its packument mirror at the
// machine-global cacheDir, and this one-off install should write into
// neither the machine's real store nor its real cache.
env.PNPM_HOME = path.join(cwd, 'cache')
env.PNPM_CONFIG_CACHE_DIR = path.join(cwd, 'cache', 'cache')
}
const result = spawn.sync(pm.name, [...pm.args, '--no-frozen-lockfile'], {
cwd,
env,
encoding: 'utf8',
})
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
if (result.status !== 0) {
throw new Error(`Verifying pnpr resolution failed with status code ${result.status}. ${output}`)
}
// The server's own request log is the evidence, rather than anything pnpm
// prints: what a package manager writes to its terminal is not a contract,
// and a reworded line should not be able to abort a benchmark that is working.
if (resolveCalls() === before) {
throw new Error(
'pnpr recorded no resolution request, so pnpm resolved on its own and ' +
`the accelerated scenario would measure a plain install. Its output was:\n${output}`
)
}
}