Repository navigation
Stop package_component packaging the Harper install through a loaded component's node_modules/harper #2585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Stop package_component packaging the Harper install through a loaded component's node_modules/harper #2585
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { join, relative, sep } from 'node:path'; | ||
| import { basename, join, relative, sep } from 'node:path'; | ||
| import { stat, readdir } from 'node:fs/promises'; | ||
| import { Readable, pipeline } from 'node:stream'; | ||
| import tar from 'tar-fs'; | ||
|
|
@@ -13,6 +13,27 @@ const DEFAULT_OPTIONS: PackageOptions = { skip_node_modules: false, skip_symlink | |
|
|
||
| const WEBPACK_CACHE_SEGMENT = join('cache', 'webpack'); | ||
|
|
||
| /** | ||
| * `node_modules/harper` is Harper's, whatever is on disk there. | ||
| * | ||
| * Every non-root component load runs `symlinkHarperModule`, which does `rmSync(harperModule, { | ||
| * recursive: true, force: true })` and then links the running install (`components/componentLoader.ts`). | ||
| * It does that whether or not the path already existed, so component content there survives no load and | ||
| * packaging it cannot be preserving component source — while packaging it walks the whole install, since | ||
| * the packer dereferences symlinks and recurses into linked directories. | ||
| * | ||
| * `harperdb` is deliberately NOT here, and the asymmetry is the reason: the loader only *repairs* | ||
| * `node_modules/harperdb` when that path already exists. A legacy component shipping a real | ||
| * npm-installed `harperdb` that has never been loaded on this host therefore owns it — and excluding it | ||
| * would strip it from the tarball, after which `installApplication` skips installing (it returns early | ||
| * when `node_modules` exists), the loader's present-gate never fires, and `import from 'harperdb'` | ||
| * fails on the target — and that holds whether the path is skipped by name or by resolved identity, | ||
| * because an archive that simply OMITS it cannot restore it. So a `harperdb` link to the install is | ||
| * still followed. Closing that means packing a placeholder at the path so the loader's present-gate can | ||
| * fire on the target, not skipping it; tracked in harper#2577. | ||
| */ | ||
| const INSTALL_OWNED_PATH = join('node_modules', 'harper'); | ||
|
|
||
| /** | ||
| * Whether `fullPath` (an absolute path under `directory`) should be excluded from the package when | ||
| * `skip_node_modules` is set. The path is first made relative to `directory`, so packaging a component | ||
|
|
@@ -21,6 +42,9 @@ const WEBPACK_CACHE_SEGMENT = join('cache', 'webpack'); | |
| * excluded the whole tree. Shared by the stream packer and the directory walk so they cannot diverge. | ||
| */ | ||
| function isExcluded(directory: string, fullPath: string, options: PackageOptions): boolean { | ||
| // Unconditional, including under `skip_symlinks`: packed literally, the link ships an absolute path to | ||
| // the packaging host's install root, which arrives dangling. | ||
| if (basename(fullPath) === 'harper' && relative(directory, fullPath) === INSTALL_OWNED_PATH) return true; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion (non-blocking): |
||
| if (!options.skip_node_modules) return false; | ||
| const rel = relative(directory, fullPath); | ||
| return rel.split(sep).includes('node_modules') || rel.includes(WEBPACK_CACHE_SEGMENT); | ||
|
Comment on lines
+45
to
50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance is a key priority in this codebase, especially on hot paths like directory walks where Using Additionally, we can restructure the function to compute // Unconditional, including under `skip_symlinks`: packed literally, the link ships an absolute path to
// the packaging host's install root, which arrives dangling.
const isHarper = fullPath.endsWith(sep + 'harper');
if (isHarper || options.skip_node_modules) {
const rel = relative(directory, fullPath);
if (isHarper && rel === INSTALL_OWNED_PATH) return true;
if (options.skip_node_modules && (rel.split(sep).includes('node_modules') || rel.includes(WEBPACK_CACHE_SEGMENT))) {
return true;
}
}
return false;References
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,120 @@ | ||||||
| /** | ||||||
| * `package_component` on a component a worker has LOADED — harper#2487. | ||||||
| * | ||||||
| * Pins: `symlinkHarperModule` links the running install into `<component>/node_modules/harper` on every | ||||||
| * non-root component load, and the packer dereferences symlinks and recurses into linked directories. So | ||||||
| * packaging a component that has actually been loaded used to walk the entire Harper install: observed as | ||||||
| * ~46s of tarring followed by `Maximum response size reached`. | ||||||
| * | ||||||
| * The unit test in `unitTests/components/packageComponent.test.js` plants the link directly; this one earns | ||||||
| * it the way production does — deploy with `restart: true` so a serving worker loads the component — which | ||||||
| * is the half a packer-only test cannot show. | ||||||
| * | ||||||
| * Run: npm run test:integration -- "integrationTests/deploy/package-loaded-component.test.ts" | ||||||
| */ | ||||||
| import { suite, test, before, after } from 'node:test'; | ||||||
| import { ok, strictEqual } from 'node:assert'; | ||||||
| import { join } from 'node:path'; | ||||||
| import { existsSync } from 'node:fs'; | ||||||
| import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'; | ||||||
| import { tmpdir } from 'node:os'; | ||||||
|
|
||||||
| import { setTimeout as sleep } from 'node:timers/promises'; | ||||||
|
|
||||||
| import { startHarper, teardownHarper, targz, type ContextWithHarper } from '@harperfast/integration-testing'; | ||||||
|
|
||||||
| const PROJECT = 'pkg-loaded-probe'; | ||||||
|
|
||||||
| // Skipped on Windows for the same reason as the sibling `deploy-dangling-symlink` suite: `restart: true` | ||||||
| // restarts there consistently miss even a 45s readiness deadline (harper#1813 — `canPreStartReplacement` is | ||||||
| // false for win32, so there is no overlapping replacement). This test additionally depends on | ||||||
| // `symlinkHarperModule` succeeding, and on Windows that needs developer mode for symlinks — its EPERM is | ||||||
| // caught and logged by the loader, so the link would simply be absent and this suite would report the | ||||||
| // packer broken when it is fine. | ||||||
| const skipSuite = process.platform === 'win32'; | ||||||
|
|
||||||
| async function operation(ctx: ContextWithHarper, body: Record<string, unknown>): Promise<any> { | ||||||
| const auth = 'Basic ' + Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64'); | ||||||
| const response = await fetch(ctx.harper.operationsAPIURL, { | ||||||
| method: 'POST', | ||||||
| headers: { 'Content-Type': 'application/json', 'Authorization': auth }, | ||||||
| body: JSON.stringify(body), | ||||||
| }); | ||||||
| strictEqual(response.status, 200, `${body.operation} failed with ${response.status}`); | ||||||
| return response.json(); | ||||||
| } | ||||||
|
|
||||||
| /** A component with a loadable resource, so a worker actually loads it and plants the link. */ | ||||||
| async function buildPayload(): Promise<string> { | ||||||
| const dir = await mkdtemp(join(tmpdir(), 'pkg-loaded-fixture-')); | ||||||
| try { | ||||||
| await writeFile(join(dir, 'package.json'), JSON.stringify({ name: PROJECT, version: '1.0.0' })); | ||||||
| await writeFile(join(dir, 'config.yaml'), 'rest: true\njsResource:\n files: resource.js\n'); | ||||||
| await writeFile( | ||||||
| join(dir, 'resource.js'), | ||||||
| 'export class PkgLoadedProbe extends Resource {\n\tget() {\n\t\treturn { ok: true };\n\t}\n}\n' | ||||||
| ); | ||||||
| await mkdir(join(dir, 'web'), { recursive: true }); | ||||||
| await writeFile(join(dir, 'web', 'index.html'), '<!doctype html><title>probe</title>\n'); | ||||||
| return await targz(dir); | ||||||
| } finally { | ||||||
| await rm(dir, { recursive: true, force: true }); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| suite( | ||||||
| 'package_component does not follow a loaded component into the install', | ||||||
| { skip: skipSuite }, | ||||||
| (ctx: ContextWithHarper) => { | ||||||
| before(async () => { | ||||||
| await startHarper(ctx); | ||||||
| }); | ||||||
| after(async () => { | ||||||
| await teardownHarper(ctx); | ||||||
| }); | ||||||
|
|
||||||
| test('a component a worker has loaded packages as itself, not as Harper', async () => { | ||||||
| // `restart: true` is the whole point: it makes a serving worker load the component, which is what | ||||||
| // creates the link. Without the restart nothing has loaded it and the bug is unreachable. | ||||||
| await operation(ctx, { | ||||||
| operation: 'deploy_component', | ||||||
| project: PROJECT, | ||||||
| payload: await buildPayload(), | ||||||
| restart: true, | ||||||
| }); | ||||||
|
|
||||||
| // `deploy_component` returns before the replacement worker has finished loading, so the link this | ||||||
| // test depends on may not exist yet. Wait for the component's own route to answer — that is the | ||||||
| // signal a worker completed the load that plants it. | ||||||
| const deadline = Date.now() + 45_000; | ||||||
| while (true) { | ||||||
| try { | ||||||
| const probe = await fetch(`${ctx.harper.httpURL}/PkgLoadedProbe/`); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In integration tests, polling loops that await network requests can hang indefinitely if the request never settles (e.g., if the server accepts the connection but hangs without responding). Since standard
Suggested change
|
||||||
| if (probe.status < 500) break; | ||||||
| } catch { | ||||||
| // not accepting connections yet | ||||||
| } | ||||||
| if (Date.now() > deadline) throw new Error('Timed out waiting for the component to load after restart'); | ||||||
|
Comment on lines
+89
to
+97
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion (non-blocking): two issues with this readiness loop. First, |
||||||
| await sleep(250); | ||||||
| } | ||||||
|
|
||||||
| const livePath = join(ctx.harper.dataRootDir, 'components', PROJECT); | ||||||
| ok(existsSync(join(livePath, 'resource.js')), 'the component is live'); | ||||||
| // The link is expected to be here — this test is about the packer not following it, not about | ||||||
| // preventing it. If a future change stops creating it, this test stops proving anything, so assert | ||||||
| // its presence rather than silently passing. | ||||||
| ok(existsSync(join(livePath, 'node_modules', 'harper')), 'a worker load planted the harper link'); | ||||||
|
|
||||||
| const estimate = await operation(ctx, { operation: 'package_component', project: PROJECT, estimate: true }); | ||||||
| ok( | ||||||
| estimate.total_size < 1_000_000, | ||||||
| `packaging walked the install (${estimate.total_size} bytes for a handful of small files)` | ||||||
| ); | ||||||
|
|
||||||
| // And the real thing, not just the estimate: the payload comes back rather than failing on size. | ||||||
| const packaged = await operation(ctx, { operation: 'package_component', project: PROJECT }); | ||||||
| ok(typeof packaged.payload === 'string' && packaged.payload.length > 0, 'a payload was produced'); | ||||||
| ok(packaged.payload.length < 2_000_000, `payload carries the install (${packaged.payload.length} base64 chars)`); | ||||||
| }); | ||||||
| } | ||||||
| ); | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
basenameis no longer needed inisExcludedafter optimizing the path check, we can remove it from the imports to keep the imports clean.