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.
@@ -1,9 +1,15 @@
1
1
  import {
2
+ isOwnedTerminalReplicaRow,
2
3
  isSchemaValidProjectedTerminalReplicaRow,
3
4
  TERMINAL_REPLICA_DEFAULT_COLOR,
4
5
  } from "./terminal-replica-owned-row.ts";
5
6
  import type { TerminalReplicaColor, TerminalReplicaRow } from "@tmux-ide/contracts";
6
- import { createBufferedFnv64, type BufferedFnv64 } from "./terminal-fnv64-wasm.ts";
7
+ import {
8
+ createBufferedFnv64,
9
+ FNV64_OFFSET_BASIS,
10
+ nativeFnv64,
11
+ type BufferedFnv64,
12
+ } from "./terminal-fnv64-wasm.ts";
7
13
 
8
14
  const ROW_HASH_CACHE = new WeakMap<object, string>();
9
15
  const DEEPLY_FROZEN_ROWS = new WeakSet<object>();
@@ -425,6 +431,12 @@ export function hashTerminalReplicaRowCached(
425
431
  const cached = ROW_HASH_CACHE.get(row);
426
432
  if (cached) return cached;
427
433
  onMiss?.();
434
+ const native = nativeFnv64();
435
+ if (native) {
436
+ const digest = hashRowBytes(row, native);
437
+ if (isTerminalReplicaRowDeeplyFrozen(row)) ROW_HASH_CACHE.set(row, digest);
438
+ return digest;
439
+ }
428
440
  const hash = encodingCache?.createHash() ?? new CanonicalFnv64();
429
441
  let canonicalBytes = 10 + String(row.cells.length).length;
430
442
  hash.ascii("a2:");
@@ -631,6 +643,11 @@ export function primeTerminalReplicaRowHash(row: TerminalReplicaRow, digest: str
631
643
 
632
644
  export function isTerminalReplicaRowDeeplyFrozen(row: TerminalReplicaRow): boolean {
633
645
  if (DEEPLY_FROZEN_ROWS.has(row)) return true;
646
+ // Every owned-row constructor freezes the row, its cells and their colors.
647
+ if (isOwnedTerminalReplicaRow(row as unknown)) {
648
+ DEEPLY_FROZEN_ROWS.add(row);
649
+ return true;
650
+ }
634
651
  if (!Object.isFrozen(row) || !Object.isFrozen(row.cells)) return false;
635
652
  for (const cell of row.cells) {
636
653
  if (
@@ -643,3 +660,93 @@ export function isTerminalReplicaRowDeeplyFrozen(row: TerminalReplicaRow): boole
643
660
  DEEPLY_FROZEN_ROWS.add(row);
644
661
  return true;
645
662
  }
663
+
664
+ // #708: the same canonical row bytes as hashTerminalReplicaRowCached's
665
+ // CanonicalFnv64 path, assembled from per-cell byte strings and hashed in
666
+ // WASM in 4 KiB chunks instead of several small JS writes per cell.
667
+ const ASCII_ENCODER = new TextEncoder();
668
+ const CELL_BYTES_LIMIT = 4_096;
669
+ const PLAIN_CELL_BYTES = new Map<string, Uint8Array>();
670
+ const STYLED_CELL_BYTES = new Map<string, Uint8Array>();
671
+ const ROW_TRAILER = ASCII_ENCODER.encode(";;");
672
+
673
+ function canonicalNumber(value: number): string {
674
+ const text = String(value);
675
+ return `d${text.length}:${text};`;
676
+ }
677
+
678
+ function canonicalColor(color: TerminalReplicaColor): string {
679
+ if (color.kind === "default") return "o1:s4:kind;s7:default;;";
680
+ if (color.kind === "indexed")
681
+ return `o2:s5:index;${canonicalNumber(color.index)}s4:kind;s7:indexed;;`;
682
+ return `o2:s4:kind;s3:rgb;s5:value;${canonicalNumber(color.value)};`;
683
+ }
684
+
685
+ function encodeCell(cell: TerminalReplicaRow["cells"][number]): Uint8Array {
686
+ const grapheme = cell.grapheme;
687
+ let length = grapheme.length;
688
+ for (let index = 0; index < grapheme.length; index += 1)
689
+ if (grapheme.charCodeAt(index) > 0x7f) {
690
+ length = UTF8_ENCODER.encode(grapheme).byteLength;
691
+ break;
692
+ }
693
+ return UTF8_ENCODER.encode(
694
+ `a5:s${length}:${grapheme};${canonicalNumber(cell.width)}${canonicalColor(
695
+ cell.foreground,
696
+ )}${canonicalColor(cell.background)}${canonicalNumber(cell.attributes)};`,
697
+ );
698
+ }
699
+
700
+ function remember(cache: Map<string, Uint8Array>, key: string, bytes: Uint8Array): Uint8Array {
701
+ if (cache.size >= CELL_BYTES_LIMIT) cache.clear();
702
+ cache.set(key, bytes);
703
+ return bytes;
704
+ }
705
+
706
+ function cellBytes(cell: TerminalReplicaRow["cells"][number]): Uint8Array {
707
+ const { grapheme, width, attributes, foreground, background } = cell;
708
+ if (
709
+ width === 1 &&
710
+ attributes === 0 &&
711
+ foreground.kind === "default" &&
712
+ background.kind === "default"
713
+ )
714
+ return PLAIN_CELL_BYTES.get(grapheme) ?? remember(PLAIN_CELL_BYTES, grapheme, encodeCell(cell));
715
+ const key = `${width}\u0000${attributes}\u0000${canonicalColor(foreground)}\u0000${canonicalColor(
716
+ background,
717
+ )}\u0000${grapheme}`;
718
+ return STYLED_CELL_BYTES.get(key) ?? remember(STYLED_CELL_BYTES, key, encodeCell(cell));
719
+ }
720
+
721
+ function hashRowBytes(
722
+ row: TerminalReplicaRow,
723
+ native: NonNullable<ReturnType<typeof nativeFnv64>>,
724
+ ): string {
725
+ const scratch = native.scratch;
726
+ const capacity = scratch.byteLength;
727
+ let state = FNV64_OFFSET_BASIS;
728
+ let length = 0;
729
+ const write = (bytes: Uint8Array) => {
730
+ if (bytes.byteLength <= capacity - length) {
731
+ scratch.set(bytes, length);
732
+ length += bytes.byteLength;
733
+ return;
734
+ }
735
+ let offset = 0;
736
+ while (offset < bytes.byteLength) {
737
+ const count = Math.min(capacity - length, bytes.byteLength - offset);
738
+ scratch.set(bytes.subarray(offset, offset + count), length);
739
+ length += count;
740
+ offset += count;
741
+ if (length === capacity) {
742
+ state = BigInt.asUintN(64, native.update(state, length));
743
+ length = 0;
744
+ }
745
+ }
746
+ };
747
+ write(ASCII_ENCODER.encode(`a2:${row.wrapped ? "b1;" : "b0;"}a${row.cells.length}:`));
748
+ for (const cell of row.cells) write(cellBytes(cell));
749
+ write(ROW_TRAILER);
750
+ state = BigInt.asUintN(64, native.update(state, length));
751
+ return state.toString(16).padStart(16, "0");
752
+ }
@@ -1001,6 +1001,20 @@ function resolveFrameHash(
1001
1001
  * seed whose frame hash differs is a protocol conflict, not idempotent.
1002
1002
  */
1003
1003
  function terminalReplicaFrameIdentity(update: CanonicalTerminalReplicaUpdate): unknown {
1004
+ if (update.type === "terminal.patch") {
1005
+ // #708: a flood patch appends many history rows the interpreter has just
1006
+ // hashed; enter them, and the changed screen rows, by their row hashes.
1007
+ const { rows, history, historyDelta, ...rest } = update.patch;
1008
+ return [
1009
+ "terminal-replica-patch-frame-v2",
1010
+ { ...update, patch: rest },
1011
+ rows.map(({ index, row }) => [index, hashTerminalReplicaRow(row)]),
1012
+ history === undefined ? null : hashTerminalReplicaRows(history),
1013
+ historyDelta === undefined
1014
+ ? null
1015
+ : [historyDelta.trim, hashTerminalReplicaRows(historyDelta.append)],
1016
+ ];
1017
+ }
1004
1018
  if (update.type !== "terminal.seed") return update;
1005
1019
  const { grid, history, ...rest } = update.snapshot;
1006
1020
  return [
@@ -160,11 +160,15 @@ export function isVisibleFleetSession(name) {
160
160
  export function readAdoptedSessionNames(runTmux = _tmuxRunner) {
161
161
  let raw;
162
162
  try {
163
- raw = runTmux(["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`]);
163
+ raw = runTmux(ADOPTED_SESSIONS_ARGS);
164
164
  }
165
165
  catch {
166
166
  return null;
167
167
  }
168
+ return parseAdoptedSessionNames(raw);
169
+ }
170
+ const ADOPTED_SESSIONS_ARGS = ["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`];
171
+ function parseAdoptedSessionNames(raw) {
168
172
  const names = [];
169
173
  for (const line of raw.split("\n")) {
170
174
  if (!line)
@@ -223,18 +227,47 @@ export function readAdoptedFleet(registry = getDefaultWorkspaceRegistry(), runTm
223
227
  const adopted = readAdoptedSessionNames(runTmux);
224
228
  if (adopted === null)
225
229
  return null;
226
- const adoptedSet = new Set(adopted);
227
- if (adoptedSet.size === 0)
230
+ if (adopted.length === 0)
231
+ return [];
232
+ let panesRaw;
233
+ try {
234
+ panesRaw = runTmux(FLEET_PANES_ARGS);
235
+ }
236
+ catch {
237
+ panesRaw = "";
238
+ }
239
+ return assembleAdoptedFleet(adopted, panesRaw, registry, nativeEpoch, resolveInteractionEndpoint, nativeServerEpoch);
240
+ }
241
+ /**
242
+ * {@link readAdoptedFleet} over an asynchronous tmux runner: the same two reads
243
+ * in the same order, without blocking the event loop while tmux answers (#709).
244
+ */
245
+ export async function readAdoptedFleetAsync(registry, runTmux, resolveInteractionEndpoint, nativeServerEpoch) {
246
+ const nativeEpoch = nativeServerEpoch?.() ?? null;
247
+ let sessionsRaw;
248
+ try {
249
+ sessionsRaw = await runTmux(ADOPTED_SESSIONS_ARGS);
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ const adopted = parseAdoptedSessionNames(sessionsRaw);
255
+ if (adopted.length === 0)
228
256
  return [];
229
- const appCreatedSessions = new Set(registry.list().map((workspace) => workspace.sessionName));
230
- const panesBySession = new Map();
231
257
  let panesRaw;
232
258
  try {
233
- panesRaw = runTmux(["list-panes", "-a", "-F", FLEET_PANE_FORMAT]);
259
+ panesRaw = await runTmux(FLEET_PANES_ARGS);
234
260
  }
235
261
  catch {
236
262
  panesRaw = "";
237
263
  }
264
+ return assembleAdoptedFleet(adopted, panesRaw, registry, nativeEpoch, resolveInteractionEndpoint, nativeServerEpoch);
265
+ }
266
+ const FLEET_PANES_ARGS = ["list-panes", "-a", "-F", FLEET_PANE_FORMAT];
267
+ function assembleAdoptedFleet(adopted, panesRaw, registry, nativeEpoch, resolveInteractionEndpoint, nativeServerEpoch) {
268
+ const adoptedSet = new Set(adopted);
269
+ const appCreatedSessions = new Set(registry.list().map((workspace) => workspace.sessionName));
270
+ const panesBySession = new Map();
238
271
  for (const line of panesRaw.split("\n")) {
239
272
  if (!line)
240
273
  continue;
@@ -0,0 +1,10 @@
1
+ import { readAdoptedFleet, readAdoptedFleetAsync, } from "../command-center/discovery.js";
2
+ /**
3
+ * Read the catalog fleet without blocking the event loop (#709), except inside
4
+ * a session mutation, where the read keeps the fence's synchronous runner.
5
+ */
6
+ export async function readCatalogFleet(registry, runners, resolveInteractionEndpoint, nativeServerEpoch) {
7
+ return runners.fence.active
8
+ ? readAdoptedFleet(registry, runners.fencedSync, resolveInteractionEndpoint, nativeServerEpoch)
9
+ : readAdoptedFleetAsync(registry, runners.generationFencedAsync, resolveInteractionEndpoint, nativeServerEpoch);
10
+ }
@@ -68,7 +68,8 @@ import { setDaemonShutdownBackend, setDaemonRestartBackend, } from "../command-c
68
68
  import { readAppSettings } from "./app-settings.js";
69
69
  import { getDefaultWorkspaceRegistry, readWorkspaceRegistrySessionInventory, WORKSPACE_REGISTRY_TMUX_TIMEOUT_MS, } from "./workspace-registry.js";
70
70
  import { createPinnedWorkspaceTmuxRunner, createPinnedWorkspaceTmuxAsyncRunner, resolveWorkspacePaneTmuxAuthority, WorkspacePaneCreationAuthority, } from "./workspace-pane-creation.js";
71
- import { discoverLiveSessionSummaries, readAdoptedFleet } from "../command-center/discovery.js";
71
+ import { discoverLiveSessionSummaries, readAdoptedFleet, } from "../command-center/discovery.js";
72
+ import { readCatalogFleet } from "./catalog-fleet-read.js";
72
73
  import { WorkspaceOpenAuthority } from "./workspace-open.js";
73
74
  import { WorkspacePromotionAuthority } from "./workspace-promotion.js";
74
75
  import { WorkspaceOpenHandoffCoordinator } from "./workspace-open-handoff.js";
@@ -1542,9 +1543,19 @@ async function startEmbeddedDaemonGeneration(opts) {
1542
1543
  paneStreamRuntime,
1543
1544
  catalogLiveSessions: () => discoverLiveSessionSummaries(catalogTmuxRunner),
1544
1545
  catalogFleet: async () => {
1545
- const fleet = readAdoptedFleet(workspaceRegistry, nativeGenerationTmuxRunner, (sessionName, pane) => pane.semanticPaneId
1546
+ const endpoint = (sessionName, pane) => pane.semanticPaneId
1546
1547
  ? (interactionEvidence?.captureInventoryEndpoint(sessionName, pane.runtimePaneId, pane.semanticPaneId) ?? null)
1547
- : null, () => observationSelector?.nativeServerEpoch ?? null);
1548
+ : null;
1549
+ const epoch = () => observationSelector?.nativeServerEpoch ?? null;
1550
+ // Same pinned server generation either way. Outside a session
1551
+ // mutation the read must not block the event loop: every concurrent
1552
+ // catalog request queued behind its spawnSync (#709). Inside one,
1553
+ // keep the fenced synchronous runner the mutation relies on.
1554
+ const fleet = await readCatalogFleet(workspaceRegistry, {
1555
+ fence: sessionMutationFence,
1556
+ fencedSync: nativeGenerationTmuxRunner,
1557
+ generationFencedAsync: (args) => fleetFactsTmuxRunner(args),
1558
+ }, endpoint, epoch);
1548
1559
  if (!fleet)
1549
1560
  return null;
1550
1561
  const names = await readTeamMemberships(fleet.flatMap((session) => session.panes.map((pane) => ({
@@ -106,13 +106,18 @@ export async function manageDaemonService(action, executable, deps = defaults())
106
106
  refused("The service manager loaded a different definition; refusing to control it");
107
107
  return state;
108
108
  };
109
- const ready = async (previousInstance) => {
109
+ // `retiredPid` is the process this restart terminated. It can publish a record
110
+ // while it shuts down (a KeepAlive relaunch just before the restart), so it is
111
+ // never the replacement, whatever its instance id.
112
+ const ready = async (previousInstance, retiredPid) => {
110
113
  const deadline = Date.now() + deps.waitMs;
111
114
  do {
112
115
  const state = deps.inspect();
113
116
  if (!boundTo(state, plan.supervisionId))
114
117
  refused("Service reservation changed during startup");
115
- if (state.status === "valid" && state.info.instanceId !== previousInstance) {
118
+ if (state.status === "valid" &&
119
+ state.info.instanceId !== previousInstance &&
120
+ state.info.pid !== retiredPid) {
116
121
  const identity = await deps.probe(state.info);
117
122
  const service = await inspectManager();
118
123
  if (identity &&
@@ -236,11 +241,14 @@ export async function manageDaemonService(action, executable, deps = defaults())
236
241
  const before = deps.inspect();
237
242
  if (action === "install" && service.active)
238
243
  return await ready();
239
- if (service.loaded)
240
- await manager.restart(service.pid !== null);
244
+ // Read the manager again: a relaunch may have replaced the process while this
245
+ // waited for the lock, and the restart terminates whichever one runs now.
246
+ const current = service.loaded ? await inspectManager() : service;
247
+ if (current.loaded)
248
+ await manager.restart(current.pid !== null);
241
249
  else
242
250
  await manager.install();
243
- return await ready(before.status === "valid" ? before.info.instanceId : undefined);
251
+ return await ready(before.status === "valid" ? before.info.instanceId : undefined, current.loaded ? current.pid : null);
244
252
  }
245
253
  catch (error) {
246
254
  // Never discard ownership after an uncertain OS activation. The retained
@@ -24,6 +24,8 @@ export class TmuxServerOwners {
24
24
  constructor(options) {
25
25
  this.options = options;
26
26
  }
27
+ #queuedRefresh = null;
28
+ #queuedRefreshTail = null;
27
29
  #serial(work) {
28
30
  const run = this.#tail.then(() => {
29
31
  if (this.#disposed)
@@ -134,12 +136,29 @@ export class TmuxServerOwners {
134
136
  this.#entries.set(scope.serverId, { registration, scope, observation, owner });
135
137
  }
136
138
  refresh() {
137
- return this.#serial(async () => {
139
+ // A refresh still waiting its turn has not probed anything yet, so it is
140
+ // as fresh for a later caller as a new one: share it instead of queuing
141
+ // another full refresh behind it (#709). Once it starts, callers queue anew.
142
+ // Share it only while nothing has been queued behind it, so a refresh
143
+ // stays ordered after any mutation queued before the caller asked.
144
+ if (this.#queuedRefresh && this.#tail === this.#queuedRefreshTail)
145
+ return this.#queuedRefresh;
146
+ const queued = this.#serial(async () => {
147
+ if (this.#queuedRefresh === queued)
148
+ this.#queuedRefresh = null;
138
149
  // Sequential admission bounds subprocess work and avoids alias races.
139
150
  for (const entry of this.#entries.values())
140
151
  await this.#refresh(entry);
141
152
  return this.list();
142
153
  });
154
+ this.#queuedRefresh = queued;
155
+ this.#queuedRefreshTail = this.#tail;
156
+ const settle = () => {
157
+ if (this.#queuedRefresh === queued)
158
+ this.#queuedRefresh = null;
159
+ };
160
+ queued.then(settle, settle);
161
+ return queued;
143
162
  }
144
163
  current(scopeInput) {
145
164
  const scope = TmuxServerScopeSchemaZ.parse(scopeInput);
@@ -26,8 +26,18 @@ export const MIN_TUI_BINARY_BYTES = 10 * 1024 * 1024;
26
26
  /** Hard download/decompression ceilings: release binaries are currently ~70MB. */
27
27
  export const MAX_TUI_COMPRESSED_BYTES = 128 * 1024 * 1024;
28
28
  export const MAX_TUI_BINARY_BYTES = 256 * 1024 * 1024;
29
- export const TUI_DOWNLOAD_TIMEOUT_MS = 30_000;
29
+ /** Abort a request when no bytes arrive for this long; a slow transfer never trips it. */
30
+ export const TUI_DOWNLOAD_STALL_TIMEOUT_MS = 30_000;
31
+ /** One budget for the manifest, the binary, and every retry: ~145 KB/s at the size ceiling. */
32
+ export const TUI_DOWNLOAD_TOTAL_TIMEOUT_MS = 15 * 60_000;
33
+ /** Attempts per file; only transient failures are retried. */
34
+ export const TUI_DOWNLOAD_MAX_ATTEMPTS = 3;
35
+ export const TUI_DOWNLOAD_RETRY_DELAY_MS = 1_000;
30
36
  const MAX_CHECKSUM_MANIFEST_BYTES = 4 * 1024;
37
+ /** A second installer says why it is quiet once the wait is longer than a lock handover. */
38
+ const TUI_DOWNLOAD_LOCK_NOTICE_MS = 2_000;
39
+ /** Lets a lock waiter outlast the holder's verify and install after its download budget. */
40
+ const TUI_DOWNLOAD_LOCK_MARGIN_MS = 5_000;
31
41
  const SUPPORTED = {
32
42
  "darwin-arm64": "darwin-arm64",
33
43
  "linux-x64": "linux-x64",
@@ -109,6 +119,9 @@ export function findDownloadedTui(version = getCurrentVersion(), options = {}) {
109
119
  purgeCachedBinary(path);
110
120
  return null;
111
121
  }
122
+ /** A failure a retry cannot fix: the release asset is missing or breaks a hard limit. */
123
+ class PermanentDownloadError extends Error {
124
+ }
112
125
  function checksumPath(path) {
113
126
  return `${path}.sha256`;
114
127
  }
@@ -198,32 +211,53 @@ function purgeCachedBinary(path) {
198
211
  }
199
212
  }
200
213
  }
201
- async function readBoundedResponse(fetchImpl, url, maxBytes, timeoutMs) {
214
+ function isTransientHttpStatus(status) {
215
+ return status === 408 || status === 429 || status >= 500;
216
+ }
217
+ async function readBoundedResponse(fetchImpl, url, maxBytes, policy) {
202
218
  const controller = new AbortController();
203
- const timer = setTimeout(() => controller.abort(), timeoutMs);
219
+ let total = 0;
220
+ let timeoutMessage = null;
221
+ const abortWith = (message) => () => {
222
+ timeoutMessage = message();
223
+ controller.abort();
224
+ };
225
+ const overallTimer = setTimeout(abortWith(() => `download did not finish within ${policy.totalTimeoutMs}ms`), Math.max(0, policy.deadline - Date.now()));
226
+ const onStall = abortWith(() => `download stalled: no data for ${policy.stallTimeoutMs}ms after ${total} bytes`);
227
+ let stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
228
+ const markProgress = () => {
229
+ clearTimeout(stallTimer);
230
+ stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
231
+ };
204
232
  try {
205
233
  const response = await fetchImpl(url, { signal: controller.signal });
206
234
  if (!response.ok) {
207
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
235
+ const message = `HTTP ${response.status} ${response.statusText}`;
236
+ if (isTransientHttpStatus(response.status))
237
+ throw new Error(message);
238
+ throw new PermanentDownloadError(message);
208
239
  }
240
+ markProgress();
209
241
  const declaredLength = Number(response.headers.get("content-length"));
210
242
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
211
- throw new Error(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
243
+ throw new PermanentDownloadError(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
212
244
  }
213
245
  if (!response.body)
214
246
  throw new Error("response has no body");
215
247
  const chunks = [];
216
- let total = 0;
217
248
  const reader = response.body.getReader();
218
249
  try {
219
250
  while (true) {
220
251
  const { done, value } = await reader.read();
221
252
  if (done)
222
253
  break;
254
+ if (value.byteLength === 0)
255
+ continue;
256
+ markProgress();
223
257
  total += value.byteLength;
224
258
  if (total > maxBytes) {
225
259
  await reader.cancel();
226
- throw new Error(`download exceeds ${maxBytes} bytes`);
260
+ throw new PermanentDownloadError(`download exceeds ${maxBytes} bytes`);
227
261
  }
228
262
  chunks.push(Buffer.from(value));
229
263
  }
@@ -234,13 +268,33 @@ async function readBoundedResponse(fetchImpl, url, maxBytes, timeoutMs) {
234
268
  return Buffer.concat(chunks, total);
235
269
  }
236
270
  catch (error) {
237
- if (controller.signal.aborted) {
238
- throw new Error(`download timed out after ${timeoutMs}ms`, { cause: error });
239
- }
271
+ if (timeoutMessage)
272
+ throw new Error(timeoutMessage, { cause: error });
240
273
  throw error;
241
274
  }
242
275
  finally {
243
- clearTimeout(timer);
276
+ clearTimeout(overallTimer);
277
+ clearTimeout(stallTimer);
278
+ // Releases the connection of any response whose body was not fully read.
279
+ controller.abort();
280
+ }
281
+ }
282
+ async function readWithRetries(fetchImpl, url, maxBytes, policy, log) {
283
+ for (let attempt = 1;; attempt++) {
284
+ try {
285
+ return await readBoundedResponse(fetchImpl, url, maxBytes, policy);
286
+ }
287
+ catch (error) {
288
+ const delayMs = policy.retryDelayMs * attempt;
289
+ if (error instanceof PermanentDownloadError ||
290
+ attempt >= policy.maxAttempts ||
291
+ Date.now() + delayMs >= policy.deadline) {
292
+ throw error;
293
+ }
294
+ const message = error instanceof Error ? error.message : String(error);
295
+ log(`download attempt ${attempt}/${policy.maxAttempts} failed (${message}); retrying`);
296
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
297
+ }
244
298
  }
245
299
  }
246
300
  /**
@@ -270,10 +324,11 @@ export async function downloadTuiBinary(opts = {}) {
270
324
  minBinaryBytes: opts.limits?.minBinaryBytes ?? MIN_TUI_BINARY_BYTES,
271
325
  maxCompressedBytes: opts.limits?.maxCompressedBytes ?? MAX_TUI_COMPRESSED_BYTES,
272
326
  maxBinaryBytes: opts.limits?.maxBinaryBytes ?? MAX_TUI_BINARY_BYTES,
273
- timeoutMs: opts.timeoutMs ?? TUI_DOWNLOAD_TIMEOUT_MS,
274
327
  };
328
+ const totalTimeoutMs = opts.totalTimeoutMs ?? TUI_DOWNLOAD_TOTAL_TIMEOUT_MS;
275
329
  mkdirSync(dirname(dest), { recursive: true });
276
- const releaseLock = await acquirePrivateOperationLock(downloadLockPath(dest), limits.timeoutMs * 2 + 5_000);
330
+ const lockNotice = setTimeout(() => log("waiting for another tmux-ide download of this version to finish"), TUI_DOWNLOAD_LOCK_NOTICE_MS);
331
+ const releaseLock = await acquirePrivateOperationLock(downloadLockPath(dest), totalTimeoutMs + TUI_DOWNLOAD_LOCK_MARGIN_MS).finally(() => clearTimeout(lockNotice));
277
332
  const suffix = `${process.pid}.${randomUUID()}.tmp`;
278
333
  const binaryTmp = `${dest}.${suffix}`;
279
334
  const checksumTmp = `${checksumPath(dest)}.${suffix}`;
@@ -283,17 +338,28 @@ export async function downloadTuiBinary(opts = {}) {
283
338
  return { path: dest, bytes: cached.bytes };
284
339
  purgeCachedBinary(dest);
285
340
  const fetchImpl = opts.fetch ?? globalThis.fetch;
341
+ // Starts after the lock so the budget matches what a lock waiter allows for.
342
+ const policy = {
343
+ stallTimeoutMs: opts.stallTimeoutMs ?? TUI_DOWNLOAD_STALL_TIMEOUT_MS,
344
+ deadline: Date.now() + totalTimeoutMs,
345
+ totalTimeoutMs,
346
+ maxAttempts: opts.maxAttempts ?? TUI_DOWNLOAD_MAX_ATTEMPTS,
347
+ retryDelayMs: opts.retryDelayMs ?? TUI_DOWNLOAD_RETRY_DELAY_MS,
348
+ };
286
349
  log(`downloading checksum manifest ${checksumUrl}`);
287
350
  let manifestBytes;
288
351
  let gz;
289
352
  try {
290
- manifestBytes = await readBoundedResponse(fetchImpl, checksumUrl, MAX_CHECKSUM_MANIFEST_BYTES, limits.timeoutMs);
291
- gz = await readBoundedResponse(fetchImpl, url, limits.maxCompressedBytes, limits.timeoutMs);
353
+ manifestBytes = await readWithRetries(fetchImpl, checksumUrl, MAX_CHECKSUM_MANIFEST_BYTES, policy, log);
354
+ log(`downloading ${url}`);
355
+ gz = await readWithRetries(fetchImpl, url, limits.maxCompressedBytes, policy, log);
292
356
  }
293
357
  catch (error) {
294
358
  const message = error instanceof Error ? error.message : String(error);
295
- throw new Error(`could not download the TUI binary and checksum for v${version} (${message}). ` +
296
- `Check that the release published both exact-version assets.`, { cause: error });
359
+ const hint = error instanceof PermanentDownloadError
360
+ ? "Check that the release published both exact-version assets."
361
+ : "Check your network connection, then retry with `tmux-ide update --tui-binary`, or run the installer again if tmux-ide is not installed yet.";
362
+ throw new Error(`could not download the TUI binary and checksum for v${version} (${message}). ${hint}`, { cause: error });
297
363
  }
298
364
  const manifest = parseChecksumManifest(manifestBytes, { version, tag });
299
365
  if (sha256(gz) !== manifest.compressedSha256) {
@@ -21,9 +21,9 @@
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>2.9.16</string>
24
+ <string>2.9.17</string>
25
25
  <key>CFBundleVersion</key>
26
- <string>2.9.16</string>
26
+ <string>2.9.17</string>
27
27
  <key>LSMinimumSystemVersion</key>
28
28
  <string>11.0</string>
29
29
  <key>LSUIElement</key>
@@ -6,11 +6,11 @@
6
6
  <dict>
7
7
  <key>Resources/AppIcon.icns</key>
8
8
  <data>
9
- 0kJOhKI63m7RQF/lR1nMjIIiACY=
9
+ /fan3KhjkAUR40oxTxGrk4b03wY=
10
10
  </data>
11
11
  <key>Resources/Assets.car</key>
12
12
  <data>
13
- 80sBn8yzZzR4ZHE3L4qFvRPqA+A=
13
+ YZSYPnzkfPLVemp3jiRDnR5WhhQ=
14
14
  </data>
15
15
  </dict>
16
16
  <key>files2</key>
@@ -19,14 +19,14 @@
19
19
  <dict>
20
20
  <key>hash2</key>
21
21
  <data>
22
- YXdi2fa2PvtRaeXueLC7n1If41ofraB5MVZULmdCcC8=
22
+ zDoKzf7n2cp6cQedgfmNiTPwTj+gON7DS7H8ihtNVCE=
23
23
  </data>
24
24
  </dict>
25
25
  <key>Resources/Assets.car</key>
26
26
  <dict>
27
27
  <key>hash2</key>
28
28
  <data>
29
- PKQV15gCKuH8bIwdxGZgHEhWb0Rwq62t/R9Ozfnv/BA=
29
+ zV9+zCqfvUhHrz90MuHUjqBvx93oA7g6BxgO8hDUlN8=
30
30
  </data>
31
31
  </dict>
32
32
  </dict>