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.
- package/bin/cli.js +285 -48
- package/package.json +1 -1
- package/packages/core/src/terminal-fnv64-wasm.ts +8 -1
- package/packages/core/src/terminal-replica-hash-cache.ts +108 -1
- package/packages/core/src/terminal-replica.ts +14 -0
- package/packages/daemon/dist/command-center/discovery.js +39 -6
- package/packages/daemon/dist/lib/catalog-fleet-read.js +10 -0
- package/packages/daemon/dist/lib/daemon-embed.js +14 -3
- package/packages/daemon/dist/lib/daemon-service.js +13 -5
- package/packages/daemon/dist/lib/tmux-server-owners.js +20 -1
- package/packages/daemon/dist/lib/tui-binary.js +83 -17
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +4 -4
- package/packages/daemon/src/command-center/discovery.ts +72 -7
- package/packages/daemon/src/lib/catalog-fleet-read.ts +40 -0
- package/packages/daemon/src/lib/daemon-embed.ts +27 -12
- package/packages/daemon/src/lib/daemon-service.ts +17 -4
- package/packages/daemon/src/lib/tmux-server-owners.ts +17 -1
- package/packages/daemon/src/lib/tui-binary.ts +114 -22
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
227
|
-
|
|
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(
|
|
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
|
|
1546
|
+
const endpoint = (sessionName, pane) => pane.semanticPaneId
|
|
1546
1547
|
? (interactionEvidence?.captureInventoryEndpoint(sessionName, pane.runtimePaneId, pane.semanticPaneId) ?? null)
|
|
1547
|
-
: 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
|
-
|
|
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" &&
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
238
|
-
throw new Error(
|
|
239
|
-
}
|
|
271
|
+
if (timeoutMessage)
|
|
272
|
+
throw new Error(timeoutMessage, { cause: error });
|
|
240
273
|
throw error;
|
|
241
274
|
}
|
|
242
275
|
finally {
|
|
243
|
-
clearTimeout(
|
|
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
|
|
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
|
|
291
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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.
|
|
24
|
+
<string>2.9.17</string>
|
|
25
25
|
<key>CFBundleVersion</key>
|
|
26
|
-
<string>2.9.
|
|
26
|
+
<string>2.9.17</string>
|
|
27
27
|
<key>LSMinimumSystemVersion</key>
|
|
28
28
|
<string>11.0</string>
|
|
29
29
|
<key>LSUIElement</key>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
<dict>
|
|
7
7
|
<key>Resources/AppIcon.icns</key>
|
|
8
8
|
<data>
|
|
9
|
-
|
|
9
|
+
/fan3KhjkAUR40oxTxGrk4b03wY=
|
|
10
10
|
</data>
|
|
11
11
|
<key>Resources/Assets.car</key>
|
|
12
12
|
<data>
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
zV9+zCqfvUhHrz90MuHUjqBvx93oA7g6BxgO8hDUlN8=
|
|
30
30
|
</data>
|
|
31
31
|
</dict>
|
|
32
32
|
</dict>
|