Skip to content

Commit

Permalink
Separate read and write operations on lastKnownGood.json
Browse files Browse the repository at this point in the history
Also skip overwriting lastKnownGood.json with same content.

Signed-off-by: Jakob Ackermann <[email protected]>
  • Loading branch information
das7pad committed Apr 1, 2024
1 parent 4672162 commit 38763e5
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 94 deletions.
129 changes: 57 additions & 72 deletions sources/Engine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {UsageError} from 'clipanion';
import type {FileHandle} from 'fs/promises';
import fs from 'fs';
import path from 'path';
import process from 'process';
Expand All @@ -25,50 +24,58 @@ export type PackageManagerRequest = {
binaryVersion: string | null;
};

export function getLastKnownGoodFile(flag = `r`) {
return fs.promises.open(path.join(folderUtils.getCorepackHomeFolder(), `lastKnownGood.json`), flag);
}
async function createLastKnownGoodFile() {
await fs.promises.mkdir(folderUtils.getCorepackHomeFolder(), {recursive: true});
return getLastKnownGoodFile(`w`);
function getLastKnownGoodFilePath() {
return path.join(folderUtils.getCorepackHomeFolder(), `lastKnownGood.json`);
}

export async function getJSONFileContent(fh: FileHandle) {
let lastKnownGood: unknown;
export async function getLastKnownGood(): Promise<Record<string, string>> {
let raw: string;
try {
lastKnownGood = JSON.parse(await fh.readFile(`utf8`));
raw = await fs.promises.readFile(getLastKnownGoodFilePath(), `utf8`);
} catch (err) {
if ((err as NodeError)?.code === `ENOENT`) return {};
throw err;
}

try {
const parsed = JSON.parse(raw);
if (!parsed) return {};
if (typeof parsed !== `object`) return {};
Object.entries(parsed).forEach(([key, value]) => {
if (typeof value !== `string`) {
// Ensure that all entries are strings.
delete parsed[key];
}
});
return parsed;
} catch {
// Ignore errors; too bad
return undefined;
return {};
}

return lastKnownGood;
}

async function overwriteJSONFileContent(fh: FileHandle, content: unknown) {
await fh.truncate(0);
await fh.write(`${JSON.stringify(content, null, 2)}\n`, 0);
async function createLastKnownGoodFile(lastKnownGood: Record<string, string>) {
const content = `${JSON.stringify(lastKnownGood, null, 2)}\n`;
await fs.promises.mkdir(folderUtils.getCorepackHomeFolder(), {recursive: true});
await fs.promises.writeFile(getLastKnownGoodFilePath(), content, `utf8`);
}

export function getLastKnownGoodFromFileContent(lastKnownGood: unknown, packageManager: string) {
if (typeof lastKnownGood === `object` && lastKnownGood !== null &&
Object.hasOwn(lastKnownGood, packageManager)) {
const override = (lastKnownGood as any)[packageManager];
if (typeof override === `string`) {
return override;
}
}
export function getLastKnownGoodFromFileContent(lastKnownGood: Record<string, string>, packageManager: string) {
if (Object.hasOwn(lastKnownGood, packageManager))
return lastKnownGood[packageManager];
return undefined;
}

export async function activatePackageManagerFromFileHandle(lastKnownGoodFile: FileHandle, lastKnownGood: unknown, locator: Locator) {
if (typeof lastKnownGood !== `object` || lastKnownGood === null)
lastKnownGood = {};
export async function activatePackageManager(lastKnownGood: Record<string, string>, locator: Locator) {
if (lastKnownGood[locator.name] === locator.reference) {
debugUtils.log(`${locator.name}@${locator.reference} is already Last Known Good version`);
return;
}

(lastKnownGood as Record<string, string>)[locator.name] = locator.reference;
lastKnownGood[locator.name] = locator.reference;

debugUtils.log(`Setting ${locator.name}@${locator.reference} as Last Known Good version`);
await overwriteJSONFileContent(lastKnownGoodFile, lastKnownGood);
await createLastKnownGoodFile(lastKnownGood);
}

export class Engine {
Expand Down Expand Up @@ -150,54 +157,32 @@ export class Engine {
if (typeof definition === `undefined`)
throw new UsageError(`This package manager (${packageManager}) isn't supported by this corepack build`);

let lastKnownGoodFile = await getLastKnownGoodFile(`r+`).catch(err => {
if ((err as NodeError)?.code !== `ENOENT` && (err as NodeError)?.code !== `EROFS`) {
throw err;
}
});
try {
const lastKnownGood = lastKnownGoodFile == null || await getJSONFileContent(lastKnownGoodFile!);
const lastKnownGoodForThisPackageManager = getLastKnownGoodFromFileContent(lastKnownGood, packageManager);
if (lastKnownGoodForThisPackageManager)
return lastKnownGoodForThisPackageManager;

if (process.env.COREPACK_DEFAULT_TO_LATEST === `0`)
return definition.default;

const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom);

try {
lastKnownGoodFile ??= await createLastKnownGoodFile();
await activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, {
name: packageManager,
reference,
});
} catch {
// If for some reason, we cannot update the last known good file, we
// ignore the error.
}
const lastKnownGood = await getLastKnownGood();
const lastKnownGoodForThisPackageManager = getLastKnownGoodFromFileContent(lastKnownGood, packageManager);
if (lastKnownGoodForThisPackageManager)
return lastKnownGoodForThisPackageManager;

return reference;
} finally {
await lastKnownGoodFile?.close();
}
}
if (process.env.COREPACK_DEFAULT_TO_LATEST === `0`)
return definition.default;

async activatePackageManager(locator: Locator) {
let emptyFile = false;
const lastKnownGoodFile = await getLastKnownGoodFile(`r+`).catch(err => {
if ((err as NodeError)?.code === `ENOENT`) {
emptyFile = true;
return getLastKnownGoodFile(`w`);
}
const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom);

throw err;
});
try {
await activatePackageManagerFromFileHandle(lastKnownGoodFile, emptyFile || await getJSONFileContent(lastKnownGoodFile), locator);
} finally {
await lastKnownGoodFile.close();
await activatePackageManager(lastKnownGood, {
name: packageManager,
reference,
});
} catch {
// If for some reason, we cannot update the last known good file, we
// ignore the error.
}

return reference;
}

async activatePackageManager(locator: Locator) {
const lastKnownGood = await getLastKnownGood();
await activatePackageManager(lastKnownGood, locator);
}

async ensurePackageManager(locator: Locator) {
Expand Down
27 changes: 7 additions & 20 deletions sources/corepackUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {createHash} from 'crypto';
import {once} from 'events';
import {FileHandle} from 'fs/promises';
import fs from 'fs';
import type {Dir} from 'fs';
import Module from 'module';
Expand Down Expand Up @@ -312,26 +311,14 @@ export async function installVersion(installTarget: string, locator: Locator, {s
}

if (locatorIsASupportedPackageManager && process.env.COREPACK_DEFAULT_TO_LATEST !== `0`) {
let lastKnownGoodFile: FileHandle;
try {
lastKnownGoodFile = await engine.getLastKnownGoodFile(`r+`);
const lastKnownGood = await engine.getJSONFileContent(lastKnownGoodFile);
const defaultVersion = engine.getLastKnownGoodFromFileContent(lastKnownGood, locator.name);
if (defaultVersion) {
const currentDefault = semver.parse(defaultVersion)!;
const downloadedVersion = locatorReference as semver.SemVer;
if (currentDefault.major === downloadedVersion.major && semver.lt(currentDefault, downloadedVersion)) {
await engine.activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, locator);
}
}
} catch (err) {
// ENOENT would mean there are no lastKnownGoodFile, in which case we can ignore.
if ((err as nodeUtils.NodeError)?.code !== `ENOENT`) {
throw err;
const lastKnownGood = await engine.getLastKnownGood();
const defaultVersion = engine.getLastKnownGoodFromFileContent(lastKnownGood, locator.name);
if (defaultVersion) {
const currentDefault = semver.parse(defaultVersion)!;
const downloadedVersion = locatorReference as semver.SemVer;
if (currentDefault.major === downloadedVersion.major && semver.lt(currentDefault, downloadedVersion)) {
await engine.activatePackageManager(lastKnownGood, locator);
}
} finally {
// @ts-expect-error used before assigned
await lastKnownGoodFile?.close();
}
}

Expand Down
6 changes: 5 additions & 1 deletion tests/_runCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import * as path from 'path';
import {pathToFileURL} from 'url';

export async function runCli(cwd: PortablePath, argv: Array<string>, withCustomRegistry?: boolean): Promise<{exitCode: number | null, stdout: string, stderr: string}> {
return spawnCmd(cwd, process.execPath, [`--no-warnings`, ...(withCustomRegistry ? [`--import`, pathToFileURL(path.join(__dirname, `_registryServer.mjs`)) as any as string] : [`-r`, require.resolve(`./recordRequests.js`)]), require.resolve(`../dist/corepack.js`), ...argv]);
}

export async function spawnCmd(cwd: PortablePath, cmd: string, argv: Array<string>): Promise<{exitCode: number | null, stdout: string, stderr: string}> {
const out: Array<Buffer> = [];
const err: Array<Buffer> = [];

return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [`--no-warnings`, ...(withCustomRegistry ? [`--import`, pathToFileURL(path.join(__dirname, `_registryServer.mjs`)) as any as string] : [`-r`, require.resolve(`./recordRequests.js`)]), require.resolve(`../dist/corepack.js`), ...argv], {
const child = spawn(cmd, argv, {
cwd: npath.fromPortablePath(cwd),
env: process.env,
stdio: `pipe`,
Expand Down
92 changes: 91 additions & 1 deletion tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import config from '../config.json';
import * as folderUtils from '../sources/folderUtils';
import {SupportedPackageManagerSet} from '../sources/types';

import {runCli} from './_runCli';
import {runCli, spawnCmd} from './_runCli';


beforeEach(async () => {
Expand Down Expand Up @@ -475,6 +475,96 @@ it(`should support disabling the network accesses from the environment`, async (
});
});

describe(`read-only and offline environment`, () => {
it(`should support running in project scope`, async () => {
await xfs.mktempPromise(async cwd => {
// Reset to default
delete process.env.COREPACK_DEFAULT_TO_LATEST;

// Prepare fake project
await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {
packageManager: `[email protected]`,
});

// $ corepack install
await expect(runCli(cwd, [`install`])).resolves.toMatchObject({
stdout: `Adding [email protected] to the cache...\n`,
stderr: ``,
exitCode: 0,
});

// Let corepack discover the latest yarn version.
// BUG: This should not be necessary with a fully specified version in package.json plus populated corepack cache.
// Engine.executePackageManagerRequest needs to defer the fallback work. This requires a big refactoring.
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
exitCode: 0,
});

// Make COREPACK_HOME ro
const home = npath.toPortablePath(folderUtils.getCorepackHomeFolder());
await xfs.chmodPromise(ppath.join(home, `lastKnownGood.json`), 0o444);
await xfs.chmodPromise(home, 0o555);

// Use fake proxies to simulate offline mode
process.env.HTTP_PROXY = `0.0.0.0`;
process.env.HTTPS_PROXY = `0.0.0.0`;

// $ corepack yarn --version
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
stdout: `2.2.2\n`,
stderr: ``,
exitCode: 0,
});
});
});

it(`should support running globally`, async () => {
await xfs.mktempPromise(async installDir => {
// Reset to default
delete process.env.COREPACK_DEFAULT_TO_LATEST;

// $ corepack enable
await expect(runCli(installDir, [`enable`, `--install-directory`, npath.fromPortablePath(installDir), `yarn`])).resolves.toMatchObject({
stdout: ``,
stderr: ``,
exitCode: 0,
});

// Simulate the effect of `$ corepack enable` without the custom --install-directory option.
process.env.PATH = `${npath.toPortablePath(installDir)}:${process.env.PATH}`;

// $ corepack install --global [email protected]
await expect(runCli(installDir, [`install`, `--global`, `[email protected]`])).resolves.toMatchObject({
stdout: `Installing [email protected]...\n`,
stderr: ``,
exitCode: 0,
});

// Make COREPACK_HOME ro
const home = npath.toPortablePath(folderUtils.getCorepackHomeFolder());
await xfs.chmodPromise(ppath.join(home, `lastKnownGood.json`), 0o444);
await xfs.chmodPromise(home, 0o555);

// Use fake proxies to simulate offline mode
process.env.HTTP_PROXY = `0.0.0.0`;
process.env.HTTPS_PROXY = `0.0.0.0`;

// $ corepack yarn --version
await expect(runCli(installDir, [`yarn`, `--version`])).resolves.toMatchObject({
stdout: `2.2.2\n`,
stderr: ``,
exitCode: 0,
});
// $ yarn --version
await expect(spawnCmd(installDir, `yarn`, [`--version`])).resolves.toMatchObject({
stdout: `2.2.2\n`,
stderr: ``,
exitCode: 0,
});
});
});
});

it(`should support hydrating package managers from cached archives`, async () => {
await xfs.mktempPromise(async cwd => {
await expect(runCli(cwd, [`pack`, `[email protected]`])).resolves.toMatchObject({
Expand Down

0 comments on commit 38763e5

Please sign in to comment.