test(scripts): cover preparation cancellation and an abandoned client

Both are failure paths the registry owns: a signal that stops packing must
report as a cancellation rather than as a pack failure, and a client that
disconnects mid-tarball must not stop later requests. execa reports the first
either as a resolved canceled result or as a thrown error, so the packing step
now names both.
This commit is contained in:
Tianyi Cui committed 2026-10-07 14:07:27 +08:00
1 parent 73d853a9ae
commit eac927e143
2 files changed
+49 -4

No files matched your search

+30
View File
@@ -1,6 +1,7 @@
/** Behavior of the local Official-bundle registry: closure selection, archive validation, HTTP, and shutdown. */
import { createHash } from 'node:crypto'
import { connect } from 'node:net'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -17,6 +18,7 @@ import {
readReleaseMembers,
readWorkspacePackages,
selectBundleDependencies,
packBundleArtifacts,
startBundleRegistry,
writeRegistryManifest,
type BundleArtifact,
@@ -254,6 +256,16 @@ describe('packed archives', () => {
.toThrow(/@scope\/bundle: packed archive lacks its declared bundle patch cordis\.patch\.yml/u)
})
it('reports a preparation stopped by a signal as a cancellation', async () => {
const destination = temporaryDirectory('dsh-local-registry-cancel-')
const { member, entry } = packFixture({ destination, name: '@scope/pkg', version: '1.0.0', source: 'export default 1\n' })
const controller = new AbortController()
controller.abort()
await expect(packBundleArtifacts({
root, destination, members: [member], release: new Map([[member.name, entry]]), signal: controller.signal,
})).rejects.toThrow(/packing was cancelled/u)
})
it('rejects a missing archive and a package that is not a public release member', () => {
const destination = temporaryDirectory('dsh-local-registry-missing-')
const missing = workspacePackage('@scope/pkg', { version: '1.0.0' })
@@ -356,6 +368,24 @@ describe('registry HTTP', () => {
expect(external.headers.get('location')).toBe('https://registry.npmjs.org/@anthropic-ai%2fclaude-agent-sdk')
})
it('keeps serving after a client abandons a tarball request', async () => {
const destination = temporaryDirectory('dsh-local-registry-disconnect-')
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.0.0', source: 'export default 1\n' })
const url = await servingRun({ destination, artifacts: [artifact], workspaceNames: ['@scope/local'] })
const { port, pathname } = new URL(url)
const socket = connect(Number(port), '127.0.0.1')
await new Promise<void>((resolveConnect, rejectConnect) => {
socket.once('connect', resolveConnect)
socket.once('error', rejectConnect)
})
socket.write(`GET ${pathname}tarballs/${artifact.file} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n\r\n`)
await new Promise<void>((resolveData) => { socket.once('data', () => { resolveData() }) })
socket.destroy()
const response = await fetch(`${url}@scope%2flocal`)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ name: '@scope/local' })
})
it('rejects out-of-namespace, malformed, and non-GET requests, then stops listening', async () => {
const destination = temporaryDirectory('dsh-local-registry-reject-')
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.0.0', source: 'export default 1\n' })
+19 -4
View File
@@ -367,10 +367,16 @@ export async function packBundleArtifacts(options: {
'pack', '--recursive', `--workspace-concurrency=${String(Math.min(4, members.length))}`,
...members.map(member => `--filter=${member.name}`), '--pack-destination', destination,
])
const result = await execa(invocation.command, invocation.args, {
cwd: root, reject: false, killDescendants: true,
...options.signal === undefined ? {} : { cancelSignal: options.signal },
})
let result
try {
result = await execa(invocation.command, invocation.args, {
cwd: root, reject: false, killDescendants: true,
...options.signal === undefined ? {} : { cancelSignal: options.signal },
})
} catch (error) {
if (isCanceled(error)) throw new Error('packing was cancelled')
throw error
}
if (result.isCanceled) throw new Error('packing was cancelled')
if (result.exitCode !== 0) {
throw new Error(`pnpm pack exited with ${String(result.exitCode ?? result.signal)}:\n${result.stdout}\n${result.stderr}`)
@@ -378,6 +384,15 @@ export async function packBundleArtifacts(options: {
return readPackedArtifacts({ destination, members, release })
}
/**
* Whether a settled or thrown child-process outcome was a cancellation.
* @param value - the settled execa result or the error it rejected with.
* @returns True when an abort signal ended the child.
*/
function isCanceled(value: unknown): boolean {
return typeof value === 'object' && value !== null && 'isCanceled' in value && value.isCanceled === true
}
/**
* Validate already packed archives and describe them as servable artifacts.
* @param options - run directory, the members that were packed into it, and the public release index.