Skip to content

Commit c449adc

Browse files
das7padaduh95
andauthored
feat: separate read and write operations on lastKnownGood.json (#446)
Also skip overwriting `lastKnownGood.json` with same content. Signed-off-by: Jakob Ackermann <das7pad@outlook.com> Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
1 parent c800d3e commit c449adc

File tree

3 files changed

+142
-92
lines changed

3 files changed

+142
-92
lines changed

sources/Engine.ts

+57-72
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {UsageError} from 'clipanion';
2-
import type {FileHandle} from 'fs/promises';
32
import fs from 'fs';
43
import path from 'path';
54
import process from 'process';
@@ -25,50 +24,58 @@ export type PackageManagerRequest = {
2524
binaryVersion: string | null;
2625
};
2726

28-
export function getLastKnownGoodFile(flag = `r`) {
29-
return fs.promises.open(path.join(folderUtils.getCorepackHomeFolder(), `lastKnownGood.json`), flag);
30-
}
31-
async function createLastKnownGoodFile() {
32-
await fs.promises.mkdir(folderUtils.getCorepackHomeFolder(), {recursive: true});
33-
return getLastKnownGoodFile(`w`);
27+
function getLastKnownGoodFilePath() {
28+
return path.join(folderUtils.getCorepackHomeFolder(), `lastKnownGood.json`);
3429
}
3530

36-
export async function getJSONFileContent(fh: FileHandle) {
37-
let lastKnownGood: unknown;
31+
export async function getLastKnownGood(): Promise<Record<string, string>> {
32+
let raw: string;
3833
try {
39-
lastKnownGood = JSON.parse(await fh.readFile(`utf8`));
34+
raw = await fs.promises.readFile(getLastKnownGoodFilePath(), `utf8`);
35+
} catch (err) {
36+
if ((err as NodeError)?.code === `ENOENT`) return {};
37+
throw err;
38+
}
39+
40+
try {
41+
const parsed = JSON.parse(raw);
42+
if (!parsed) return {};
43+
if (typeof parsed !== `object`) return {};
44+
Object.entries(parsed).forEach(([key, value]) => {
45+
if (typeof value !== `string`) {
46+
// Ensure that all entries are strings.
47+
delete parsed[key];
48+
}
49+
});
50+
return parsed;
4051
} catch {
4152
// Ignore errors; too bad
42-
return undefined;
53+
return {};
4354
}
44-
45-
return lastKnownGood;
4655
}
4756

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

53-
export function getLastKnownGoodFromFileContent(lastKnownGood: unknown, packageManager: string) {
54-
if (typeof lastKnownGood === `object` && lastKnownGood !== null &&
55-
Object.hasOwn(lastKnownGood, packageManager)) {
56-
const override = (lastKnownGood as any)[packageManager];
57-
if (typeof override === `string`) {
58-
return override;
59-
}
60-
}
63+
export function getLastKnownGoodFromFileContent(lastKnownGood: Record<string, string>, packageManager: string) {
64+
if (Object.hasOwn(lastKnownGood, packageManager))
65+
return lastKnownGood[packageManager];
6166
return undefined;
6267
}
6368

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

68-
(lastKnownGood as Record<string, string>)[locator.name] = locator.reference;
75+
lastKnownGood[locator.name] = locator.reference;
6976

7077
debugUtils.log(`Setting ${locator.name}@${locator.reference} as Last Known Good version`);
71-
await overwriteJSONFileContent(lastKnownGoodFile, lastKnownGood);
78+
await createLastKnownGoodFile(lastKnownGood);
7279
}
7380

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

153-
let lastKnownGoodFile = await getLastKnownGoodFile(`r+`).catch(err => {
154-
if ((err as NodeError)?.code !== `ENOENT` && (err as NodeError)?.code !== `EROFS`) {
155-
throw err;
156-
}
157-
});
158-
try {
159-
const lastKnownGood = lastKnownGoodFile == null || await getJSONFileContent(lastKnownGoodFile!);
160-
const lastKnownGoodForThisPackageManager = getLastKnownGoodFromFileContent(lastKnownGood, packageManager);
161-
if (lastKnownGoodForThisPackageManager)
162-
return lastKnownGoodForThisPackageManager;
163-
164-
if (process.env.COREPACK_DEFAULT_TO_LATEST === `0`)
165-
return definition.default;
166-
167-
const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom);
168-
169-
try {
170-
lastKnownGoodFile ??= await createLastKnownGoodFile();
171-
await activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, {
172-
name: packageManager,
173-
reference,
174-
});
175-
} catch {
176-
// If for some reason, we cannot update the last known good file, we
177-
// ignore the error.
178-
}
160+
const lastKnownGood = await getLastKnownGood();
161+
const lastKnownGoodForThisPackageManager = getLastKnownGoodFromFileContent(lastKnownGood, packageManager);
162+
if (lastKnownGoodForThisPackageManager)
163+
return lastKnownGoodForThisPackageManager;
179164

180-
return reference;
181-
} finally {
182-
await lastKnownGoodFile?.close();
183-
}
184-
}
165+
if (process.env.COREPACK_DEFAULT_TO_LATEST === `0`)
166+
return definition.default;
185167

