tmux-ide 2.9.16 → 2.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -208,10 +208,16 @@ export function isVisibleFleetSession(name: string): boolean {
208
208
  export function readAdoptedSessionNames(runTmux: TmuxRunner = _tmuxRunner): string[] | null {
209
209
  let raw: string;
210
210
  try {
211
- raw = runTmux(["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`]);
211
+ raw = runTmux(ADOPTED_SESSIONS_ARGS);
212
212
  } catch {
213
213
  return null;
214
214
  }
215
+ return parseAdoptedSessionNames(raw);
216
+ }
217
+
218
+ const ADOPTED_SESSIONS_ARGS = ["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`];
219
+
220
+ function parseAdoptedSessionNames(raw: string): string[] {
215
221
  const names: string[] = [];
216
222
  for (const line of raw.split("\n")) {
217
223
  if (!line) continue;
@@ -312,18 +318,77 @@ export function readAdoptedFleet(
312
318
  const nativeEpoch = nativeServerEpoch?.() ?? null;
313
319
  const adopted = readAdoptedSessionNames(runTmux);
314
320
  if (adopted === null) return null;
315
- const adoptedSet = new Set(adopted);
316
- if (adoptedSet.size === 0) return [];
317
-
318
- const appCreatedSessions = new Set(registry.list().map((workspace) => workspace.sessionName));
321
+ if (adopted.length === 0) return [];
322
+ let panesRaw: string;
323
+ try {
324
+ panesRaw = runTmux(FLEET_PANES_ARGS);
325
+ } catch {
326
+ panesRaw = "";
327
+ }
328
+ return assembleAdoptedFleet(
329
+ adopted,
330
+ panesRaw,
331
+ registry,
332
+ nativeEpoch,
333
+ resolveInteractionEndpoint,
334
+ nativeServerEpoch,
335
+ );
336
+ }
319
337
 
320
- const panesBySession = new Map<string, FleetPaneFacts[]>();
338
+ /**
339
+ * {@link readAdoptedFleet} over an asynchronous tmux runner: the same two reads
340
+ * in the same order, without blocking the event loop while tmux answers (#709).
341
+ */
342
+ export async function readAdoptedFleetAsync(
343
+ registry: { list(): { sessionName: string }[] },
344
+ runTmux: (args: string[]) => Promise<string>,
345
+ resolveInteractionEndpoint?: (
346
+ sessionName: string,
347
+ pane: FleetPaneFacts,
348
+ ) => Extract<InteractionPaneEndpoint, { kind: "pane" }> | null,
349
+ nativeServerEpoch?: () => string | null,
350
+ ): Promise<FleetSessionFacts[] | null> {
351
+ const nativeEpoch = nativeServerEpoch?.() ?? null;
352
+ let sessionsRaw: string;
353
+ try {
354
+ sessionsRaw = await runTmux(ADOPTED_SESSIONS_ARGS);
355
+ } catch {
356
+ return null;
357
+ }
358
+ const adopted = parseAdoptedSessionNames(sessionsRaw);
359
+ if (adopted.length === 0) return [];
321
360
  let panesRaw: string;
322
361
  try {
323
- panesRaw = runTmux(["list-panes", "-a", "-F", FLEET_PANE_FORMAT]);
362
+ panesRaw = await runTmux(FLEET_PANES_ARGS);
324
363
  } catch {
325
364
  panesRaw = "";
326
365
  }
366
+ return assembleAdoptedFleet(
367
+ adopted,
368
+ panesRaw,
369
+ registry,
370
+ nativeEpoch,
371
+ resolveInteractionEndpoint,
372
+ nativeServerEpoch,
373
+ );
374
+ }
375
+
376
+ const FLEET_PANES_ARGS = ["list-panes", "-a", "-F", FLEET_PANE_FORMAT];
377
+
378
+ function assembleAdoptedFleet(
379
+ adopted: string[],
380
+ panesRaw: string,
381
+ registry: { list(): { sessionName: string }[] },
382
+ nativeEpoch: string | null,
383
+ resolveInteractionEndpoint?: (
384
+ sessionName: string,
385
+ pane: FleetPaneFacts,
386
+ ) => Extract<InteractionPaneEndpoint, { kind: "pane" }> | null,
387
+ nativeServerEpoch?: () => string | null,
388
+ ): FleetSessionFacts[] {
389
+ const adoptedSet = new Set(adopted);
390
+ const appCreatedSessions = new Set(registry.list().map((workspace) => workspace.sessionName));
391
+ const panesBySession = new Map<string, FleetPaneFacts[]>();
327
392
  for (const line of panesRaw.split("\n")) {
328
393
  if (!line) continue;
329
394
  const fields = line.split(FLEET_FIELD_SEPARATOR);
@@ -0,0 +1,40 @@
1
+ import type { InteractionPaneEndpoint } from "@tmux-ide/contracts";
2
+ import {
3
+ readAdoptedFleet,
4
+ readAdoptedFleetAsync,
5
+ type FleetPaneFacts,
6
+ type FleetSessionFacts,
7
+ } from "../command-center/discovery.ts";
8
+
9
+ /** The two runners a catalog fleet read may use; both are fenced to one tmux server generation. */
10
+ export interface CatalogFleetRunners {
11
+ /** The session-mutation fence; only its `active` state is read here. */
12
+ readonly fence: { readonly active: boolean };
13
+ /** Synchronous runner wrapped by that fence: inside a mutation it adds the session check. */
14
+ readonly fencedSync: (args: string[]) => string;
15
+ /** Asynchronous runner fenced to the same server generation. */
16
+ readonly generationFencedAsync: (args: string[]) => Promise<string>;
17
+ }
18
+
19
+ /**
20
+ * Read the catalog fleet without blocking the event loop (#709), except inside
21
+ * a session mutation, where the read keeps the fence's synchronous runner.
22
+ */
23
+ export async function readCatalogFleet(
24
+ registry: { list(): { sessionName: string }[] },
25
+ runners: CatalogFleetRunners,
26
+ resolveInteractionEndpoint?: (
27
+ sessionName: string,
28
+ pane: FleetPaneFacts,
29
+ ) => Extract<InteractionPaneEndpoint, { kind: "pane" }> | null,
30
+ nativeServerEpoch?: () => string | null,
31
+ ): Promise<FleetSessionFacts[] | null> {
32
+ return runners.fence.active
33
+ ? readAdoptedFleet(registry, runners.fencedSync, resolveInteractionEndpoint, nativeServerEpoch)
34
+ : readAdoptedFleetAsync(
35
+ registry,
36
+ runners.generationFencedAsync,
37
+ resolveInteractionEndpoint,
38
+ nativeServerEpoch,
39
+ );
40
+ }
@@ -117,7 +117,12 @@ import {
117
117
  resolveWorkspacePaneTmuxAuthority,
118
118
  WorkspacePaneCreationAuthority,
119
119
  } from "./workspace-pane-creation.ts";
120
- import { discoverLiveSessionSummaries, readAdoptedFleet } from "../command-center/discovery.ts";
120
+ import {
121
+ discoverLiveSessionSummaries,
122
+ readAdoptedFleet,
123
+ type FleetSessionFacts,
124
+ } from "../command-center/discovery.ts";
125
+ import { readCatalogFleet } from "./catalog-fleet-read.ts";
121
126
  import { WorkspaceOpenAuthority } from "./workspace-open.ts";
122
127
  import { WorkspacePromotionAuthority } from "./workspace-promotion.ts";
123
128
  import { WorkspaceOpenHandoffCoordinator } from "./workspace-open-handoff.ts";
@@ -2103,18 +2108,28 @@ async function startEmbeddedDaemonGeneration(
2103
2108
  paneStreamRuntime,
2104
2109
  catalogLiveSessions: () => discoverLiveSessionSummaries(catalogTmuxRunner),
2105
2110
  catalogFleet: async () => {
2106
- const fleet = readAdoptedFleet(
2111
+ const endpoint = (sessionName: string, pane: FleetSessionFacts["panes"][number]) =>
2112
+ pane.semanticPaneId
2113
+ ? (interactionEvidence?.captureInventoryEndpoint(
2114
+ sessionName,
2115
+ pane.runtimePaneId,
2116
+ pane.semanticPaneId,
2117
+ ) ?? null)
2118
+ : null;
2119
+ const epoch = () => observationSelector?.nativeServerEpoch ?? null;
2120
+ // Same pinned server generation either way. Outside a session
2121
+ // mutation the read must not block the event loop: every concurrent
2122
+ // catalog request queued behind its spawnSync (#709). Inside one,
2123
+ // keep the fenced synchronous runner the mutation relies on.
2124
+ const fleet = await readCatalogFleet(
2107
2125
  workspaceRegistry,
2108
- nativeGenerationTmuxRunner,
2109
- (sessionName, pane) =>
2110
- pane.semanticPaneId
2111
- ? (interactionEvidence?.captureInventoryEndpoint(
2112
- sessionName,
2113
- pane.runtimePaneId,
2114
- pane.semanticPaneId,
2115
- ) ?? null)
2116
- : null,
2117
- () => observationSelector?.nativeServerEpoch ?? null,
2126
+ {
2127
+ fence: sessionMutationFence,
2128
+ fencedSync: nativeGenerationTmuxRunner,
2129
+ generationFencedAsync: (args) => fleetFactsTmuxRunner(args),
2130
+ },
2131
+ endpoint,
2132
+ epoch,
2118
2133
  );
2119
2134
  if (!fleet) return null;
2120
2135
  const names = await readTeamMemberships(
@@ -149,13 +149,20 @@ export async function manageDaemonService(
149
149
  refused("The service manager loaded a different definition; refusing to control it");
150
150
  return state;
151
151
  };
152
- const ready = async (previousInstance?: string) => {
152
+ // `retiredPid` is the process this restart terminated. It can publish a record
153
+ // while it shuts down (a KeepAlive relaunch just before the restart), so it is
154
+ // never the replacement, whatever its instance id.
155
+ const ready = async (previousInstance?: string, retiredPid?: number | null) => {
153
156
  const deadline = Date.now() + deps.waitMs;
154
157
  do {
155
158
  const state = deps.inspect();
156
159
  if (!boundTo(state, plan.supervisionId))
157
160
  refused("Service reservation changed during startup");
158
- if (state.status === "valid" && state.info.instanceId !== previousInstance) {
161
+ if (
162
+ state.status === "valid" &&
163
+ state.info.instanceId !== previousInstance &&
164
+ state.info.pid !== retiredPid
165
+ ) {
159
166
  const identity = await deps.probe(state.info);
160
167
  const service = await inspectManager();
161
168
  if (
@@ -283,9 +290,15 @@ export async function manageDaemonService(
283
290
  }
284
291
  const before = deps.inspect();
285
292
  if (action === "install" && service.active) return await ready();
286
- if (service.loaded) await manager.restart(service.pid !== null);
293
+ // Read the manager again: a relaunch may have replaced the process while this
294
+ // waited for the lock, and the restart terminates whichever one runs now.
295
+ const current = service.loaded ? await inspectManager() : service;
296
+ if (current.loaded) await manager.restart(current.pid !== null);
287
297
  else await manager.install();
288
- return await ready(before.status === "valid" ? before.info.instanceId : undefined);
298
+ return await ready(
299
+ before.status === "valid" ? before.info.instanceId : undefined,
300
+ current.loaded ? current.pid : null,
301
+ );
289
302
  } catch (error) {
290
303
  // Never discard ownership after an uncertain OS activation. The retained
291
304
  // record/reservation gives the explicit remove/restart commands a recovery path.
@@ -59,6 +59,8 @@ export class TmuxServerOwners<T extends DisposableTmuxServerOwner> {
59
59
  #disposed = false;
60
60
  #disposePromise: Promise<void> | null = null;
61
61
  constructor(readonly options: TmuxServerOwnersOptions<T>) {}
62
+ #queuedRefresh: Promise<readonly TmuxServerDescriptor[]> | null = null;
63
+ #queuedRefreshTail: Promise<unknown> | null = null;
62
64
  #serial<R>(work: () => Promise<R>): Promise<R> {
63
65
  const run = this.#tail.then(() => {
64
66
  if (this.#disposed) throw new TmuxServerScopeError("disposed");
@@ -178,11 +180,25 @@ export class TmuxServerOwners<T extends DisposableTmuxServerOwner> {
178
180
  this.#entries.set(scope.serverId, { registration, scope, observation, owner });
179
181
  }
180
182
  refresh(): Promise<readonly TmuxServerDescriptor[]> {
181
- return this.#serial(async () => {
183
+ // A refresh still waiting its turn has not probed anything yet, so it is
184
+ // as fresh for a later caller as a new one: share it instead of queuing
185
+ // another full refresh behind it (#709). Once it starts, callers queue anew.
186
+ // Share it only while nothing has been queued behind it, so a refresh
187
+ // stays ordered after any mutation queued before the caller asked.
188
+ if (this.#queuedRefresh && this.#tail === this.#queuedRefreshTail) return this.#queuedRefresh;
189
+ const queued = this.#serial(async () => {
190
+ if (this.#queuedRefresh === queued) this.#queuedRefresh = null;
182
191
  // Sequential admission bounds subprocess work and avoids alias races.
183
192
  for (const entry of this.#entries.values()) await this.#refresh(entry);
184
193
  return this.list();
185
194
  });
195
+ this.#queuedRefresh = queued;
196
+ this.#queuedRefreshTail = this.#tail;
197
+ const settle = () => {
198
+ if (this.#queuedRefresh === queued) this.#queuedRefresh = null;
199
+ };
200
+ queued.then(settle, settle);
201
+ return queued;
186
202
  }
187
203
  current(scopeInput: TmuxServerScope): T {
188
204
  const scope = TmuxServerScopeSchemaZ.parse(scopeInput);
@@ -43,9 +43,19 @@ export const MIN_TUI_BINARY_BYTES = 10 * 1024 * 1024;
43
43
  /** Hard download/decompression ceilings: release binaries are currently ~70MB. */
44
44
  export const MAX_TUI_COMPRESSED_BYTES = 128 * 1024 * 1024;
45
45
  export const MAX_TUI_BINARY_BYTES = 256 * 1024 * 1024;
46
- export const TUI_DOWNLOAD_TIMEOUT_MS = 30_000;
46
+ /** Abort a request when no bytes arrive for this long; a slow transfer never trips it. */
47
+ export const TUI_DOWNLOAD_STALL_TIMEOUT_MS = 30_000;
48
+ /** One budget for the manifest, the binary, and every retry: ~145 KB/s at the size ceiling. */
49
+ export const TUI_DOWNLOAD_TOTAL_TIMEOUT_MS = 15 * 60_000;
50
+ /** Attempts per file; only transient failures are retried. */
51
+ export const TUI_DOWNLOAD_MAX_ATTEMPTS = 3;
52
+ export const TUI_DOWNLOAD_RETRY_DELAY_MS = 1_000;
47
53
 
48
54
  const MAX_CHECKSUM_MANIFEST_BYTES = 4 * 1024;
55
+ /** A second installer says why it is quiet once the wait is longer than a lock handover. */
56
+ const TUI_DOWNLOAD_LOCK_NOTICE_MS = 2_000;
57
+ /** Lets a lock waiter outlast the holder's verify and install after its download budget. */
58
+ const TUI_DOWNLOAD_LOCK_MARGIN_MS = 5_000;
49
59
 
50
60
  const SUPPORTED: Record<string, TuiPlatformTag> = {
51
61
  "darwin-arm64": "darwin-arm64",
@@ -162,9 +172,19 @@ interface DownloadLimits {
162
172
  readonly minBinaryBytes: number;
163
173
  readonly maxCompressedBytes: number;
164
174
  readonly maxBinaryBytes: number;
165
- readonly timeoutMs: number;
166
175
  }
167
176
 
177
+ interface DownloadPolicy {
178
+ readonly stallTimeoutMs: number;
179
+ readonly deadline: number;
180
+ readonly totalTimeoutMs: number;
181
+ readonly maxAttempts: number;
182
+ readonly retryDelayMs: number;
183
+ }
184
+
185
+ /** A failure a retry cannot fix: the release asset is missing or breaks a hard limit. */
186
+ class PermanentDownloadError extends Error {}
187
+
168
188
  type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
169
189
 
170
190
  function checksumPath(path: string): string {
@@ -269,36 +289,61 @@ function purgeCachedBinary(path: string): void {
269
289
  }
270
290
  }
271
291
 
292
+ function isTransientHttpStatus(status: number): boolean {
293
+ return status === 408 || status === 429 || status >= 500;
294
+ }
295
+
272
296
  async function readBoundedResponse(
273
297
  fetchImpl: FetchLike,
274
298
  url: string,
275
299
  maxBytes: number,
276
- timeoutMs: number,
300
+ policy: DownloadPolicy,
277
301
  ): Promise<Buffer> {
278
302
  const controller = new AbortController();
279
- const timer = setTimeout(() => controller.abort(), timeoutMs);
303
+ let total = 0;
304
+ let timeoutMessage: string | null = null;
305
+ const abortWith = (message: () => string) => () => {
306
+ timeoutMessage = message();
307
+ controller.abort();
308
+ };
309
+ const overallTimer = setTimeout(
310
+ abortWith(() => `download did not finish within ${policy.totalTimeoutMs}ms`),
311
+ Math.max(0, policy.deadline - Date.now()),
312
+ );
313
+ const onStall = abortWith(
314
+ () => `download stalled: no data for ${policy.stallTimeoutMs}ms after ${total} bytes`,
315
+ );
316
+ let stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
317
+ const markProgress = () => {
318
+ clearTimeout(stallTimer);
319
+ stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
320
+ };
280
321
  try {
281
322
  const response = await fetchImpl(url, { signal: controller.signal });
282
323
  if (!response.ok) {
283
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
324
+ const message = `HTTP ${response.status} ${response.statusText}`;
325
+ if (isTransientHttpStatus(response.status)) throw new Error(message);
326
+ throw new PermanentDownloadError(message);
284
327
  }
328
+ markProgress();
285
329
  const declaredLength = Number(response.headers.get("content-length"));
286
330
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
287
- throw new Error(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
331
+ throw new PermanentDownloadError(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
288
332
  }
289
333
  if (!response.body) throw new Error("response has no body");
290
334
 
291
335
  const chunks: Buffer[] = [];
292
- let total = 0;
293
336
  const reader = response.body.getReader();
294
337
  try {
295
338
  while (true) {
296
339
  const { done, value } = await reader.read();
297
340
  if (done) break;
341
+ if (value.byteLength === 0) continue;
342
+ markProgress();
298
343
  total += value.byteLength;
299
344
  if (total > maxBytes) {
300
345
  await reader.cancel();
301
- throw new Error(`download exceeds ${maxBytes} bytes`);
346
+ throw new PermanentDownloadError(`download exceeds ${maxBytes} bytes`);
302
347
  }
303
348
  chunks.push(Buffer.from(value));
304
349
  }
@@ -307,12 +352,39 @@ async function readBoundedResponse(
307
352
  }
308
353
  return Buffer.concat(chunks, total);
309
354
  } catch (error) {
310
- if (controller.signal.aborted) {
311
- throw new Error(`download timed out after ${timeoutMs}ms`, { cause: error });
312
- }
355
+ if (timeoutMessage) throw new Error(timeoutMessage, { cause: error });
313
356
  throw error;
314
357
  } finally {
315
- clearTimeout(timer);
358
+ clearTimeout(overallTimer);
359
+ clearTimeout(stallTimer);
360
+ // Releases the connection of any response whose body was not fully read.
361
+ controller.abort();
362
+ }
363
+ }
364
+
365
+ async function readWithRetries(
366
+ fetchImpl: FetchLike,
367
+ url: string,
368
+ maxBytes: number,
369
+ policy: DownloadPolicy,
370
+ log: (msg: string) => void,
371
+ ): Promise<Buffer> {
372
+ for (let attempt = 1; ; attempt++) {
373
+ try {
374
+ return await readBoundedResponse(fetchImpl, url, maxBytes, policy);
375
+ } catch (error) {
376
+ const delayMs = policy.retryDelayMs * attempt;
377
+ if (
378
+ error instanceof PermanentDownloadError ||
379
+ attempt >= policy.maxAttempts ||
380
+ Date.now() + delayMs >= policy.deadline
381
+ ) {
382
+ throw error;
383
+ }
384
+ const message = error instanceof Error ? error.message : String(error);
385
+ log(`download attempt ${attempt}/${policy.maxAttempts} failed (${message}); retrying`);
386
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
387
+ }
316
388
  }
317
389
  }
318
390
 
@@ -333,8 +405,11 @@ export async function downloadTuiBinary(
333
405
  home?: string;
334
406
  tag?: TuiPlatformTag | null;
335
407
  fetch?: FetchLike;
336
- timeoutMs?: number;
337
- limits?: Partial<Omit<DownloadLimits, "timeoutMs">>;
408
+ stallTimeoutMs?: number;
409
+ totalTimeoutMs?: number;
410
+ maxAttempts?: number;
411
+ retryDelayMs?: number;
412
+ limits?: Partial<DownloadLimits>;
338
413
  } = {},
339
414
  ): Promise<{ path: string; bytes: number }> {
340
415
  if (resolveRuntimeNamespace().development)
@@ -356,13 +431,17 @@ export async function downloadTuiBinary(
356
431
  minBinaryBytes: opts.limits?.minBinaryBytes ?? MIN_TUI_BINARY_BYTES,
357
432
  maxCompressedBytes: opts.limits?.maxCompressedBytes ?? MAX_TUI_COMPRESSED_BYTES,
358
433
  maxBinaryBytes: opts.limits?.maxBinaryBytes ?? MAX_TUI_BINARY_BYTES,
359
- timeoutMs: opts.timeoutMs ?? TUI_DOWNLOAD_TIMEOUT_MS,
360
434
  };
435
+ const totalTimeoutMs = opts.totalTimeoutMs ?? TUI_DOWNLOAD_TOTAL_TIMEOUT_MS;
361
436
  mkdirSync(dirname(dest), { recursive: true });
437
+ const lockNotice = setTimeout(
438
+ () => log("waiting for another tmux-ide download of this version to finish"),
439
+ TUI_DOWNLOAD_LOCK_NOTICE_MS,
440
+ );
362
441
  const releaseLock = await acquirePrivateOperationLock(
363
442
  downloadLockPath(dest),
364
- limits.timeoutMs * 2 + 5_000,
365
- );
443
+ totalTimeoutMs + TUI_DOWNLOAD_LOCK_MARGIN_MS,
444
+ ).finally(() => clearTimeout(lockNotice));
366
445
  const suffix = `${process.pid}.${randomUUID()}.tmp`;
367
446
  const binaryTmp = `${dest}.${suffix}`;
368
447
  const checksumTmp = `${checksumPath(dest)}.${suffix}`;
@@ -372,22 +451,35 @@ export async function downloadTuiBinary(
372
451
  purgeCachedBinary(dest);
373
452
 
374
453
  const fetchImpl = opts.fetch ?? globalThis.fetch;
454
+ // Starts after the lock so the budget matches what a lock waiter allows for.
455
+ const policy: DownloadPolicy = {
456
+ stallTimeoutMs: opts.stallTimeoutMs ?? TUI_DOWNLOAD_STALL_TIMEOUT_MS,
457
+ deadline: Date.now() + totalTimeoutMs,
458
+ totalTimeoutMs,
459
+ maxAttempts: opts.maxAttempts ?? TUI_DOWNLOAD_MAX_ATTEMPTS,
460
+ retryDelayMs: opts.retryDelayMs ?? TUI_DOWNLOAD_RETRY_DELAY_MS,
461
+ };
375
462
  log(`downloading checksum manifest ${checksumUrl}`);
376
463
  let manifestBytes: Buffer;
377
464
  let gz: Buffer;
378
465
  try {
379
- manifestBytes = await readBoundedResponse(
466
+ manifestBytes = await readWithRetries(
380
467
  fetchImpl,
381
468
  checksumUrl,
382
469
  MAX_CHECKSUM_MANIFEST_BYTES,
383
- limits.timeoutMs,
470
+ policy,
471
+ log,
384
472
  );
385
- gz = await readBoundedResponse(fetchImpl, url, limits.maxCompressedBytes, limits.timeoutMs);
473
+ log(`downloading ${url}`);
474
+ gz = await readWithRetries(fetchImpl, url, limits.maxCompressedBytes, policy, log);
386
475
  } catch (error) {
387
476
  const message = error instanceof Error ? error.message : String(error);
477
+ const hint =
478
+ error instanceof PermanentDownloadError
479
+ ? "Check that the release published both exact-version assets."
480
+ : "Check your network connection, then retry with `tmux-ide update --tui-binary`, or run the installer again if tmux-ide is not installed yet.";
388
481
  throw new Error(
389
- `could not download the TUI binary and checksum for v${version} (${message}). ` +
390
- `Check that the release published both exact-version assets.`,
482
+ `could not download the TUI binary and checksum for v${version} (${message}). ${hint}`,
391
483
  { cause: error },
392
484
  );
393
485
  }