Skip to content

Commit 6c4976c

Browse files
authored
fix(cli): await trailing progress-log writes before settling download (#1122)
The download promise settled on a progressWrite captured by value, so a chunk write still in flight could recreate the progress log after cleanup cleared it, leaving a stale binary.progress.log behind. Chain each progress-log write onto the previous one and settle only after the write stream closes, so every write is awaited before cleanup runs. Fixes #1120
1 parent 0d97bf9 commit 6c4976c

4 files changed

Lines changed: 74 additions & 37 deletions

File tree

‎src/core/cliManager.ts‎

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -805,50 +805,50 @@ export class CliManager {
805805
: (buffer.byteLength / contentLength) * 100,
806806
});
807807
if (onProgress) {
808-
progressWrite = onProgress(
809-
written,
810-
Number.isNaN(contentLength) ? null : contentLength,
811-
).catch((error) => {
812-
this.output.warn(
813-
"Failed to write progress log:",
814-
errToStr(error),
815-
);
816-
});
808+
// Chain so awaiting the final progressWrite awaits every
809+
// progress-log write, not just the last one.
810+
progressWrite = progressWrite
811+
.then(() =>
812+
onProgress(
813+
written,
814+
Number.isNaN(contentLength) ? null : contentLength,
815+
),
816+
)
817+
.catch((error) => {
818+
this.output.warn(
819+
"Failed to write progress log:",
820+
errToStr(error),
821+
);
822+
});
817823
}
818824
});
819825
});
820826

821827
// Wait for the stream to end or error.
822828
return new Promise<boolean>((resolve, reject) => {
829+
// fs emits "close" only after every pending write callback, so
830+
// settling after it cannot race the trailing progress-log write.
831+
const settle = (settleFn: () => void): void => {
832+
writeStream.once("close", () => {
833+
void progressWrite.then(settleFn);
834+
});
835+
};
836+
const downloadError = (error: unknown): Error =>
837+
new Error(
838+
`Unable to download binary: ${errToStr(error, "no reason given")}`,
839+
);
840+
823841
writeStream.on("error", (error) => {
824842
readStream.destroy();
825-
void progressWrite.then(() =>
826-
reject(
827-
new Error(
828-
`Unable to download binary: ${errToStr(error, "no reason given")}`,
829-
),
830-
),
831-
);
843+
settle(() => reject(downloadError(error)));
832844
});
833845
readStream.on("error", (error) => {
834846
writeStream.close();
835-
void progressWrite.then(() =>
836-
reject(
837-
new Error(
838-
`Unable to download binary: ${errToStr(error, "no reason given")}`,
839-
),
840-
),
841-
);
847+
settle(() => reject(downloadError(error)));
842848
});
843849
readStream.on("close", () => {
844850
writeStream.close();
845-
void progressWrite.then(() => {
846-
if (cancelled) {
847-
resolve(false);
848-
} else {
849-
resolve(true);
850-
}
851-
});
851+
settle(() => resolve(!cancelled));
852852
});
853853
});
854854
},

‎test/unit/core/cliManager.concurrent.test.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,12 @@ function setupManager(testDir: string): CliManager {
9393
}
9494

9595
/**
96-
* Asserts the lock and progress files are removed. The lock directory can
97-
* briefly reappear while a peer re-acquires and releases it, so poll until gone.
96+
* Asserts the lock and progress files are removed. fetchBinary settles only
97+
* after trailing fs writes, so no polling is needed.
9898
*/
9999
async function expectLockFilesRemoved(binaryPath: string): Promise<void> {
100-
await vi.waitFor(async () => {
101-
await expect(fs.access(binaryPath + ".lock")).rejects.toThrow();
102-
await expect(fs.access(binaryPath + ".progress.log")).rejects.toThrow();
103-
});
100+
await expect(fs.access(binaryPath + ".lock")).rejects.toThrow();
101+
await expect(fs.access(binaryPath + ".progress.log")).rejects.toThrow();
104102
}
105103

106104
describe("CliManager Concurrent Downloads", () => {

‎test/unit/core/cliManager.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,14 @@ describe("CliManager", () => {
707707
}
708708
},
709709
);
710+
711+
it("waits for trailing fs writes before finishing, keeping the progress log cleaned up", async () => {
712+
const { manager, mockApi, withTrailingWriteFlush } = setupCliManager();
713+
withTrailingWriteFlush();
714+
expectPathsEqual(await manager.fetchBinary(mockApi), BINARY_PATH);
715+
await flushPendingIO();
716+
expect(memfs.existsSync(`${BINARY_PATH}.progress.log`)).toBe(false);
717+
});
710718
});
711719

712720
describe("Download Progress Tracking", () => {

‎test/unit/core/cliManagerHarness.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,32 @@ export function setupCliManager(basePath: string = BASE_PATH) {
112112
createMockStream(partial, { error: new Error("connection reset") }),
113113
);
114114

115+
/**
116+
* Serve a download whose fs write callbacks are held until close() is
117+
* called, like a real stream on a loaded runner.
118+
*/
119+
const withTrailingWriteFlush = () => {
120+
vi.spyOn(fs, "createWriteStream").mockImplementation((writePath) => {
121+
const pendingWrites: Array<() => void> = [];
122+
const stream = new EventEmitter() as fs.WriteStream;
123+
stream.write = ((chunk: Buffer, callback?: () => void) => {
124+
memfs.appendFileSync(String(writePath), chunk);
125+
if (callback) {
126+
pendingWrites.push(callback);
127+
}
128+
return true;
129+
}) as fs.WriteStream["write"];
130+
stream.close = () => {
131+
setImmediate(() => {
132+
pendingWrites.splice(0).forEach((flush) => flush());
133+
setImmediate(() => stream.emit("close"));
134+
});
135+
};
136+
return stream;
137+
});
138+
withSuccessfulDownload();
139+
};
140+
115141
/** Queue one HTTP response per signature source status. */
116142
const withSignatureResponses = (statuses: number[]) => {
117143
for (const status of statuses) {
@@ -134,7 +160,11 @@ export function setupCliManager(basePath: string = BASE_PATH) {
134160
const stream = new EventEmitter();
135161
(stream as unknown as fs.WriteStream).write = vi.fn();
136162
(stream as unknown as fs.WriteStream).close = vi.fn();
137-
setImmediate(() => stream.emit("error", new Error(message)));
163+
setImmediate(() => {
164+
stream.emit("error", new Error(message));
165+
// A real fs stream with autoClose emits "close" after "error".
166+
setImmediate(() => stream.emit("close"));
167+
});
138168
return stream as ReturnType<typeof memfs.createWriteStream>;
139169
});
140170
withHttpResponse(
@@ -183,6 +213,7 @@ export function setupCliManager(basePath: string = BASE_PATH) {
183213
withSignatureResponses,
184214
withInvalidSignature,
185215
withStreamError,
216+
withTrailingWriteFlush,
186217
event,
187218
noEvent,
188219
expectProps,

0 commit comments

Comments
 (0)