186-
async activatePackageManager(locator: Locator) {
187-
let emptyFile = false;
188-
const lastKnownGoodFile = await getLastKnownGoodFile(`r+`).catch(err => {
189-
if ((err as NodeError)?.code === `ENOENT`) {
190-
emptyFile = true;
191-
return getLastKnownGoodFile(`w`);
192-
}
168+
const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom);
193169

194-
throw err;
195-
});
196170
try {
197-
await activatePackageManagerFromFileHandle(lastKnownGoodFile, emptyFile || await getJSONFileContent(lastKnownGoodFile), locator);
198-
} finally {
199-
await lastKnownGoodFile.close();
171+
await activatePackageManager(lastKnownGood, {
172+
name: packageManager,
173+
reference,
174+
});
175+
} catch {
176+
// If for some reason, we cannot update the last known good file, we
177+
// ignore the error.
200178
}
179+
180+
return reference;
181+
}
182+
183+
async activatePackageManager(locator: Locator) {
184+
const lastKnownGood = await getLastKnownGood();
185+
await activatePackageManager(lastKnownGood, locator);
201186
}
202187

203188
async ensurePackageManager(locator: Locator) {

sources/corepackUtils.ts

+7-20
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {createHash} from 'crypto';
22
import {once} from 'events';
3-
import {FileHandle} from 'fs/promises';
43
import fs from 'fs';
54
import type {Dir} from 'fs';
65
import Module from 'module';
@@ -325,26 +324,14 @@ export async function installVersion(installTarget: string, locator: Locator, {s
325324
}
326325

327326
if (locatorIsASupportedPackageManager && process.env.COREPACK_DEFAULT_TO_LATEST !== `0`) {
328-
let lastKnownGoodFile: FileHandle;
329-
try {
330-
lastKnownGoodFile = await engine.getLastKnownGoodFile(`r+`);
331-
const lastKnownGood = await engine.getJSONFileContent(lastKnownGoodFile);
332-
const defaultVersion = engine.getLastKnownGoodFromFileContent(lastKnownGood, locator.name);
333-
if (defaultVersion) {
334-
const currentDefault = semver.parse(defaultVersion)!;
335-
const downloadedVersion = locatorReference as semver.SemVer;
336-
if (currentDefault.major === downloadedVersion.major && semver.lt(currentDefault, downloadedVersion)) {
337-
await engine.activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, locator);
338-
}
339-
}
340-
} catch (err) {
341-
// ENOENT would mean there are no lastKnownGoodFile, in which case we can ignore.
342-
if ((err as nodeUtils.NodeError)?.code !== `ENOENT`) {
343-
throw err;
327+
const lastKnownGood = await engine.getLastKnownGood();
328+
const defaultVersion = engine.getLastKnownGoodFromFileContent(lastKnownGood, locator.name);
329+
if (defaultVersion) {
330+
const currentDefault = semver.parse(defaultVersion)!;
331+
const downloadedVersion = locatorReference as semver.SemVer;
332+
if (currentDefault.major === downloadedVersion.major && semver.lt(currentDefault, downloadedVersion)) {
333+
await engine.activatePackageManager(lastKnownGood, locator);
344334
}
345-
} finally {
346-
// @ts-expect-error used before assigned
347-
await lastKnownGoodFile?.close();
348335
}
349336
}
350337

tests/main.test.ts

+78
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,84 @@ it(`should support disabling the network accesses from the environment`, async (
476476
});
477477
});
478478

479+
describe(`read-only and offline environment`, () => {
480+
it(`should support running in project scope`, async () => {
481+
await xfs.mktempPromise(async cwd => {
482+
// Reset to default
483+
delete process.env.COREPACK_DEFAULT_TO_LATEST;
484+
485+
// Prepare fake project
486+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {
487+
packageManager: `yarn@2.2.2`,
488+
});
489+
490+
// $ corepack install
491+
await expect(runCli(cwd, [`install`])).resolves.toMatchObject({
492+
stdout: `Adding yarn@2.2.2 to the cache...\n`,
493+
stderr: ``,
494+
exitCode: 0,
495+
});
496+
497+
// Let corepack discover the latest yarn version.
498+
// BUG: This should not be necessary with a fully specified version in package.json plus populated corepack cache.
499+
// Engine.executePackageManagerRequest needs to defer the fallback work. This requires a big refactoring.
500+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
501+
exitCode: 0,
502+
});
503+
504+
// Make COREPACK_HOME ro
505+
const home = npath.toPortablePath(folderUtils.getCorepackHomeFolder());
506+
await xfs.chmodPromise(ppath.join(home, `lastKnownGood.json`), 0o444);
507+
await xfs.chmodPromise(home, 0o555);
508+
509+
// Use fake proxies to simulate offline mode
510+
process.env.HTTP_PROXY = `0.0.0.0`;
511+
process.env.HTTPS_PROXY = `0.0.0.0`;
512+
513+
// $ corepack yarn --version
514+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
515+
stdout: `2.2.2\n`,
516+
stderr: ``,
517+
exitCode: 0,
518+
});
519+
});
520+
});
521+
522+
it(`should support running globally`, async () => {
523+
await xfs.mktempPromise(async installDir => {
524+
// Reset to default
525+
delete process.env.COREPACK_DEFAULT_TO_LATEST;
526+
527+
await expect(runCli(installDir, [`enable`, `--install-directory`, npath.fromPortablePath(installDir), `yarn`])).resolves.toMatchObject({
528+
stdout: ``,
529+
stderr: ``,
530+
exitCode: 0,
531+
});
532+
533+
await expect(runCli(installDir, [`install`, `--global`, `yarn@2.2.2`])).resolves.toMatchObject({
534+
stdout: `Installing yarn@2.2.2...\n`,
535+
stderr: ``,
536+
exitCode: 0,
537+
});
538+
539+
// Make COREPACK_HOME ro
540+
const home = npath.toPortablePath(folderUtils.getCorepackHomeFolder());
541+
await xfs.chmodPromise(ppath.join(home, `lastKnownGood.json`), 0o444);
542+
await xfs.chmodPromise(home, 0o555);
543+
544+
// Use fake proxies to simulate offline mode
545+
process.env.HTTP_PROXY = `0.0.0.0`;
546+
process.env.HTTPS_PROXY = `0.0.0.0`;
547+
548+
await expect(runCli(installDir, [`yarn`, `--version`])).resolves.toMatchObject({
549+
stdout: `2.2.2\n`,
550+
stderr: ``,
551+
exitCode: 0,
552+
});
553+
});
554+
});
555+
});
556+
479557
it(`should support hydrating package managers from cached archives`, async () => {
480558
await xfs.mktempPromise(async cwd => {
481559
await expect(runCli(cwd, [`pack`, `yarn@2.2.2`])).resolves.toMatchObject({

0 commit comments

Comments
 (0)