Skip to content

Commit edb87d6

Browse files
authored
feat(upgrade): progress bars for delta-patch apply and full download (#1281)
## What `sentry upgrade` gives no feedback during its slow phases. This adds two byte-driven progress bars. - **Apply phase** (the slow part of a delta upgrade): a determinate bar — `Applying 3 patch(es) [███████░░░░░░░░░] 22.1 MB / 45.0 MB`. Total is the **final binary size** (the last patch's declared `newSize`), i.e. the real output the user gets — not the sum of intermediate hop writes. Progress is byte-driven across the whole chain, so it advances smoothly rather than stalling per hop. - **Full-binary download**: an indeterminate byte counter — `Downloading 38.0 MB`. The decompressed size isn't known ahead of time (`Content-Length` covers only the compressed stream), so it shows an honest live byte count, not a fake fraction. Both bars are gated on interactive output via the existing `isPlainOutput()`, so they self-suppress under `NO_COLOR`, piped stdout, and CI. ## How - New `src/lib/progress.ts` — `makeByteProgress(label, totalBytes)`. Reuses the existing `formatBytes` (`formatters/numbers`) and `isPlainOutput` (`formatters/plain-detect`) helpers. Cosmetic-only: a render failure never aborts the upgrade, and **both** `onProgress` and `done()` are guarded against throwing streams. - An optional `onBytes` callback is threaded through `applyReaderToMemory` / `applyReaderToFile` → `applyPatchChainInMemory` → `applyPatchChain`, reporting bytes-written per output chunk. Purely additive — no behavior change to the apply/verify logic. - Apply bar created in `applyChainAndReturn`; download bar in `streamDecompressToFile`. ## Tests `test/lib/progress.test.ts` (7): determinate/indeterminate rendering, plain-output and non-TTY header-only behavior, byte accumulation, full-bar clamping, and the never-throws contract — **mutation-verified**: an unguarded `done()` turns the never-throws test RED. ## Verification - 22/22 (`progress` + `bspatch`) pass, `tsc --noEmit` clean, `biome check` clean. - Independent of #1279 (`MAX_OUTPUT_SIZE`) and #1280 (SWAR) — separate file regions, no overlap.
1 parent 9b0e23b commit edb87d6

7 files changed

Lines changed: 388 additions & 37 deletions

File tree

src/commands/cli/upgrade.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,8 @@ async function executeStandardUpgrade(opts: {
558558
channel === "nightly" && !versionArg ? NIGHTLY_TAG : undefined;
559559
const downloadResult = await withProgress(
560560
{ message: `Downloading ${target}...`, json },
561-
async () => executeUpgrade(method, target, downloadTag, offline)
561+
async (setMessage) =>
562+
executeUpgrade(method, target, downloadTag, offline, setMessage)
562563
);
563564

564565
if (downloadResult?.patchBytes) {
@@ -629,7 +630,8 @@ async function migrateToStandaloneForNightly(
629630
const downloadTag = versionArg ? undefined : NIGHTLY_TAG;
630631
const downloadResult = await withProgress(
631632
{ message: `Downloading ${target}...`, json },
632-
async () => executeUpgrade("curl", target, downloadTag)
633+
async (setMessage) =>
634+
executeUpgrade("curl", target, downloadTag, undefined, setMessage)
633635
);
634636

635637
if (downloadResult?.patchBytes) {

src/lib/bspatch.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,8 @@ async function transformPatch(
681681
async function applyReaderToFile(
682682
oldReader: OldReader,
683683
patchData: Uint8Array,
684-
destPath: string
684+
destPath: string,
685+
onBytes?: (bytes: number) => void
685686
): Promise<string> {
686687
const writer = createWriteStream(destPath);
687688
const hasher = createHash("sha256");
@@ -703,6 +704,7 @@ async function applyReaderToFile(
703704
}
704705
writer.write(chunk);
705706
hasher.update(chunk);
707+
onBytes?.(chunk.byteLength);
706708
});
707709
} finally {
708710
await new Promise<void>((resolve, reject) => {
@@ -735,7 +737,8 @@ async function applyReaderToFile(
735737
*/
736738
async function applyReaderToMemory(
737739
oldReader: OldReader,
738-
patchData: Uint8Array
740+
patchData: Uint8Array,
741+
onBytes?: (bytes: number) => void
739742
): Promise<Uint8Array> {
740743
// Preallocate the exact output size from the header so chunks can be copied
741744
// in place — avoids a final concat pass over ~100 MB of output.
@@ -746,6 +749,7 @@ async function applyReaderToMemory(
746749
await transformPatch(oldReader, patchData, (chunk) => {
747750
output.set(chunk, offset);
748751
offset += chunk.byteLength;
752+
onBytes?.(chunk.byteLength);
749753
});
750754

751755
return output;
@@ -794,7 +798,8 @@ export function applyPatchToMemory(
794798
export async function applyPatchChainInMemory(
795799
oldPath: string,
796800
patches: Uint8Array[],
797-
destPath: string
801+
destPath: string,
802+
onBytes?: (bytes: number) => void
798803
): Promise<string> {
799804
if (patches.length === 0) {
800805
throw new Error("Cannot apply an empty patch chain");
@@ -812,7 +817,7 @@ export async function applyPatchChainInMemory(
812817
if (!patch) {
813818
throw new Error(`Missing patch at index ${i}`);
814819
}
815-
const next = await applyReaderToMemory(reader, patch);
820+
const next = await applyReaderToMemory(reader, patch, onBytes);
816821
await reader.close();
817822
reader = new MemoryOldReader(next);
818823
}
@@ -822,7 +827,7 @@ export async function applyPatchChainInMemory(
822827
if (!finalPatch) {
823828
throw new Error("Missing final patch");
824829
}
825-
return await applyReaderToFile(reader, finalPatch, destPath);
830+
return await applyReaderToFile(reader, finalPatch, destPath, onBytes);
826831
} finally {
827832
await reader.close();
828833
}

src/lib/delta-upgrade.ts

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
isDowngrade,
2828
isNightlyVersion,
2929
} from "./binary.js";
30-
import { applyPatchChainInMemory } from "./bspatch.js";
30+
import { applyPatchChainInMemory, parsePatchHeader } from "./bspatch.js";
3131
import { CLI_VERSION } from "./constants.js";
3232
import { customFetch } from "./custom-ca.js";
3333
import { formatBytes } from "./formatters/numbers.js";
@@ -40,6 +40,7 @@ import {
4040
} from "./ghcr.js";
4141
import { logger } from "./logger.js";
4242
import { loadCachedChain, savePatchesToCache } from "./patch-cache.js";
43+
import { makeByteProgress, type SetMessage } from "./progress.js";
4344
import { withTracing, withTracingSpan } from "./telemetry.js";
4445

4546
/** Scoped logger for delta upgrade operations */
@@ -835,11 +836,13 @@ export async function resolveNightlyChain(opts: {
835836
* @param destPath - Path to write the patched binary
836837
* @returns Delta result with SHA-256 and size info, or null if delta is unavailable
837838
*/
839+
// biome-ignore lint/nursery/useMaxParams: established 4-param shape; setMessage is a defaulted spinner-progress extension
838840
export function attemptDeltaUpgrade(
839841
targetVersion: string,
840842
oldBinaryPath: string,
841843
destPath: string,
842-
offline?: boolean
844+
offline?: boolean,
845+
setMessage?: SetMessage
843846
): Promise<DeltaResult | null> {
844847
if (!canAttemptDelta(targetVersion)) {
845848
return Promise.resolve(null);
@@ -865,13 +868,15 @@ export function attemptDeltaUpgrade(
865868
targetVersion,
866869
oldBinaryPath,
867870
destPath,
868-
offline
871+
offline,
872+
setMessage
869873
)
870874
: await resolveStableDelta(
871875
targetVersion,
872876
oldBinaryPath,
873877
destPath,
874-
offline
878+
offline,
879+
setMessage
875880
);
876881

877882
if (result) {
@@ -977,13 +982,41 @@ async function tryLoadCachedChain(
977982
async function applyChainAndReturn(
978983
chain: PatchChain,
979984
oldBinaryPath: string,
980-
destPath: string
985+
destPath: string,
986+
setMessage?: SetMessage
981987
): Promise<DeltaResult> {
982-
const sha256 = await withTracing(
983-
"apply-patch-chain",
984-
"upgrade.delta.apply",
985-
() => applyPatchChain(chain, oldBinaryPath, destPath)
988+
// Progress for the apply phase. The `onBytes` callback fires for every
989+
// output byte of every hop — intermediate in-memory hops AND the final disk
990+
// write — so the total must be the SUM of all hops' output sizes (each
991+
// patch's declared `newSize`), not just the final binary size. Using only
992+
// the last patch's size would make a multi-hop bar hit 100% after hop 1 and
993+
// then freeze. Feeds the surrounding spinner via setMessage — cosmetic, a
994+
// render failure never aborts the apply.
995+
let totalBytes: number | null = 0;
996+
try {
997+
for (const p of chain.patches) {
998+
totalBytes += parsePatchHeader(p.data).newSize;
999+
}
1000+
} catch {
1001+
// Header parse is best-effort for the bar; a corrupt header is rejected
1002+
// properly downstream. Leave the bar indeterminate in that case.
1003+
totalBytes = null;
1004+
}
1005+
const progress = makeByteProgress(
1006+
`Applying ${chain.patches.length} patch(es)`,
1007+
totalBytes,
1008+
setMessage
9861009
);
1010+
1011+
let sha256: string;
1012+
try {
1013+
sha256 = await withTracing("apply-patch-chain", "upgrade.delta.apply", () =>
1014+
applyPatchChain(chain, oldBinaryPath, destPath, progress.onProgress)
1015+
);
1016+
} finally {
1017+
progress.done();
1018+
}
1019+
9871020
return {
9881021
sha256,
9891022
patchBytes: chain.totalSize,
@@ -1002,6 +1035,8 @@ type ResolveAndApplyOpts = {
10021035
channel: string;
10031036
/** When true, skip the network fallback — only use cached patches */
10041037
offline?: boolean;
1038+
/** Spinner message setter for apply-phase progress (from withProgress) */
1039+
setMessage?: SetMessage;
10051040
};
10061041

10071042
/**
@@ -1021,6 +1056,7 @@ async function resolveAndApplyDelta(
10211056
resolveFromNetwork,
10221057
channel,
10231058
offline,
1059+
setMessage,
10241060
} = opts;
10251061
// Check patch cache first — enables fully offline upgrades
10261062
const cached = await tryLoadCachedChain(CLI_VERSION, targetVersion);
@@ -1029,7 +1065,12 @@ async function resolveAndApplyDelta(
10291065
log.debug(
10301066
`Using cached patches: ${cached.patches.length} patch(es), ${formatBytes(cached.totalSize)} total`
10311067
);
1032-
return await applyChainAndReturn(cached, oldBinaryPath, destPath);
1068+
return await applyChainAndReturn(
1069+
cached,
1070+
oldBinaryPath,
1071+
destPath,
1072+
setMessage
1073+
);
10331074
}
10341075

10351076
// In offline mode, skip the network resolution entirely — if the cache
@@ -1051,7 +1092,7 @@ async function resolveAndApplyDelta(
10511092
return null;
10521093
}
10531094

1054-
return await applyChainAndReturn(chain, oldBinaryPath, destPath);
1095+
return await applyChainAndReturn(chain, oldBinaryPath, destPath, setMessage);
10551096
}
10561097

10571098
/**
@@ -1062,11 +1103,13 @@ async function resolveAndApplyDelta(
10621103
*
10631104
* @returns Delta result with SHA-256 and size info, or null if delta is unavailable
10641105
*/
1106+
// biome-ignore lint/nursery/useMaxParams: established 4-param shape; setMessage is a defaulted spinner-progress extension
10651107
export function resolveStableDelta(
10661108
targetVersion: string,
10671109
oldBinaryPath: string,
10681110
destPath: string,
1069-
offline?: boolean
1111+
offline?: boolean,
1112+
setMessage?: SetMessage
10701113
): Promise<DeltaResult | null> {
10711114
return resolveAndApplyDelta({
10721115
targetVersion,
@@ -1078,6 +1121,7 @@ export function resolveStableDelta(
10781121
),
10791122
channel: "stable",
10801123
offline,
1124+
setMessage,
10811125
});
10821126
}
10831127

@@ -1089,11 +1133,13 @@ export function resolveStableDelta(
10891133
*
10901134
* @returns Delta result with SHA-256 and size info, or null if delta is unavailable
10911135
*/
1136+
// biome-ignore lint/nursery/useMaxParams: established 4-param shape; setMessage is a defaulted spinner-progress extension
10921137
export function resolveNightlyDelta(
10931138
targetVersion: string,
10941139
oldBinaryPath: string,
10951140
destPath: string,
1096-
offline?: boolean
1141+
offline?: boolean,
1142+
setMessage?: SetMessage
10971143
): Promise<DeltaResult | null> {
10981144
return resolveAndApplyDelta({
10991145
targetVersion,
@@ -1102,6 +1148,7 @@ export function resolveNightlyDelta(
11021148
resolveFromNetwork: () => resolveNightlyChainWithContext(targetVersion),
11031149
channel: "nightly",
11041150
offline,
1151+
setMessage,
11051152
});
11061153
}
11071154

@@ -1178,7 +1225,8 @@ async function resolveNightlyChainWithContext(
11781225
export function applyPatchChain(
11791226
chain: PatchChain,
11801227
oldBinaryPath: string,
1181-
destPath: string
1228+
destPath: string,
1229+
onBytes?: (bytes: number) => void
11821230
): Promise<string> {
11831231
return withTracingSpan(
11841232
"apply-patches",
@@ -1197,7 +1245,8 @@ export function applyPatchChain(
11971245
const sha256 = await applyPatchChainInMemory(
11981246
oldBinaryPath,
11991247
chain.patches.map((p) => p.data),
1200-
destPath
1248+
destPath,
1249+
onBytes
12011250
);
12021251

12031252
// Verify the final SHA-256 matches

src/lib/progress.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Byte-driven progress reporting for long-running upgrade phases (patch apply,
3+
* full-binary download).
4+
*
5+
* sentry upgrade already runs under `withProgress`, which animates a spinner
6+
* and passes down a `setMessage(text)` callback. To avoid two competing
7+
* in-place redraws fighting over the same terminal line, this helper does NOT
8+
* draw its own bar — it formats a compact progress string and pushes it into
9+
* that `setMessage` callback, so the byte counter rides on the existing spinner.
10+
*
11+
* Design contract:
12+
* - Cosmetic ONLY — a formatting/callback failure must never abort the work.
13+
* - Determinate ("152 MB / 310 MB [====> ] 49%") when a byte `total` is given;
14+
* an indeterminate byte counter ("38 MB") otherwise (the decompressed
15+
* download size isn't known ahead of time).
16+
* - Updates are throttled so a fast byte stream doesn't spam the spinner.
17+
*/
18+
19+
import { formatBytes } from "./formatters/numbers.js";
20+
21+
/** Callback that sets the surrounding spinner's message text. */
22+
export type SetMessage = (message: string) => void;
23+
24+
export type ByteProgress = {
25+
/** Report `bytes` additional bytes processed since the last call. */
26+
onProgress: (bytes: number) => void;
27+
/** Emit a final message reflecting the total processed (best-effort). */
28+
done: () => void;
29+
};
30+
31+
const BAR_WIDTH = 16;
32+
/** Minimum gap between spinner message updates. */
33+
const THROTTLE_MS = 100;
34+
35+
function renderBar(frac: number, width = BAR_WIDTH): string {
36+
const filled = Math.max(0, Math.min(width, Math.round(width * frac)));
37+
return "█".repeat(filled) + "░".repeat(width - filled);
38+
}
39+
40+
/**
41+
* Create a byte-progress reporter that feeds a spinner `setMessage` callback.
42+
*
43+
* @param label - Short label prefix (e.g. "Applying 3 patch(es)").
44+
* @param totalBytes - Expected total bytes; `null` renders an indeterminate
45+
* byte counter instead of a bar.
46+
* @param setMessage - Spinner message setter (from `withProgress`). When
47+
* undefined (JSON mode / non-TTY / no surrounding spinner), this is a no-op
48+
* so nothing is drawn.
49+
* @param nowMs - Injectable clock for tests.
50+
*/
51+
export function makeByteProgress(
52+
label: string,
53+
totalBytes: number | null,
54+
setMessage?: SetMessage,
55+
nowMs: () => number = Date.now
56+
): ByteProgress {
57+
let written = 0;
58+
let lastEmit = 0;
59+
60+
const format = (): string => {
61+
if (totalBytes === null || totalBytes <= 0) {
62+
return `${label} ${formatBytes(written)}`;
63+
}
64+
const frac = Math.min(written / totalBytes, 1);
65+
const pct = Math.round(frac * 100);
66+
return (
67+
`${label} [${renderBar(frac)}] ` +
68+
`${formatBytes(written)} / ${formatBytes(totalBytes)} (${pct}%)`
69+
);
70+
};
71+
72+
const emit = (): void => {
73+
// Cosmetic only — a formatting or callback failure must never propagate.
74+
try {
75+
setMessage?.(format());
76+
} catch {
77+
// ignore — progress is cosmetic
78+
}
79+
};
80+
81+
const onProgress = (bytes: number): void => {
82+
written += bytes;
83+
if (!setMessage) {
84+
return;
85+
}
86+
const now = nowMs();
87+
if (now - lastEmit < THROTTLE_MS) {
88+
return;
89+
}
90+
lastEmit = now;
91+
emit();
92+
};
93+
94+
// Emit a final, un-throttled message so the last state is always shown.
95+
const done = (): void => {
96+
emit();
97+
};
98+
99+
return { onProgress, done };
100+
}

0 commit comments

Comments
 (0)