From eac927e143bc44aa64e061fa69ee5e366ce13c06 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:14:26 +0800 Subject: [PATCH] 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. --- scripts/local-bundle-registry.spec.ts | 30 +++++++++++++++++++++++++++ scripts/local-bundle-registry.ts | 23 ++++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/scripts/local-bundle-registry.spec.ts b/scripts/local-bundle-registry.spec.ts index 1b0941161c..55c4feb284 100644 --- a/scripts/local-bundle-registry.spec.ts +++ b/scripts/local-bundle-registry.spec.ts @@ -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((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((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' }) diff --git a/scripts/local-bundle-registry.ts b/scripts/local-bundle-registry.ts index 1e26d17bf0..4a908b1f43 100644 --- a/scripts/local-bundle-registry.ts +++ b/scripts/local-bundle-registry.ts @@ -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.