mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 06:46:10 +00:00
fix(artifacts): serialize ASAR snapshot ownership
Serialize preparation and close for each reader so concurrent callers share one owned snapshot. Preserve per-call cancellation and expected digest checks, cleanup retry ownership, and reuse after close. Validation: 34 ASAR boundary tests, one fork-isolated snapshot race regression, test discovery, and check:fast passed.
This commit is contained in:
1 parent
29cc1b8746
commit
026126eafe
3 files changed
+245
-67
No files matched your search
@@ -54,6 +54,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
>();
|
||||
#snapshot: { readonly path: string; readonly sha256: string } | undefined;
|
||||
#snapshotRoot: string | undefined;
|
||||
#snapshotOperation: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
@@ -234,62 +235,20 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
expectedSha256?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
abortIfNeeded(signal);
|
||||
if (this.#snapshot === undefined && this.#snapshotRoot !== undefined)
|
||||
await this.close();
|
||||
if (this.#snapshot === undefined) {
|
||||
try {
|
||||
this.#snapshotRoot = await mkdtemp(
|
||||
join(tmpdir(), "rea-asar-snapshot-"),
|
||||
await this.#withSnapshotOperation(async () => {
|
||||
abortIfNeeded(signal);
|
||||
if (this.#snapshot === undefined && this.#snapshotRoot !== undefined)
|
||||
await this.#closeSnapshot();
|
||||
if (this.#snapshot === undefined) await this.#captureSnapshot(signal);
|
||||
if (
|
||||
expectedSha256 !== undefined &&
|
||||
this.#snapshot?.sha256 !== expectedSha256
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR container changed before interpretation: ${this.path}`,
|
||||
);
|
||||
const snapshotPath = join(this.#snapshotRoot, "container.asar");
|
||||
const hash = createHash("sha256");
|
||||
const handle = await open(
|
||||
this.path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
const source = handle.createReadStream();
|
||||
const digesting = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
await pipeline(
|
||||
source,
|
||||
digesting,
|
||||
createWriteStream(snapshotPath, { flags: "wx", mode: 0o600 }),
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
this.#snapshot = { path: snapshotPath, sha256: hash.digest("hex") };
|
||||
} catch (cause: unknown) {
|
||||
const primary =
|
||||
signal?.aborted === true
|
||||
? new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"ASAR operation cancelled",
|
||||
{ cause },
|
||||
)
|
||||
: asarFailure(this.path, "snapshot", cause);
|
||||
try {
|
||||
await this.close();
|
||||
} catch (cleanupCause: unknown) {
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
primary,
|
||||
ArtifactReaderFailure.cleanupObservation(cleanupCause, this.path),
|
||||
);
|
||||
}
|
||||
throw primary;
|
||||
}
|
||||
}
|
||||
if (
|
||||
expectedSha256 !== undefined &&
|
||||
this.#snapshot.sha256 !== expectedSha256
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR container changed before interpretation: ${this.path}`,
|
||||
);
|
||||
}, signal);
|
||||
}
|
||||
|
||||
/** Read the same captured container that supplies headers and packed members. */
|
||||
@@ -302,30 +261,86 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
let cleanupFailure: ArtifactReaderFailure | undefined;
|
||||
for (const [handle, { path }] of this.#unpackedHandles) {
|
||||
await this.#withSnapshotOperation(async () => {
|
||||
let cleanupFailure: ArtifactReaderFailure | undefined;
|
||||
for (const [handle, { path }] of this.#unpackedHandles) {
|
||||
try {
|
||||
await this.#closeUnpackedHandle(handle);
|
||||
} catch (cause: unknown) {
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
cleanupFailure ?? cause,
|
||||
ArtifactReaderFailure.cleanupObservation(cause, path),
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.#closeUnpackedHandle(handle);
|
||||
await this.#closeSnapshot();
|
||||
} catch (cause: unknown) {
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
cleanupFailure ?? cause,
|
||||
ArtifactReaderFailure.cleanupObservation(cause, path),
|
||||
ArtifactReaderFailure.cleanupObservation(cause, this.path),
|
||||
);
|
||||
}
|
||||
if (cleanupFailure !== undefined) throw cleanupFailure;
|
||||
});
|
||||
}
|
||||
|
||||
async #captureSnapshot(signal?: AbortSignal): Promise<void> {
|
||||
try {
|
||||
this.#snapshotRoot = await mkdtemp(join(tmpdir(), "rea-asar-snapshot-"));
|
||||
const snapshotPath = join(this.#snapshotRoot, "container.asar");
|
||||
const hash = createHash("sha256");
|
||||
const handle = await open(
|
||||
this.path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
const source = handle.createReadStream();
|
||||
const digesting = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
await pipeline(
|
||||
source,
|
||||
digesting,
|
||||
createWriteStream(snapshotPath, { flags: "wx", mode: 0o600 }),
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
this.#snapshot = { path: snapshotPath, sha256: hash.digest("hex") };
|
||||
} catch (cause: unknown) {
|
||||
const primary =
|
||||
signal?.aborted === true
|
||||
? new ArtifactReaderFailure("cancelled", "ASAR operation cancelled", {
|
||||
cause,
|
||||
})
|
||||
: asarFailure(this.path, "snapshot", cause);
|
||||
try {
|
||||
await this.#closeSnapshot();
|
||||
} catch (cleanupCause: unknown) {
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
primary,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupCause,
|
||||
this.#snapshotRoot ?? this.path,
|
||||
),
|
||||
);
|
||||
}
|
||||
throw primary;
|
||||
}
|
||||
}
|
||||
|
||||
async #closeSnapshot(): Promise<void> {
|
||||
const root = this.#snapshotRoot;
|
||||
if (root !== undefined) {
|
||||
try {
|
||||
await this.removeSnapshot(root);
|
||||
if (this.#snapshotRoot === root) this.#snapshotRoot = undefined;
|
||||
} catch (cause: unknown) {
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
cleanupFailure ?? cause,
|
||||
{
|
||||
reason: `Could not remove ASAR snapshot: ${root}`,
|
||||
resources: [root],
|
||||
},
|
||||
);
|
||||
throw ArtifactReaderFailure.withCleanup(cause, {
|
||||
reason: `Could not remove ASAR snapshot: ${root}`,
|
||||
resources: [root],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (root === undefined || this.#snapshotRoot === undefined) {
|
||||
@@ -334,7 +349,29 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
this.#resetArchiveState();
|
||||
this.#entries.clear();
|
||||
}
|
||||
if (cleanupFailure !== undefined) throw cleanupFailure;
|
||||
}
|
||||
|
||||
async #withSnapshotOperation<Value>(
|
||||
operation: () => Promise<Value>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Value> {
|
||||
const previous = this.#snapshotOperation;
|
||||
let release!: () => void;
|
||||
this.#snapshotOperation = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
try {
|
||||
await waitForOperation(previous, signal);
|
||||
} catch (cause: unknown) {
|
||||
void previous.then(release, release);
|
||||
throw cause;
|
||||
}
|
||||
try {
|
||||
abortIfNeeded(signal);
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
#snapshotPath(): string {
|
||||
@@ -511,6 +548,35 @@ const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled");
|
||||
};
|
||||
|
||||
const waitForOperation = async (
|
||||
operation: Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
if (signal === undefined) return operation;
|
||||
abortIfNeeded(signal);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const removeAbortListener = (): void =>
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
const onAbort = (): void => {
|
||||
removeAbortListener();
|
||||
reject(
|
||||
new ArtifactReaderFailure("cancelled", "ASAR operation cancelled"),
|
||||
);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
() => {
|
||||
removeAbortListener();
|
||||
resolve();
|
||||
},
|
||||
(cause: unknown) => {
|
||||
removeAbortListener();
|
||||
reject(cause);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const toArtifactPath = (listed: string): string => {
|
||||
const portable = sep === "\\" ? listed.replaceAll("\\", "/") : listed;
|
||||
return portable.replace(/^\/+|\/+$/gu, "");
|
||||
|
||||
Reference in new issue
Block a user