opentray 0.32.0 → 0.33.1

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/dist/index.mjs CHANGED
@@ -484,6 +484,37 @@ const resolveAccessibleLibrary = async (path, target, packageName) => {
484
484
  //#endregion
485
485
  //#region src/client.ts
486
486
  /**
487
+ * Default per-call deadline classes (W1, harden-transport-robustness;
488
+ * field-validated against the pnpm-pub silent-wedge incident): interactive
489
+ * calls settle or reject within 5 s, teardown-class frames within 2 s, and
490
+ * bootstrap-class round-trips (connection init and first bring-up) within
491
+ * 10 s. They live beside `OpenTrayTransport` because they are part of the
492
+ * generic transport call contract, not of one broker implementation.
493
+ */
494
+ const INTERACTIVE_CALL_DEADLINE_MS = 5e3;
495
+ const TEARDOWN_CALL_DEADLINE_MS = 2e3;
496
+ const BOOTSTRAP_CALL_DEADLINE_MS = 1e4;
497
+ /**
498
+ * Typed per-call deadline rejection: the transport's budget for one
499
+ * round-trip expired without a correlated reply. This is the third
500
+ * rejection class, distinct from transport-lost (the plain
501
+ * `BROKER_CONNECTION_CLOSED_MESSAGE` sentinel family, or the typed
502
+ * `extension_transport_closed` for accepted deferred operations) and from
503
+ * broker-rejected (`BrokerServerError` / `ExtensionOperationError`). An
504
+ * expiry settles exactly one call; it never declares the transport dead —
505
+ * death stays a socket-close/error (and, later, heartbeat) verdict.
506
+ */
507
+ var TransportTimeoutError = class extends Error {
508
+ requestId;
509
+ deadlineMs;
510
+ constructor(requestId, deadlineMs) {
511
+ super(`transport call deadline expired after ${deadlineMs}ms: ${requestId}`);
512
+ this.name = "TransportTimeoutError";
513
+ this.requestId = requestId;
514
+ this.deadlineMs = deadlineMs;
515
+ }
516
+ };
517
+ /**
487
518
  * Typed rejection for synchronous broker error frames: carries the wire
488
519
  * `code`, the structured `details` payload when the error family freezes one,
489
520
  * and the transport cause when relevant. Consumers match on `code`; the human
@@ -606,10 +637,10 @@ function createTrayHandle(transport, appId, trayId, nextRequestId = createReques
606
637
  });
607
638
  expectResponse(response, requestId, "ack");
608
639
  },
609
- async commandExtension(ext, data) {
610
- await this.requestExtension(ext, data);
640
+ async commandExtension(ext, data, callOptions) {
641
+ await this.requestExtension(ext, data, callOptions);
611
642
  },
612
- async requestExtension(ext, data) {
643
+ async requestExtension(ext, data, callOptions) {
613
644
  const requestId = nextRequestId();
614
645
  const response = await transport.request({
615
646
  type: "ext-command",
@@ -618,7 +649,7 @@ function createTrayHandle(transport, appId, trayId, nextRequestId = createReques
618
649
  trayId,
619
650
  ext,
620
651
  data
621
- });
652
+ }, callOptions);
622
653
  if (response.type === "ack") {
623
654
  expectResponse(response, requestId, "ack");
624
655
  return {
@@ -652,7 +683,7 @@ function createTrayHandle(transport, appId, trayId, nextRequestId = createReques
652
683
  requestId,
653
684
  appId,
654
685
  trayId
655
- });
686
+ }, { deadlineMs: TEARDOWN_CALL_DEADLINE_MS });
656
687
  expectResponse(response, requestId, "ack");
657
688
  }
658
689
  };
@@ -665,10 +696,15 @@ const attachEventfulTrayHandle = (handle, source, appId, nextMountId, sessionId)
665
696
  if (!isOwnedTrayEvent(frame.event, appId, handle.trayId)) return;
666
697
  handler(frame.event);
667
698
  });
668
- const onConnectionDead = isConnectionDeadSource(source) ? (handler) => source.onConnectionDead(handler) : void 0;
699
+ const onConnectionDead = isConnectionDeadSource$1(source) ? (handler) => source.onConnectionDead(handler) : void 0;
700
+ const transportSupervision = isTransportSupervisionSource(source) ? {
701
+ onTransportStateChange: (handler) => source.onTransportStateChange(handler),
702
+ registerTransportRebuild: (rebuild) => source.registerTransportRebuild(rebuild)
703
+ } : void 0;
669
704
  const eventful = {
670
705
  ...handle,
671
706
  ...onConnectionDead === void 0 ? {} : { onConnectionDead },
707
+ ...transportSupervision === void 0 ? {} : transportSupervision,
672
708
  listenExtension(ext, handler) {
673
709
  return source.onEvent((frame) => {
674
710
  if (frame.type !== "ext-event" || frame.ext !== ext) return;
@@ -708,7 +744,8 @@ const attachEventfulTrayHandle = (handle, source, appId, nextMountId, sessionId)
708
744
  };
709
745
  const isOwnedTrayEvent = (event, appId, trayId) => event.type !== "ready" && event.appId === appId && event.trayId === trayId;
710
746
  const isOpenTrayEventSource = (transport) => "onEvent" in transport && typeof transport.onEvent === "function";
711
- const isConnectionDeadSource = (transport) => "onConnectionDead" in transport && typeof transport.onConnectionDead === "function";
747
+ const isConnectionDeadSource$1 = (transport) => "onConnectionDead" in transport && typeof transport.onConnectionDead === "function";
748
+ const isTransportSupervisionSource = (transport) => "onTransportStateChange" in transport && typeof transport.onTransportStateChange === "function" && "registerTransportRebuild" in transport && typeof transport.registerTransportRebuild === "function";
712
749
  const resolveDefaultAppRef = async (transport, nextRequestId) => {
713
750
  const requestId = nextRequestId();
714
751
  const response = await transport.request({
@@ -802,13 +839,13 @@ const createTrayExtensionContext = (tray, appId, extension, options, nextMountId
802
839
  artifact,
803
840
  mountId,
804
841
  ensureLoaded,
805
- async command(data) {
842
+ async command(data, callOptions) {
806
843
  await ensureLoaded();
807
- await tray.commandExtension(mountId, data);
844
+ await tray.commandExtension(mountId, data, callOptions);
808
845
  },
809
- async request(data) {
846
+ async request(data, callOptions) {
810
847
  await ensureLoaded();
811
- return tray.requestExtension(mountId, data);
848
+ return tray.requestExtension(mountId, data, callOptions);
812
849
  }
813
850
  };
814
851
  };
@@ -1559,6 +1596,12 @@ var ExtensionOperationError = class extends Error {
1559
1596
  };
1560
1597
  const extensionTransportClosedError = (cause) => new ExtensionOperationError(EXTENSION_TRANSPORT_CLOSED_CODE, "the broker transport closed while a deferred extension operation was pending", { cause });
1561
1598
  /**
1599
+ * Correlation label the bootstrap deadline reports under: the init frame
1600
+ * carries no requestId on the wire, so the typed timeout names the call class
1601
+ * itself.
1602
+ */
1603
+ const INIT_REQUEST_ID = "init";
1604
+ /**
1562
1605
  * Typed handshake rejection for a Ready frame whose protocol version does not
1563
1606
  * equal the version this client sent in its Init frame: a structurally valid
1564
1607
  * Ready frame alone must never accept the session (add-ext-dialog batch A
@@ -1669,6 +1712,7 @@ var LocalBrokerConnection = class {
1669
1712
  /** operationId -> requestId of accepted-but-unsettled deferred operations. */
1670
1713
  deferredOperations = /* @__PURE__ */ new Map();
1671
1714
  deadListeners = /* @__PURE__ */ new Set();
1715
+ deadlineExpiryListeners = /* @__PURE__ */ new Set();
1672
1716
  deadError;
1673
1717
  ready;
1674
1718
  constructor(socket, endpoint, callerLabel) {
@@ -1695,6 +1739,12 @@ var LocalBrokerConnection = class {
1695
1739
  this.deadListeners.delete(listener);
1696
1740
  };
1697
1741
  }
1742
+ onDeadlineExpiry(listener) {
1743
+ this.deadlineExpiryListeners.add(listener);
1744
+ return () => {
1745
+ this.deadlineExpiryListeners.delete(listener);
1746
+ };
1747
+ }
1698
1748
  async init(clientVersion, protocolVersion, expectedBrokerArtifactIdentity) {
1699
1749
  const ready = new Promise((resolve, reject) => {
1700
1750
  this.ready = {
@@ -1707,18 +1757,24 @@ var LocalBrokerConnection = class {
1707
1757
  protocolVersion,
1708
1758
  clientVersion
1709
1759
  });
1710
- const frame = await ready;
1760
+ const frame = await this.raceBootstrapDeadline(ready);
1711
1761
  if (frame.protocolVersion !== protocolVersion) throw new BrokerProtocolVersionError(protocolVersion, frame.protocolVersion);
1712
1762
  if (!brokerArtifactIdentityEquals(frame.brokerArtifactIdentity, expectedBrokerArtifactIdentity)) throw new Error(`broker artifact identity mismatch: expected=${JSON.stringify(expectedBrokerArtifactIdentity)} actual=${JSON.stringify(frame.brokerArtifactIdentity)}`);
1713
1763
  this.sessionId = frame.sessionId;
1714
1764
  }
1715
- async request(frame) {
1765
+ async request(frame, options = {}) {
1716
1766
  if (this.pending.has(frame.requestId)) throw new Error(`duplicate requestId: ${frame.requestId}`);
1717
1767
  if (this.deadError !== void 0) throw new Error(this.deadError.message);
1768
+ const deadlineMs = options.deadlineMs ?? 5e3;
1718
1769
  const response = new Promise((resolve, reject) => {
1770
+ const deadlineTimer = setTimeout(() => {
1771
+ this.expireRequest(frame.requestId, deadlineMs);
1772
+ }, deadlineMs);
1773
+ deadlineTimer.unref();
1719
1774
  this.pending.set(frame.requestId, {
1720
1775
  resolve,
1721
- reject
1776
+ reject,
1777
+ deadlineTimer
1722
1778
  });
1723
1779
  });
1724
1780
  this.write(frame);
@@ -1734,7 +1790,15 @@ var LocalBrokerConnection = class {
1734
1790
  if (this.deadError !== void 0) return;
1735
1791
  this.write({ type: "exit" });
1736
1792
  await new Promise((resolve) => {
1737
- this.socket.end(resolve);
1793
+ const degrade = setTimeout(() => {
1794
+ this.socket.destroy();
1795
+ resolve();
1796
+ }, TEARDOWN_CALL_DEADLINE_MS);
1797
+ degrade.unref();
1798
+ this.socket.end(() => {
1799
+ clearTimeout(degrade);
1800
+ resolve();
1801
+ });
1738
1802
  });
1739
1803
  }
1740
1804
  consume(chunk) {
@@ -1764,8 +1828,7 @@ var LocalBrokerConnection = class {
1764
1828
  if (frame.type === "error") {
1765
1829
  const error = new BrokerServerError(frame.code, frame.message, { details: frame.details });
1766
1830
  if (frame.requestId !== void 0) {
1767
- this.pending.get(frame.requestId)?.reject(error);
1768
- this.pending.delete(frame.requestId);
1831
+ this.takePending(frame.requestId)?.reject(error);
1769
1832
  return;
1770
1833
  }
1771
1834
  if (this.ready) {
@@ -1786,8 +1849,7 @@ var LocalBrokerConnection = class {
1786
1849
  }
1787
1850
  const requestId = responseRequestId(frame);
1788
1851
  if (requestId !== void 0) {
1789
- this.pending.get(requestId)?.resolve(frame);
1790
- this.pending.delete(requestId);
1852
+ this.takePending(requestId)?.resolve(frame);
1791
1853
  return;
1792
1854
  }
1793
1855
  if (frame.type === "event" || frame.type === "app-event" || frame.type === "ext-event") for (const listener of this.listeners) listener(frame);
@@ -1800,7 +1862,10 @@ var LocalBrokerConnection = class {
1800
1862
  * settles the request. The broker's owner loop guarantees exactly one
1801
1863
  * terminal per operation; a second acceptance for the same request or an
1802
1864
  * already-bound operationId is defensively ignored, never a second
1803
- * registration.
1865
+ * registration. Acceptance also stops the call's deadline: dispatch was the
1866
+ * bounded transport phase the client owns, and the accepted operation now
1867
+ * awaits a native terminal that can be legitimately user-paced (a held modal
1868
+ * dialog), bounded only by transport liveness.
1804
1869
  */
1805
1870
  acceptDeferredOperation(requestId, operationId) {
1806
1871
  const entry = this.pending.get(requestId);
@@ -1808,6 +1873,7 @@ var LocalBrokerConnection = class {
1808
1873
  if (this.deferredOperations.has(operationId)) return;
1809
1874
  entry.deferredOperation = operationId;
1810
1875
  this.deferredOperations.set(operationId, requestId);
1876
+ this.clearDeadline(entry);
1811
1877
  }
1812
1878
  /**
1813
1879
  * The single terminal settlement: resolves the still-pending request with
@@ -1815,15 +1881,17 @@ var LocalBrokerConnection = class {
1815
1881
  * typed wire error (`"error"`). Terminals for unknown or already-settled
1816
1882
  * operationIds are silently dropped - the broker side already settles
1817
1883
  * through a one-shot CAS; the client mirrors that stateless drop instead of
1818
- * guessing an owner.
1884
+ * guessing an owner. A terminal arriving after the call's deadline expired
1885
+ * is the same stateless drop (W1).
1819
1886
  */
1820
1887
  settleDeferredOperation(operationId, payload) {
1821
1888
  const requestId = this.deferredOperations.get(operationId);
1822
1889
  if (requestId === void 0) return;
1823
- const entry = this.pending.get(requestId);
1824
- this.deferredOperations.delete(operationId);
1825
- this.pending.delete(requestId);
1826
- if (entry === void 0) return;
1890
+ const entry = this.takePending(requestId);
1891
+ if (entry === void 0) {
1892
+ this.deferredOperations.delete(operationId);
1893
+ return;
1894
+ }
1827
1895
  if (payload.kind === "result") {
1828
1896
  const terminal = {
1829
1897
  type: "ext-operation-terminal",
@@ -1836,10 +1904,83 @@ var LocalBrokerConnection = class {
1836
1904
  const wireError = payload.error;
1837
1905
  entry.reject(new ExtensionOperationError(wireError.code, wireError.message, { details: wireError.details }));
1838
1906
  }
1907
+ /**
1908
+ * Removes and fully cleans one pending entry: clears its deadline timer and
1909
+ * the deferred-operation reverse mapping, so every settle path (response,
1910
+ * correlated error, deferred terminal, expiry) leaves no timer or mapping
1911
+ * behind.
1912
+ */
1913
+ takePending(requestId) {
1914
+ const entry = this.pending.get(requestId);
1915
+ if (entry === void 0) return;
1916
+ this.pending.delete(requestId);
1917
+ this.clearDeadline(entry);
1918
+ if (entry.deferredOperation !== void 0) this.deferredOperations.delete(entry.deferredOperation);
1919
+ return entry;
1920
+ }
1921
+ clearDeadline(entry) {
1922
+ if (entry.deadlineTimer !== void 0) {
1923
+ clearTimeout(entry.deadlineTimer);
1924
+ entry.deadlineTimer = void 0;
1925
+ }
1926
+ }
1927
+ /**
1928
+ * Deadline expiry (W1): settles exactly this call with the typed timeout
1929
+ * rejection and removes its entries, so a late reply or terminal finds no
1930
+ * owner. Deliberately NOT `markDead` — an unanswered call is one liveness
1931
+ * signal, not a death verdict; the connection stays usable and the death
1932
+ * listeners stay silent. The expiry is reported through the internal seam
1933
+ * for the Phase C heartbeat's failure classification.
1934
+ */
1935
+ expireRequest(requestId, deadlineMs) {
1936
+ const entry = this.pending.get(requestId);
1937
+ if (entry === void 0) return;
1938
+ this.takePending(requestId);
1939
+ this.notifyDeadlineExpiry({
1940
+ requestId,
1941
+ deadlineMs
1942
+ });
1943
+ entry.reject(new TransportTimeoutError(requestId, deadlineMs));
1944
+ }
1945
+ /**
1946
+ * Bootstrap-class budget (W1): the Ready frame must arrive within
1947
+ * `BOOTSTRAP_CALL_DEADLINE_MS` or init settles with the typed timeout
1948
+ * rejection. Like per-call expiry this never marks the transport dead —
1949
+ * `connectLocalBroker`'s error path performs the bounded close.
1950
+ */
1951
+ raceBootstrapDeadline(ready) {
1952
+ return new Promise((resolve, reject) => {
1953
+ let bootstrapTimer = setTimeout(() => {
1954
+ bootstrapTimer = void 0;
1955
+ this.notifyDeadlineExpiry({
1956
+ requestId: INIT_REQUEST_ID,
1957
+ deadlineMs: BOOTSTRAP_CALL_DEADLINE_MS
1958
+ });
1959
+ reject(new TransportTimeoutError(INIT_REQUEST_ID, BOOTSTRAP_CALL_DEADLINE_MS));
1960
+ }, BOOTSTRAP_CALL_DEADLINE_MS);
1961
+ bootstrapTimer.unref();
1962
+ const settle = (settleFn) => {
1963
+ if (bootstrapTimer !== void 0) {
1964
+ clearTimeout(bootstrapTimer);
1965
+ bootstrapTimer = void 0;
1966
+ }
1967
+ settleFn();
1968
+ };
1969
+ ready.then((frame) => settle(() => resolve(frame)), (error) => settle(() => reject(error)));
1970
+ });
1971
+ }
1972
+ notifyDeadlineExpiry(expiry) {
1973
+ for (const listener of [...this.deadlineExpiryListeners]) try {
1974
+ listener(expiry);
1975
+ } catch {}
1976
+ }
1839
1977
  rejectAll(error) {
1840
1978
  this.ready?.reject(error);
1841
1979
  this.ready = void 0;
1842
- for (const pending of this.pending.values()) pending.reject(error);
1980
+ for (const pending of this.pending.values()) {
1981
+ this.clearDeadline(pending);
1982
+ pending.reject(error);
1983
+ }
1843
1984
  this.pending.clear();
1844
1985
  this.deferredOperations.clear();
1845
1986
  }
@@ -1858,7 +1999,10 @@ var LocalBrokerConnection = class {
1858
1999
  this.deadError = error;
1859
2000
  this.ready?.reject(error);
1860
2001
  this.ready = void 0;
1861
- for (const pending of this.pending.values()) pending.reject(pending.deferredOperation === void 0 ? error : extensionTransportClosedError(error));
2002
+ for (const pending of this.pending.values()) {
2003
+ this.clearDeadline(pending);
2004
+ pending.reject(pending.deferredOperation === void 0 ? error : extensionTransportClosedError(error));
2005
+ }
1862
2006
  this.pending.clear();
1863
2007
  this.deferredOperations.clear();
1864
2008
  for (const listener of [...this.deadListeners]) try {
@@ -1897,6 +2041,729 @@ const resolveCliEntrypoint = () => {
1897
2041
  const suffix = fileURLToPath(import.meta.url).endsWith(".ts") ? "cli.ts" : "cli.mjs";
1898
2042
  return fileURLToPath(new URL(`./${suffix}`, import.meta.url));
1899
2043
  };
2044
+ /**
2045
+ * Typed fail-fast rejection for every call issued after supervision reached
2046
+ * its terminal `abandoned` state: the recovery budget is exhausted and the
2047
+ * runtime stays headless by design (the consumer process keeps running).
2048
+ * Consumers match on `code` (`transport_abandoned`); `details` carries the
2049
+ * budget facts `{ recoveries, windowMs }`.
2050
+ */
2051
+ var TransportAbandonedError = class extends Error {
2052
+ code = "transport_abandoned";
2053
+ details;
2054
+ constructor(recoveries, windowMs) {
2055
+ super(`transport abandoned: ${recoveries} recovery attempts within ${windowMs}ms exhausted the budget; every later call fails fast`);
2056
+ this.name = "TransportAbandonedError";
2057
+ this.details = {
2058
+ recoveries,
2059
+ windowMs
2060
+ };
2061
+ }
2062
+ };
2063
+ const journalKeyOf = (entry) => {
2064
+ switch (entry.kind) {
2065
+ case "resolve-default-app": return "resolve-default-app";
2066
+ case "create-tray": return;
2067
+ case "set-tray-menu":
2068
+ case "set-tray-icon":
2069
+ case "set-tray-tooltip": return `${entry.kind}:${entry.appId}:${entry.trayId}`;
2070
+ case "set-app-name":
2071
+ case "set-app-icon":
2072
+ case "set-app-icon-variant": return `${entry.kind}:${entry.appId}`;
2073
+ case "load-ext": return `load-ext:${entry.appId}:${entry.mountId ?? entry.name}`;
2074
+ }
2075
+ };
2076
+ const isDeadlineExpirySource = (connection) => "onDeadlineExpiry" in connection && typeof connection.onDeadlineExpiry === "function";
2077
+ const isConnectionDeadSource = (connection) => "onConnectionDead" in connection && typeof connection.onConnectionDead === "function";
2078
+ const sleepUnref = (ms) => new Promise((resolve) => {
2079
+ setTimeout(resolve, ms).unref();
2080
+ });
2081
+ /**
2082
+ * Supervised transport (W2/W4/W5/W6): a stable `OpenTrayConnection`-shaped
2083
+ * proxy for the layers above (createClient, tray handles) that owns one
2084
+ * broker connection per generation underneath. Consumer event subscriptions
2085
+ * and the supervision state machine survive generation changes;
2086
+ * per-connection surfaces (`onConnectionDead`) stay per-generation truth.
2087
+ */
2088
+ var SupervisedLocalBrokerConnection = class {
2089
+ connectFactory;
2090
+ heartbeatIntervalMs;
2091
+ probeDeadlineMs;
2092
+ heartbeatFailureThreshold;
2093
+ recoveryEnabled;
2094
+ maxRestarts;
2095
+ recoveryWindowMs;
2096
+ cooldownMs;
2097
+ backoffFactor;
2098
+ backoffCapMs;
2099
+ connectTimeoutMs;
2100
+ restartApp;
2101
+ connection;
2102
+ generation = 0;
2103
+ state = "healthy";
2104
+ journal = [];
2105
+ eventListeners = /* @__PURE__ */ new Set();
2106
+ deadListeners = /* @__PURE__ */ new Set();
2107
+ stateListeners = /* @__PURE__ */ new Set();
2108
+ rebuildCallbacks = [];
2109
+ unwireGeneration = noop$1;
2110
+ heartbeatTimer;
2111
+ heartbeatOrdinal = 0;
2112
+ heartbeatInFlight = false;
2113
+ consecutiveHeartbeatFailures = 0;
2114
+ lastSettledAt = 0;
2115
+ restartTimestamps = [];
2116
+ callerInitiated = false;
2117
+ /**
2118
+ * Monotonic teardown epoch: `shutdown()` bumps it so an in-flight recovery
2119
+ * attempt can detect that it raced caller-initiated teardown and abort
2120
+ * instead of resurrecting a destroyed runtime (no candidate adoption, no
2121
+ * `healthy` transition, no restarted heartbeat).
2122
+ */
2123
+ teardownEpoch = 0;
2124
+ shutdownPromise;
2125
+ constructor(connectFactory, options) {
2126
+ this.connectFactory = connectFactory;
2127
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 3e4;
2128
+ this.probeDeadlineMs = options.probeDeadlineMs ?? 3e3;
2129
+ this.heartbeatFailureThreshold = options.heartbeatFailureThreshold ?? 3;
2130
+ const recovery = options.recovery ?? {};
2131
+ this.recoveryEnabled = recovery.enabled ?? true;
2132
+ this.maxRestarts = recovery.maxRestarts ?? 3;
2133
+ this.recoveryWindowMs = recovery.windowMs ?? 6e5;
2134
+ this.cooldownMs = recovery.cooldownMs ?? 1e3;
2135
+ this.backoffFactor = recovery.backoffFactor ?? 2;
2136
+ this.backoffCapMs = recovery.backoffCapMs ?? 3e4;
2137
+ this.connectTimeoutMs = recovery.connectTimeoutMs ?? 1e4;
2138
+ this.restartApp = recovery.restartApp;
2139
+ }
2140
+ /** Current supervision state; edge events go to {@link onTransportStateChange}. */
2141
+ get transportState() {
2142
+ return this.state;
2143
+ }
2144
+ /** Monotonic generation counter; each respawn/replay increments it once. */
2145
+ get transportGeneration() {
2146
+ return this.generation;
2147
+ }
2148
+ /** Current generation's endpoint (proxy; absent before the first connect). */
2149
+ get endpoint() {
2150
+ return this.connection?.endpoint ?? "";
2151
+ }
2152
+ /** Current generation's caller label (proxy; absent before the first connect). */
2153
+ get callerLabel() {
2154
+ return this.connection?.callerLabel ?? "";
2155
+ }
2156
+ /**
2157
+ * Current generation's broker session id. Read dynamically so extension
2158
+ * contexts and rebuild callbacks can attribute session-owned state to the
2159
+ * live generation (the id changes on every respawn).
2160
+ */
2161
+ get sessionId() {
2162
+ return this.connection?.sessionId ?? "";
2163
+ }
2164
+ /** True once the current generation's connection died (or none exists). */
2165
+ get connectionDead() {
2166
+ return this.connection === void 0 || this.connection.connectionDead;
2167
+ }
2168
+ /**
2169
+ * Establishes generation 0. A failure here propagates to the caller
2170
+ * unchanged — supervision (heartbeat, recovery) only guards a connection
2171
+ * that once existed; an app that never started is a plain startup failure.
2172
+ */
2173
+ async connect() {
2174
+ const connection = await this.connectFactory();
2175
+ this.connection = connection;
2176
+ this.unwireGeneration = this.wireGeneration(connection);
2177
+ this.lastSettledAt = Date.now();
2178
+ this.consecutiveHeartbeatFailures = 0;
2179
+ this.startHeartbeat();
2180
+ }
2181
+ /**
2182
+ * Forwards one request to the current generation. After the terminal
2183
+ * `abandoned` state every call fails fast with {@link TransportAbandonedError}.
2184
+ * A teardown-budget call (`deadlineMs === TEARDOWN_CALL_DEADLINE_MS`) marks
2185
+ * the supervision caller-initiated: Phase A froze the budget class as the
2186
+ * caller-intent channel, so a socket close racing caller-initiated destroy
2187
+ * can never be misread as uninvited death.
2188
+ */
2189
+ async request(frame, options = {}) {
2190
+ if (this.state === "abandoned") throw new TransportAbandonedError(this.restartTimestamps.length, this.recoveryWindowMs);
2191
+ if (options.deadlineMs === 2e3) this.callerInitiated = true;
2192
+ const connection = this.connection;
2193
+ if (connection === void 0) throw new Error(BROKER_CONNECTION_CLOSED_MESSAGE);
2194
+ const candidate = journalCandidateFrom(frame);
2195
+ const response = await connection.request(frame, options);
2196
+ this.noteSuccessfulSettlement();
2197
+ if (candidate !== void 0) this.commitJournalEntry(candidate, response);
2198
+ if (frame.type === "destroy-tray") this.evictJournalTray(frame.appId, frame.trayId);
2199
+ return response;
2200
+ }
2201
+ /**
2202
+ * Consumer event subscription that survives generation changes: each new
2203
+ * generation's event stream fans out to the same listeners.
2204
+ */
2205
+ onEvent(listener) {
2206
+ this.eventListeners.add(listener);
2207
+ return () => {
2208
+ this.eventListeners.delete(listener);
2209
+ };
2210
+ }
2211
+ /**
2212
+ * Per-connection death notification, forwarded for every generation (D3
2213
+ * semantics preserved: consumers using it for per-connection cleanup keep
2214
+ * working; the supervision state machine is the new higher-level truth).
2215
+ */
2216
+ onConnectionDead(listener) {
2217
+ this.deadListeners.add(listener);
2218
+ return () => {
2219
+ this.deadListeners.delete(listener);
2220
+ };
2221
+ }
2222
+ /**
2223
+ * Tier 1 health projection (W6): edge-triggered `healthy | recovering |
2224
+ * abandoned` transitions. The initial `healthy` state never fires —
2225
+ * consumers reading the current state use `transportState`.
2226
+ */
2227
+ onTransportStateChange(listener) {
2228
+ this.stateListeners.add(listener);
2229
+ return () => {
2230
+ this.stateListeners.delete(listener);
2231
+ };
2232
+ }
2233
+ /**
2234
+ * Registers one facade rebuild callback (W4). Callbacks run in
2235
+ * registration order after the core journal replay on every successful
2236
+ * recovery, receiving the fresh generation number and session id so
2237
+ * session-attributed native state can be re-created against the live
2238
+ * broker session.
2239
+ */
2240
+ registerTransportRebuild(rebuild) {
2241
+ this.rebuildCallbacks.push(rebuild);
2242
+ }
2243
+ /**
2244
+ * Graceful teardown channel (W4/W3): idempotent, marks the supervision
2245
+ * caller-initiated synchronously (so the close-driven death event can
2246
+ * never trigger recovery), bumps the teardown epoch (so an in-flight
2247
+ * recovery attempt aborts instead of resurrecting the runtime), stops the
2248
+ * heartbeat, then closes the current generation — including an
2249
+ * adoption-in-progress candidate routed through `this.connection` —
2250
+ * within its bounded graceful-close budget.
2251
+ */
2252
+ async shutdown() {
2253
+ this.callerInitiated = true;
2254
+ this.teardownEpoch += 1;
2255
+ this.shutdownPromise ??= (async () => {
2256
+ this.stopHeartbeat();
2257
+ this.unwireGeneration();
2258
+ this.unwireGeneration = noop$1;
2259
+ const connection = this.connection;
2260
+ this.connection = void 0;
2261
+ if (connection !== void 0) await connection.close().catch(noopAsync);
2262
+ })();
2263
+ await this.shutdownPromise;
2264
+ }
2265
+ /** LocalBrokerClient structural compatibility: graceful close == shutdown. */
2266
+ close() {
2267
+ return this.shutdown();
2268
+ }
2269
+ startHeartbeat() {
2270
+ if (this.heartbeatTimer !== void 0) return;
2271
+ this.heartbeatTimer = setInterval(() => {
2272
+ this.heartbeatTick();
2273
+ }, this.heartbeatIntervalMs);
2274
+ this.heartbeatTimer.unref();
2275
+ }
2276
+ stopHeartbeat() {
2277
+ if (this.heartbeatTimer !== void 0) {
2278
+ clearInterval(this.heartbeatTimer);
2279
+ this.heartbeatTimer = void 0;
2280
+ }
2281
+ }
2282
+ /**
2283
+ * Idle-gated heartbeat (W2): a transport that keeps settling requests
2284
+ * proves its own liveness, so the probe budget is spent only on silence.
2285
+ * A probe failure of any rejection class (typed timeout, transport-lost,
2286
+ * broker error frame) counts; a successful settlement resets the streak.
2287
+ * Single-flight with a generation token: at most one probe per generation
2288
+ * is outstanding (a configured interval shorter than the probe deadline
2289
+ * must not run concurrent probes whose completions double-count), and a
2290
+ * probe that outlives its generation settles nothing.
2291
+ */
2292
+ async heartbeatTick() {
2293
+ if (this.heartbeatInFlight) return;
2294
+ const connection = this.connection;
2295
+ const probeGeneration = this.generation;
2296
+ if (connection === void 0 || this.state !== "healthy" || this.callerInitiated) return;
2297
+ if (Date.now() - this.lastSettledAt < this.heartbeatIntervalMs) return;
2298
+ this.heartbeatInFlight = true;
2299
+ const requestId = `supervisor-heartbeat-${this.generation}-${this.heartbeatOrdinal += 1}`;
2300
+ try {
2301
+ await connection.request({
2302
+ type: "health",
2303
+ requestId
2304
+ }, { deadlineMs: this.probeDeadlineMs });
2305
+ if (this.generation === probeGeneration && this.state === "healthy") {
2306
+ this.consecutiveHeartbeatFailures = 0;
2307
+ this.noteSuccessfulSettlement();
2308
+ }
2309
+ } catch (error) {
2310
+ if (this.generation !== probeGeneration || this.state !== "healthy") return;
2311
+ this.consecutiveHeartbeatFailures += 1;
2312
+ if (this.consecutiveHeartbeatFailures < this.heartbeatFailureThreshold) return;
2313
+ const death = new Error(BROKER_CONNECTION_CLOSED_MESSAGE, { cause: `heartbeat declared death after ${this.consecutiveHeartbeatFailures} consecutive probe failures (${String(error)})` });
2314
+ this.handleGenerationDeath(death);
2315
+ connection.close().catch(noopAsync);
2316
+ } finally {
2317
+ this.heartbeatInFlight = false;
2318
+ }
2319
+ }
2320
+ noteSuccessfulSettlement() {
2321
+ this.lastSettledAt = Date.now();
2322
+ this.consecutiveHeartbeatFailures = 0;
2323
+ }
2324
+ /**
2325
+ * Subscribes this supervisor's stable listener sets to one generation.
2326
+ * Event fanout and the death/expiry seams are wired before the generation
2327
+ * becomes `this.connection` (recovery candidates are wired before replay)
2328
+ * so no observable event window opens between adoption steps.
2329
+ */
2330
+ wireGeneration(connection) {
2331
+ const unwires = [];
2332
+ const unlistenEvents = connection.onEvent((frame) => {
2333
+ for (const listener of [...this.eventListeners]) try {
2334
+ listener(frame);
2335
+ } catch {}
2336
+ });
2337
+ unwires.push(unlistenEvents);
2338
+ if (isConnectionDeadSource(connection)) {
2339
+ const unlistenDead = connection.onConnectionDead((error) => {
2340
+ this.handleGenerationDeath(error);
2341
+ });
2342
+ unwires.push(unlistenDead);
2343
+ }
2344
+ if (isDeadlineExpirySource(connection)) {
2345
+ const unlistenExpiry = connection.onDeadlineExpiry((expiry) => {
2346
+ if (expiry.requestId.startsWith("supervisor-heartbeat-")) return;
2347
+ this.consecutiveHeartbeatFailures += 1;
2348
+ if (this.consecutiveHeartbeatFailures >= this.heartbeatFailureThreshold && this.state === "healthy" && !this.callerInitiated) {
2349
+ const connectionAtDeath = this.connection;
2350
+ this.handleGenerationDeath(new Error(BROKER_CONNECTION_CLOSED_MESSAGE, { cause: `heartbeat declared death after ${this.consecutiveHeartbeatFailures} consecutive deadline expiries` }));
2351
+ if (connectionAtDeath !== void 0) connectionAtDeath.close().catch(noopAsync);
2352
+ }
2353
+ });
2354
+ unwires.push(unlistenExpiry);
2355
+ }
2356
+ return () => {
2357
+ for (const unwire of unwires.splice(0)) unwire();
2358
+ };
2359
+ }
2360
+ /**
2361
+ * Death funnel: forwards the per-connection event first (D3 truth), stops
2362
+ * this generation's wiring, then decides the supervision reaction. The
2363
+ * state guard makes the reaction single-flight — deaths of candidates
2364
+ * during an ongoing recovery only forward, they never nest recoveries.
2365
+ */
2366
+ handleGenerationDeath(error) {
2367
+ for (const listener of [...this.deadListeners]) try {
2368
+ listener(error);
2369
+ } catch {}
2370
+ if (this.callerInitiated || this.state !== "healthy") return;
2371
+ this.unwireGeneration();
2372
+ this.unwireGeneration = noop$1;
2373
+ if (this.restartApp !== void 0) {
2374
+ this.handOverToRestartApp();
2375
+ return;
2376
+ }
2377
+ if (!this.recoveryEnabled) return;
2378
+ this.runRecovery();
2379
+ }
2380
+ /**
2381
+ * Tier 2 hand-over: bounded teardown of the current generation, the app's
2382
+ * restart vector invoked exactly once, then terminal. No in-process
2383
+ * rebuild, no loop — restart ownership belongs to the callback.
2384
+ */
2385
+ async handOverToRestartApp() {
2386
+ this.setState("recovering");
2387
+ this.callerInitiated = true;
2388
+ this.stopHeartbeat();
2389
+ const connection = this.connection;
2390
+ if (connection !== void 0 && !connection.connectionDead) await connection.close().catch(noopAsync);
2391
+ try {
2392
+ await this.restartApp?.();
2393
+ } catch (error) {
2394
+ console.error("OpenTray recovery.restartApp callback failed:", error);
2395
+ }
2396
+ this.setState("abandoned");
2397
+ }
2398
+ /**
2399
+ * In-process recovery (W4/W5): cooldown backoff → reconnect through the
2400
+ * original connect options → journal replay → facade rebuilds → healthy.
2401
+ * Every attempt consumes budget up front; a failed attempt (connect,
2402
+ * replay, or rebuild — including artifact identity mismatches) retries
2403
+ * inside the remaining budget until exhaustion abandons the runtime. Each
2404
+ * reconnect attempt is bounded by the connect budget, and every step
2405
+ * re-checks the teardown epoch so a shutdown racing the loop aborts it
2406
+ * instead of resurrecting the runtime.
2407
+ */
2408
+ async runRecovery() {
2409
+ this.setState("recovering");
2410
+ let backoffOrdinal = 0;
2411
+ while (true) {
2412
+ if (this.callerInitiated) return;
2413
+ if (!this.consumeRestartBudget()) {
2414
+ this.setState("abandoned");
2415
+ return;
2416
+ }
2417
+ await sleepUnref(this.cooldownForBackoff(backoffOrdinal));
2418
+ if (this.callerInitiated) return;
2419
+ const attemptEpoch = this.teardownEpoch;
2420
+ try {
2421
+ const candidate = await this.connectWithBudget();
2422
+ if (this.teardownEpoch !== attemptEpoch || this.callerInitiated) {
2423
+ await candidate.close().catch(noopAsync);
2424
+ return;
2425
+ }
2426
+ await this.adoptGeneration(candidate, attemptEpoch);
2427
+ if (this.teardownEpoch !== attemptEpoch || this.callerInitiated) return;
2428
+ this.setState("healthy");
2429
+ return;
2430
+ } catch {
2431
+ backoffOrdinal += 1;
2432
+ }
2433
+ }
2434
+ }
2435
+ /**
2436
+ * One reconnect attempt under the connect budget. A factory that never
2437
+ * settles counts as a failed attempt; a connection arriving after the
2438
+ * budget expired is closed on arrival so no orphan generation leaks.
2439
+ */
2440
+ async connectWithBudget() {
2441
+ let settled = false;
2442
+ return new Promise((resolve, reject) => {
2443
+ const late = (candidate) => {
2444
+ candidate.close().catch(noopAsync);
2445
+ };
2446
+ const timer = setTimeout(() => {
2447
+ if (settled) return;
2448
+ settled = true;
2449
+ reject(/* @__PURE__ */ new Error(`recovery connect exceeded ${this.connectTimeoutMs}ms`));
2450
+ }, this.connectTimeoutMs);
2451
+ timer.unref();
2452
+ this.connectFactory().then((candidate) => {
2453
+ if (settled) {
2454
+ late(candidate);
2455
+ return;
2456
+ }
2457
+ settled = true;
2458
+ clearTimeout(timer);
2459
+ resolve(candidate);
2460
+ }, (error) => {
2461
+ if (settled) return;
2462
+ settled = true;
2463
+ clearTimeout(timer);
2464
+ reject(error instanceof Error ? error : new Error(String(error)));
2465
+ });
2466
+ });
2467
+ }
2468
+ cooldownForBackoff(backoffOrdinal) {
2469
+ return Math.min(this.cooldownMs * this.backoffFactor ** backoffOrdinal, this.backoffCapMs);
2470
+ }
2471
+ /**
2472
+ * Sliding-window budget: one timestamp per respawn attempt (successful or
2473
+ * not — both are respawns the consumer experienced). In-memory per
2474
+ * supervisor by ruling; a fresh `createTray` starts a fresh budget.
2475
+ */
2476
+ consumeRestartBudget() {
2477
+ const windowStart = Date.now() - this.recoveryWindowMs;
2478
+ this.restartTimestamps = this.restartTimestamps.filter((timestamp) => timestamp > windowStart);
2479
+ if (this.restartTimestamps.length >= this.maxRestarts) return false;
2480
+ this.restartTimestamps.push(Date.now());
2481
+ return true;
2482
+ }
2483
+ /**
2484
+ * Wires the candidate generation, replays the journal, and runs the
2485
+ * facade rebuild callbacks. The candidate becomes `this.connection` for
2486
+ * the adoption's duration — facade rebuild callbacks route their
2487
+ * re-creation commands through this supervisor, so they must reach the
2488
+ * candidate, not the dead generation (the rebuild-routing law). A failure
2489
+ * rolls the candidate back out (unwired, closed) and restores the dead
2490
+ * previous connection so consumer calls keep failing fast; a teardown
2491
+ * racing the adoption aborts without resurrecting anything.
2492
+ */
2493
+ async adoptGeneration(candidate, attemptEpoch) {
2494
+ const previousConnection = this.connection;
2495
+ this.generation += 1;
2496
+ this.unwireGeneration = this.wireGeneration(candidate);
2497
+ this.connection = candidate;
2498
+ try {
2499
+ await this.replayJournal(candidate);
2500
+ for (const rebuild of [...this.rebuildCallbacks]) await rebuild({
2501
+ generation: this.generation,
2502
+ sessionId: candidate.sessionId.length > 0 ? candidate.sessionId : void 0
2503
+ });
2504
+ if (this.teardownEpoch !== attemptEpoch || this.callerInitiated) {
2505
+ this.rollbackAdoption(candidate, previousConnection);
2506
+ throw new Error("recovery aborted by caller-initiated teardown");
2507
+ }
2508
+ } catch (error) {
2509
+ this.rollbackAdoption(candidate, previousConnection);
2510
+ throw error;
2511
+ }
2512
+ this.consecutiveHeartbeatFailures = 0;
2513
+ this.noteSuccessfulSettlement();
2514
+ }
2515
+ /**
2516
+ * Removes a failed (or torn-down) adoption candidate: stop its wiring,
2517
+ * close it bounded, and restore the previous dead connection unless a
2518
+ * caller-initiated shutdown already dissolved the runtime (shutdown's own
2519
+ * close handled the candidate in that window; the restore must not undo
2520
+ * the torn-down state).
2521
+ */
2522
+ rollbackAdoption(candidate, previousConnection) {
2523
+ this.unwireGeneration();
2524
+ this.unwireGeneration = noop$1;
2525
+ if (this.connection === candidate) this.connection = this.callerInitiated ? void 0 : previousConnection;
2526
+ candidate.close().catch(noopAsync);
2527
+ }
2528
+ /**
2529
+ * Replays the declarative journal in record order with fresh request ids
2530
+ * under the bootstrap deadline class. The resolved default app of the new
2531
+ * generation becomes the appId authority: replayed frames are rewritten
2532
+ * onto it, so app-scoped state follows broker truth instead of a stale
2533
+ * generation's identity.
2534
+ */
2535
+ async replayJournal(connection) {
2536
+ let ordinal = 0;
2537
+ const appIdRewrites = /* @__PURE__ */ new Map();
2538
+ const rewriteAppId = (appId) => appIdRewrites.get(appId) ?? appId;
2539
+ for (const entry of this.journal) {
2540
+ ordinal += 1;
2541
+ const requestId = `supervisor-replay-${this.generation}-${ordinal}`;
2542
+ let frame;
2543
+ switch (entry.kind) {
2544
+ case "resolve-default-app":
2545
+ frame = {
2546
+ type: "resolve-default-app",
2547
+ requestId
2548
+ };
2549
+ break;
2550
+ case "create-tray":
2551
+ frame = {
2552
+ type: "create-tray",
2553
+ requestId,
2554
+ app: { appId: rewriteAppId(entry.app.appId) },
2555
+ tray: {
2556
+ ...entry.tray,
2557
+ id: entry.trayId
2558
+ }
2559
+ };
2560
+ break;
2561
+ case "set-tray-menu":
2562
+ frame = {
2563
+ type: "set-tray-menu",
2564
+ requestId,
2565
+ appId: rewriteAppId(entry.appId),
2566
+ trayId: entry.trayId,
2567
+ menu: entry.menu
2568
+ };
2569
+ break;
2570
+ case "set-tray-icon":
2571
+ frame = {
2572
+ type: "set-tray-icon",
2573
+ requestId,
2574
+ appId: rewriteAppId(entry.appId),
2575
+ trayId: entry.trayId,
2576
+ icon: entry.icon
2577
+ };
2578
+ break;
2579
+ case "set-tray-tooltip":
2580
+ frame = {
2581
+ type: "set-tray-tooltip",
2582
+ requestId,
2583
+ appId: rewriteAppId(entry.appId),
2584
+ trayId: entry.trayId,
2585
+ tooltip: entry.tooltip
2586
+ };
2587
+ break;
2588
+ case "set-app-name":
2589
+ frame = {
2590
+ type: "set-app-name",
2591
+ requestId,
2592
+ appId: rewriteAppId(entry.appId),
2593
+ name: entry.name
2594
+ };
2595
+ break;
2596
+ case "set-app-icon":
2597
+ frame = {
2598
+ type: "set-app-icon",
2599
+ requestId,
2600
+ appId: rewriteAppId(entry.appId),
2601
+ appIcon: entry.appIcon
2602
+ };
2603
+ break;
2604
+ case "set-app-icon-variant":
2605
+ frame = {
2606
+ type: "set-app-icon-variant",
2607
+ requestId,
2608
+ appId: rewriteAppId(entry.appId),
2609
+ variant: entry.variant
2610
+ };
2611
+ break;
2612
+ case "load-ext":
2613
+ frame = {
2614
+ type: "load-ext",
2615
+ requestId,
2616
+ appId: rewriteAppId(entry.appId),
2617
+ name: entry.name,
2618
+ path: entry.path,
2619
+ expectedIdentity: entry.expectedIdentity,
2620
+ ...entry.mountId === void 0 ? {} : { mountId: entry.mountId }
2621
+ };
2622
+ break;
2623
+ }
2624
+ const response = await connection.request(frame, { deadlineMs: BOOTSTRAP_CALL_DEADLINE_MS });
2625
+ if (response.type === "error") throw new BrokerServerError(response.code, response.message, { details: response.details });
2626
+ if (entry.kind === "resolve-default-app" && response.type === "default-app") appIdRewrites.set(entry.appId, response.app.appId);
2627
+ }
2628
+ }
2629
+ setState(state) {
2630
+ if (this.state === state) return;
2631
+ this.state = state;
2632
+ if (state === "abandoned") this.stopHeartbeat();
2633
+ for (const listener of [...this.stateListeners]) try {
2634
+ listener(state);
2635
+ } catch {}
2636
+ }
2637
+ commitJournalEntry(frame, response) {
2638
+ switch (frame.type) {
2639
+ case "resolve-default-app":
2640
+ if (response.type === "default-app") this.upsertJournal({
2641
+ kind: "resolve-default-app",
2642
+ appId: response.app.appId
2643
+ });
2644
+ return;
2645
+ case "create-tray": {
2646
+ const trayId = response.type === "tray-created" && typeof response.trayId === "string" ? response.trayId : frame.tray.id;
2647
+ this.journal.push({
2648
+ kind: "create-tray",
2649
+ app: frame.app,
2650
+ tray: frame.tray,
2651
+ trayId
2652
+ });
2653
+ return;
2654
+ }
2655
+ case "set-tray-menu":
2656
+ this.upsertJournal({
2657
+ kind: "set-tray-menu",
2658
+ appId: frame.appId,
2659
+ trayId: frame.trayId,
2660
+ menu: frame.menu
2661
+ });
2662
+ return;
2663
+ case "set-tray-icon":
2664
+ this.upsertJournal({
2665
+ kind: "set-tray-icon",
2666
+ appId: frame.appId,
2667
+ trayId: frame.trayId,
2668
+ icon: frame.icon
2669
+ });
2670
+ return;
2671
+ case "set-tray-tooltip":
2672
+ this.upsertJournal({
2673
+ kind: "set-tray-tooltip",
2674
+ appId: frame.appId,
2675
+ trayId: frame.trayId,
2676
+ tooltip: frame.tooltip
2677
+ });
2678
+ return;
2679
+ case "set-app-name":
2680
+ this.upsertJournal({
2681
+ kind: "set-app-name",
2682
+ appId: frame.appId,
2683
+ name: frame.name
2684
+ });
2685
+ return;
2686
+ case "set-app-icon":
2687
+ this.upsertJournal({
2688
+ kind: "set-app-icon",
2689
+ appId: frame.appId,
2690
+ appIcon: frame.appIcon
2691
+ });
2692
+ return;
2693
+ case "set-app-icon-variant":
2694
+ this.upsertJournal({
2695
+ kind: "set-app-icon-variant",
2696
+ appId: frame.appId,
2697
+ variant: frame.variant
2698
+ });
2699
+ return;
2700
+ case "load-ext":
2701
+ this.upsertJournal({
2702
+ kind: "load-ext",
2703
+ appId: frame.appId,
2704
+ name: frame.name,
2705
+ path: frame.path,
2706
+ expectedIdentity: frame.expectedIdentity,
2707
+ ...frame.mountId === void 0 ? {} : { mountId: frame.mountId }
2708
+ });
2709
+ return;
2710
+ default: return;
2711
+ }
2712
+ }
2713
+ /**
2714
+ * Keyed entries replace in place at first-occurrence position (last-write-
2715
+ * wins value, stable ordering); first occurrences append. Tray creations
2716
+ * and mount declarations keep their creation order by construction.
2717
+ */
2718
+ upsertJournal(entry) {
2719
+ const key = journalKeyOf(entry);
2720
+ if (key === void 0) {
2721
+ this.journal.push(entry);
2722
+ return;
2723
+ }
2724
+ const existing = this.journal.findIndex((candidate) => journalKeyOf(candidate) === key);
2725
+ if (existing < 0) {
2726
+ this.journal.push(entry);
2727
+ return;
2728
+ }
2729
+ this.journal[existing] = entry;
2730
+ }
2731
+ evictJournalTray(appId, trayId) {
2732
+ this.journal = this.journal.filter((entry) => {
2733
+ switch (entry.kind) {
2734
+ case "create-tray": return !(entry.app.appId === appId && entry.trayId === trayId);
2735
+ case "set-tray-menu":
2736
+ case "set-tray-icon":
2737
+ case "set-tray-tooltip": return !(entry.appId === appId && entry.trayId === trayId);
2738
+ default: return true;
2739
+ }
2740
+ });
2741
+ }
2742
+ };
2743
+ /**
2744
+ * Snapshot candidate for the declarative journal: a defensive clone of every
2745
+ * journalable mutating frame, taken before the await so later caller
2746
+ * mutation of the frame object cannot corrupt the recorded state. The entry
2747
+ * is committed only after the frame settles successfully.
2748
+ */
2749
+ const journalCandidateFrom = (frame) => {
2750
+ switch (frame.type) {
2751
+ case "resolve-default-app":
2752
+ case "create-tray":
2753
+ case "set-tray-menu":
2754
+ case "set-tray-icon":
2755
+ case "set-tray-tooltip":
2756
+ case "set-app-name":
2757
+ case "set-app-icon":
2758
+ case "set-app-icon-variant":
2759
+ case "load-ext": return structuredClone(frame);
2760
+ default: return;
2761
+ }
2762
+ };
2763
+ /** Constructs a supervised transport around one connect factory. */
2764
+ const createTransportSupervisor = (options) => new SupervisedLocalBrokerConnection(options.connect, options);
2765
+ const noop$1 = () => {};
2766
+ const noopAsync = () => {};
1900
2767
  //#endregion
1901
2768
  //#region src/menu-input.ts
1902
2769
  const normalizeCreateTrayMenu = (menu) => {
@@ -2054,23 +2921,28 @@ const createTray = async (options, runtimeOptions = {}) => {
2054
2921
  const appIcon = runtimeOptions.appIcon === void 0 ? void 0 : normalizeAppIcon(runtimeOptions.appIcon);
2055
2922
  const normalized = normalizeCreateTrayOptions(options);
2056
2923
  const appLaunch = normalizeAppLaunch(runtimeOptions.appLaunch);
2057
- const connection = await connectLocalBroker({
2058
- ...runtimeOptions,
2059
- ...appIcon === void 0 ? {} : { appIcon },
2060
- appLaunch
2924
+ const { recovery, ...brokerOptions } = runtimeOptions;
2925
+ const supervisor = createTransportSupervisor({
2926
+ connect: () => connectLocalBroker({
2927
+ ...brokerOptions,
2928
+ ...appIcon === void 0 ? {} : { appIcon },
2929
+ appLaunch
2930
+ }),
2931
+ ...recovery === void 0 ? {} : { recovery }
2061
2932
  });
2062
2933
  try {
2063
- const tray = await createClient(connection, { appOptions: {
2934
+ await supervisor.connect();
2935
+ const tray = await createClient(supervisor, { appOptions: {
2064
2936
  ...runtimeOptions.appName === void 0 ? {} : { name: runtimeOptions.appName },
2065
2937
  ...appIcon === void 0 ? {} : { appIcon }
2066
2938
  } }).createTray(normalized.options);
2067
2939
  return wrapCreateTrayHandle(tray, {
2068
- closeConnection: () => connection.close(),
2940
+ shutdown: () => supervisor.shutdown(),
2069
2941
  destroyPromise: void 0,
2070
2942
  menuUnsubscribe: bindMenuClickHandlers(tray, normalized.menuHandlers)
2071
2943
  });
2072
2944
  } catch (error) {
2073
- await connection.close().catch(noop);
2945
+ await supervisor.shutdown().catch(noop);
2074
2946
  throw error;
2075
2947
  }
2076
2948
  };
@@ -2117,7 +2989,7 @@ const wrapCreateTrayHandle = (tray, state) => {
2117
2989
  if (!isTransportClosed(error)) throw error;
2118
2990
  } finally {
2119
2991
  try {
2120
- await state.closeConnection();
2992
+ await state.shutdown();
2121
2993
  } catch (error) {
2122
2994
  if (!isTransportClosed(error)) throw error;
2123
2995
  }
@@ -2135,6 +3007,6 @@ const bindMenuClickHandlers = (tray, handlers) => {
2135
3007
  };
2136
3008
  const noop = () => {};
2137
3009
  //#endregion
2138
- export { AppIconVariantNotFoundError, BROKER_CONNECTION_CLOSED_MESSAGE, BrokerProtocolVersionError, BrokerServerError, DEFAULT_APP_ICON_VARIANT, EMBEDDED_STAGING_MANIFEST_PATH, EXTENSION_EVENT_RECORD_MAX_BYTES, EXTENSION_TRANSPORT_CLOSED_CODE, ExtensionOperationError, InvalidAppIconError, NATIVE_EXTENSION_EMBEDDED_ERROR_CODES, NATIVE_EXTENSION_EMBEDDED_TARGET_MATRIX, NativeExtensionArtifactResolutionError, NativeExtensionEmbeddedArtifactError, PROTOCOL_VERSION, createBrokerEndpointIdentity, createClient, createInitFrame, createTray, createTrayHandle, formatBrokerEndpointName, formatBrokerStateRoot, formatUnixSocketPath, formatWindowsPipeName, isSupportedProtocolVersion, resolveNativeExtensionArtifact, validateAppIcon };
3010
+ export { AppIconVariantNotFoundError, BOOTSTRAP_CALL_DEADLINE_MS, BROKER_CONNECTION_CLOSED_MESSAGE, BrokerProtocolVersionError, BrokerServerError, DEFAULT_APP_ICON_VARIANT, EMBEDDED_STAGING_MANIFEST_PATH, EXTENSION_EVENT_RECORD_MAX_BYTES, EXTENSION_TRANSPORT_CLOSED_CODE, ExtensionOperationError, INTERACTIVE_CALL_DEADLINE_MS, InvalidAppIconError, NATIVE_EXTENSION_EMBEDDED_ERROR_CODES, NATIVE_EXTENSION_EMBEDDED_TARGET_MATRIX, NativeExtensionArtifactResolutionError, NativeExtensionEmbeddedArtifactError, PROTOCOL_VERSION, TEARDOWN_CALL_DEADLINE_MS, TransportAbandonedError, TransportTimeoutError, createBrokerEndpointIdentity, createClient, createInitFrame, createTransportSupervisor, createTray, createTrayHandle, formatBrokerEndpointName, formatBrokerStateRoot, formatUnixSocketPath, formatWindowsPipeName, isSupportedProtocolVersion, resolveNativeExtensionArtifact, validateAppIcon };
2139
3011
 
2140
3012
  //# sourceMappingURL=index.mjs.map