Skip to content

Commit

Permalink
Handle ENOENT When Fingerprinting Deleted Files (#991)
Browse files Browse the repository at this point in the history
Handle missing file errors thrown while trying to fingerprint an input file.
  • Loading branch information
ObliviousHarmony authored Jan 10, 2024
1 parent bd61903 commit 58c34a4
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 8 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ Versioning](https://semver.org/spec/v2.0.0.html).
- The local cache strategy will now create copy-on-write files when supported. This can improve performance when copying output files either into the cache or restoring from out of it, as the files' underlying data doesn't need to be copied, only filesystem metadata.
- Unhandled exceptions will now be handled more gracefully.

### Fixed

- Handle missing file errors thrown while trying to fingerprint an input file.

## [0.14.1] - 2023-10-20

### Fixed
Expand Down
12 changes: 11 additions & 1 deletion src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ export type Failure =
| DependencyInvalid
| ServiceExitedUnexpectedly
| DependencyServiceExitedUnexpectedly
| Aborted;
| Aborted
| FilesDeletedDuringFingerprinting;

interface ErrorBase<T extends PackageReference = ScriptReference>
extends EventBase<T> {
Expand Down Expand Up @@ -281,6 +282,15 @@ export interface Aborted extends ErrorBase {
reason: 'aborted';
}

/**
* A file was deleting after globbing but before fingerprinting and
* was unable to be read.
*/
export interface FilesDeletedDuringFingerprinting extends ErrorBase {
reason: 'files-deleted-during-fingerprinting';
filePaths: string[];
}

/**
* We reached the point of doing cyclic dependency checking, and one of our
* transitive dependencies had not transitioned to being locally validated.
Expand Down
8 changes: 7 additions & 1 deletion src/execution/no-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ export class NoCommandScriptExecution extends BaseExecution<NoCommandScriptConfi
this._config,
dependencyFingerprints.value,
);
if (!fingerprint.ok) {
return {
ok: false,
error: [fingerprint.error],
};
}
this._logger.log({
script: this._config,
type: 'success',
reason: 'no-command',
});
return {ok: true, value: fingerprint};
return {ok: true, value: fingerprint.value};
}
}
41 changes: 40 additions & 1 deletion src/execution/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,11 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScri
};
void Fingerprint.compute(this._config, depFingerprints).then(
(result) => {
this.#onFingerprinted(result);
if (!result.ok) {
this.#onFingerprintingErr(result.error);
} else {
this.#onFingerprinted(result.value);
}
},
);
return;
Expand Down Expand Up @@ -474,6 +478,41 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScri
}
}

#onFingerprintingErr(failure: Failure) {
switch (this.#state.id) {
case 'fingerprinting': {
const detached = this.#state.adoptee?.detach();
if (detached !== undefined) {
this.#enterStartedBrokenState(failure, detached);
} else {
this.#enterFailedState(failure);
}
return;
}
case 'failed':
case 'stopped': {
return;
}
case 'initial':
case 'executingDeps':
case 'stoppingAdoptee':
case 'unstarted':
case 'depsStarting':
case 'starting':
case 'readying':
case 'started':
case 'started-broken':
case 'stopping':
case 'failing':
case 'detached': {
throw unexpectedState(this.#state);
}
default: {
throw unknownState(this.#state);
}
}
}

#onFingerprinted(fingerprint: Fingerprint) {
switch (this.#state.id) {
case 'fingerprinting': {
Expand Down
9 changes: 8 additions & 1 deletion src/execution/standard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,17 @@ export class StandardScriptExecution extends BaseExecutionWithCommand<StandardSc
// Note we must wait for dependencies to finish before generating the
// cache key, because a dependency could create or modify an input file to
// this script, which would affect the key.
const fingerprint = await Fingerprint.compute(
const fingerprintResponse = await Fingerprint.compute(
this._config,
dependencyFingerprints.value,
);
if (!fingerprintResponse.ok) {
return {
ok: false,
error: [fingerprintResponse.error],
};
}
const fingerprint = fingerprintResponse.value;
if (
this._executor.failedInPreviousWatchIteration(
this._config,
Expand Down
34 changes: 30 additions & 4 deletions src/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import type {
ScriptReferenceString,
Dependency,
} from './config.js';
import {Result} from './error.js';
import {Failure} from './event.js';

/**
* All meaningful inputs of a script. Used for determining if a script is fresh,
Expand Down Expand Up @@ -131,7 +133,7 @@ export class Fingerprint {
static async compute(
script: ScriptConfig,
dependencyFingerprints: Array<[Dependency, Fingerprint]>,
): Promise<Fingerprint> {
): Promise<Result<Fingerprint, Failure>> {
let allDependenciesAreFullyTracked = true;
const filteredDependencyFingerprints: Array<
[ScriptReferenceString, FingerprintSha256HexDigest]
Expand Down Expand Up @@ -172,16 +174,40 @@ export class Fingerprint {
// read) as a heuristic to detect files that have likely changed, and
// otherwise re-use cached hashes that we store in e.g.
// ".wireit/<script>/hashes".
const erroredFilePaths: string[] = [];
fileHashes = await Promise.all(
files.map(async (file): Promise<[string, FileSha256HexDigest]> => {
const absolutePath = file.path;
const hash = createHash('sha256');
for await (const chunk of await createReadStream(absolutePath)) {
hash.update(chunk as Buffer);
try {
const stream = await createReadStream(absolutePath);
for await (const chunk of stream) {
hash.update(chunk as Buffer);
}
} catch (error) {
// It's possible for a file to be deleted between the
// time it is globbed and the time it is fingerprinted.
const {code} = error as {code: string};
if (code !== /* does not exist */ 'ENOENT') {
throw error;
}
erroredFilePaths.push(absolutePath);
}
return [file.path, hash.digest('hex') as FileSha256HexDigest];
}),
);

if (erroredFilePaths.length > 0) {
return {
ok: false,
error: {
type: 'failure',
reason: 'files-deleted-during-fingerprinting',
script: script,
filePaths: erroredFilePaths,
},
};
}
} else {
fileHashes = [];
}
Expand Down Expand Up @@ -232,7 +258,7 @@ export class Fingerprint {
env: script.env,
};
fingerprint.#data = data as FingerprintData;
return fingerprint;
return {ok: true, value: fingerprint};
}

#str?: FingerprintString;
Expand Down
8 changes: 8 additions & 0 deletions src/logging/default-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ export class DefaultLogger implements Logger {
this.console.error(`❌${prefix} Service exited unexpectedly`);
break;
}
case 'files-deleted-during-fingerprinting': {
for (const filePath of event.filePaths) {
this.console.error(
`❌${prefix} "${filePath}" was deleted during fingerprinting.`,
);
}
break;
}
case 'aborted':
case 'dependency-service-exited-unexpectedly': {
// These event isn't very useful to log, because they are downstream
Expand Down
1 change: 1 addition & 0 deletions src/logging/quiet/run-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ export class QuietRunLogger implements Disposable {
// Also logged elswhere.
break;
}
case 'files-deleted-during-fingerprinting':
case 'service-exited-unexpectedly':
case 'cycle':
case 'dependency-invalid':
Expand Down

0 comments on commit 58c34a4

Please sign in to comment.