Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion components/packageComponent.ts
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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since basename is no longer needed in isExcluded after optimizing the path check, we can remove it from the imports to keep the imports clean.

Suggested change
import { basename, join, relative, sep } from 'node:path';
import { join, relative, sep } from 'node:path';

import { stat, readdir } from 'node:fs/promises';
import { Readable, pipeline } from 'node:stream';
import tar from 'tar-fs';
Expand All @@ -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
Expand All @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): isExcluded runs once per file/directory entry during the full recursive walk (both scanPackageDirectory's loop and tar-fs's ignore callback), so it's a real hot path. basename(fullPath) === 'harper' parses the path via path.basename on every entry; fullPath.endsWith(sep + 'harper') is a native, allocation-free check that's cheaper here. Separately, when an entry's basename happens to be 'harper' but isn't the install-owned path and skip_node_modules is set, relative(directory, fullPath) gets computed twice (once here, again on the next line) — hoisting it to compute once, guarded by isHarper || options.skip_node_modules, avoids the redundant resolution. If adopted, basename becomes unused in the line-1 import.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Performance is a key priority in this codebase, especially on hot paths like directory walks where isExcluded is called for every single file and directory.

Using basename(fullPath) === 'harper' requires parsing the path string on every call, which is relatively expensive. We can optimize this by using fullPath.endsWith(sep + 'harper'), which is a highly optimized native string method.

Additionally, we can restructure the function to compute relative(directory, fullPath) only once and only when necessary (i.e., when either isHarper or options.skip_node_modules is true), avoiding redundant path resolution calls.

	// 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
  1. Performance is a feature here. Do flag hot-path allocations, unnecessary async/await layers, and per-request work that could be hoisted. (link)

Expand Down
120 changes: 120 additions & 0 deletions integrationTests/deploy/package-loaded-component.test.ts
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/`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 fetch in Node.js does not have a default timeout, we should pass a timeout signal using AbortSignal.timeout to ensure the test suite fails cleanly instead of hanging.

Suggested change
const probe = await fetch(`${ctx.harper.httpURL}/PkgLoadedProbe/`);
const probe = await fetch(`${ctx.harper.httpURL}/PkgLoadedProbe/`, { signal: AbortSignal.timeout(5000) });

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): two issues with this readiness loop. First, probe.status < 500 is satisfied on the very first request — Harper's HTTP layer returns a plain 404 (not a connection error, not >=500) for any unmatched resource path, confirmed by integrationTests/deploy/inactive-component-404.test.ts. Since startHarper(ctx) already has the base server listening before this deploy runs, /PkgLoadedProbe/ returns 404 immediately regardless of whether the worker has finished loading the new component, so the loop breaks on the first iteration and never actually waits for the load — unlike sibling tests deploy-from-source.test.ts and redeploy-runtime-equivalence.test.ts, which correctly gate on status === 200. Second, the fetch itself has no timeout; if the server accepted the connection but hung, this await could block indefinitely rather than failing with the intended deadline message. Suggest probe.status === 200 for the break condition plus signal: AbortSignal.timeout(5000) on the fetch.

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)`);
});
}
);
56 changes: 55 additions & 1 deletion unitTests/components/packageComponent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ const { Readable } = require('node:stream');
const testUtils = require('../testUtils.js');
testUtils.preTestPrep();

const { streamPackagedDirectory, packageDirectory, findDanglingSymlinks } = require('#src/components/packageComponent');
const {
streamPackagedDirectory,
packageDirectory,
findDanglingSymlinks,
scanPackageDirectory,
} = require('#src/components/packageComponent');
const { PACKAGE_ROOT } = require('#src/utility/packageUtils');
const { buildMultipartBody } = require('#src/bin/multipartBuilder');
const { parseMultipartRequest } = require('#src/server/serverHelpers/multipartParser');
const gunzip = require('gunzip-maybe');
Expand Down Expand Up @@ -173,6 +179,54 @@ describe('streamPackagedDirectory round-trip', () => {
}
});

it('never packages node_modules/harper, but leaves a component-owned harperdb alone', async function () {
this.timeout(30000);
for (const options of [{}, { skip_symlinks: true }, { skip_node_modules: true }]) {
const label = JSON.stringify(options);
const sourceDir = await makeFixture({ 'index.js': 'x\n', 'src/a.js': 'a\n' });
const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pkg-harper-link-'));
await fs.mkdir(path.join(sourceDir, 'node_modules', 'keep-me'), { recursive: true });
await fs.writeFile(path.join(sourceDir, 'node_modules', 'keep-me', 'index.js'), 'keep\n');
await fs.symlink(PACKAGE_ROOT, path.join(sourceDir, 'node_modules', 'harper'), 'dir');
// A legacy component's REAL harperdb dependency. The loader only repairs that path when it
// already exists, so this content is the component's — and stripping it would not be reinstalled
// on the target, because `installApplication` skips installing when node_modules exists.
await fs.mkdir(path.join(sourceDir, 'node_modules', 'harperdb'), { recursive: true });
await fs.writeFile(path.join(sourceDir, 'node_modules', 'harperdb', 'index.js'), 'legacy\n');

try {
const scan = await scanPackageDirectory(sourceDir, options);
assert.ok(scan.totalSize < 100_000, `${label}: estimate walked the install (${scan.totalSize} bytes)`);

await pipeline(streamPackagedDirectory(sourceDir, options), gunzip(), tar.extract(extractDir));
const extracted = await readDirTree(extractDir);
assert.strictEqual(extracted['index.js'], 'x\n', `${label}: real files survive`);
// Segment-exact, not a prefix test: `node_modules/harper` is a prefix of
// `node_modules/harperdb`, so a `startsWith` here would flag the legacy dependency below.
const harperPrefix = path.join('node_modules', 'harper') + path.sep;
assert.ok(
!Object.keys(extracted).some((k) => k.startsWith(harperPrefix)),
`${label}: packed the install: ${Object.keys(extracted).slice(0, 5).join(', ')}`
);
if (!options.skip_node_modules) {
assert.strictEqual(
extracted[path.join('node_modules', 'keep-me', 'index.js')],
'keep\n',
`${label}: the component's own dependencies still ship`
);
assert.strictEqual(
extracted[path.join('node_modules', 'harperdb', 'index.js')],
'legacy\n',
`${label}: a component-owned harperdb must not be stripped`
);
}
} finally {
await fs.rm(sourceDir, { recursive: true, force: true });
await fs.rm(extractDir, { recursive: true, force: true });
}
}
});

it('findDanglingSymlinks reports broken links and ignores valid ones and node_modules', async function () {
this.timeout(15000);
const sourceDir = await makeFixture({ 'index.js': 'x\n', 'src/a.js': 'a\n' });
Expand Down
Loading