dsh-zen-remote 2.3.10 → 2.4.0
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/CHANGELOG.md +44 -72
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +573 -366
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +575 -370
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/src/intercept.ts
CHANGED
|
@@ -81,20 +81,23 @@
|
|
|
81
81
|
|
|
82
82
|
import { RelayError, defaultClock } from './relay-client.js'
|
|
83
83
|
import type { RelayClient, RelayClock, RelayState } from './relay-client.js'
|
|
84
|
+
import type { RelayPool } from './relay-pool.js'
|
|
84
85
|
import { checkGatewayShape } from './intercept-shape.js'
|
|
85
86
|
import type { GatewayShapeCheck } from './intercept-shape.js'
|
|
86
87
|
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
|
|
87
88
|
import { INVOKE_BINARY_RESULT_METHODS, extractResultAttachments, restoreInvokeBinary, type ResultAttachment } from './invoke-binary.js'
|
|
88
89
|
import {
|
|
90
|
+
composeMergedWorkspaceFrames,
|
|
89
91
|
createControlMerger,
|
|
90
92
|
createWorkspaceMerger,
|
|
93
|
+
localWorkspaceLists,
|
|
91
94
|
mergeModelCatalogs,
|
|
92
95
|
mergeSessionList,
|
|
93
96
|
sessionSummaryAddedFrames,
|
|
94
97
|
virtualSessionIdsInWorkspaceFrames,
|
|
95
98
|
virtualizeModelSelectionValue,
|
|
96
99
|
} from './merge-streams.js'
|
|
97
|
-
import type { ControlMerger, MergerAnnotation, MergerIdentity, WorkspaceMerger } from './merge-streams.js'
|
|
100
|
+
import type { ControlMerger, MergerAnnotation, MergerIdentity, MergerLang, RemoteWorkspaceContribution, WorkspaceMerger } from './merge-streams.js'
|
|
98
101
|
|
|
99
102
|
/** The session-locating argument fields, as registered per method. Identical
|
|
100
103
|
* in name and meaning to relay-access.ts's `SessionField`; a field named
|
|
@@ -203,6 +206,11 @@ export const CLIENT_METHOD_FIELDS: Readonly<Record<string, readonly SessionField
|
|
|
203
206
|
'terminal/rename': ['agentId'],
|
|
204
207
|
'terminal/close': ['agentId'],
|
|
205
208
|
'terminal/follow': ['agentId'],
|
|
209
|
+
// TA-1: the 0.2.2 duplex stream (follow+write+resize merged), located by
|
|
210
|
+
// the same agentId. TA-2 wired the interceptor half: the host's uplink
|
|
211
|
+
// inbox and its window travel to the relay's openStream options (see
|
|
212
|
+
// REMOTE_UPLINK_STREAM_METHODS) instead of being half-closed.
|
|
213
|
+
'terminal/attach': ['agentId'],
|
|
206
214
|
// the retain/list half locates by a plain top-level json sessionId
|
|
207
215
|
'terminal/list': ['sessionId'],
|
|
208
216
|
'terminal/retain': ['sessionId'],
|
|
@@ -344,12 +352,54 @@ export function isRemoteWrite(endpoint: string): boolean {
|
|
|
344
352
|
return !REMOTE_READ_METHODS.has(endpoint)
|
|
345
353
|
}
|
|
346
354
|
|
|
347
|
-
/**
|
|
348
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Stream endpoints whose caller-supplied `uplink` iterable and
|
|
357
|
+
* `uplinkWindow` travel to the relay instead of being half-closed locally
|
|
358
|
+
* with `releaseUplink`. DSH 0.2.2+ replaced `terminal/follow|write|resize`
|
|
359
|
+
* with the duplex `terminal/attach` stream (`TerminalInput` write/resize
|
|
360
|
+
* commands ride the uplink; `ack` frames ride the downlink). The relay
|
|
361
|
+
* client's pump (relay-client.ts) is the other half: it forwards the host's
|
|
362
|
+
* frames through the server's `/v1/stream-uplink` channel and releases the
|
|
363
|
+
* host inbox itself when the stream ends — the interceptor only hands the
|
|
364
|
+
* iterable over, it never half-closes a member of this set.
|
|
365
|
+
*/
|
|
366
|
+
export const REMOTE_UPLINK_STREAM_METHODS: ReadonlySet<string> = new Set(['terminal/attach'])
|
|
367
|
+
|
|
368
|
+
export type InterceptLang = MergerLang
|
|
369
|
+
|
|
370
|
+
const INTERCEPT_COPY = {
|
|
371
|
+
zh: {
|
|
372
|
+
writeOffline: '服务端离线,远程会话暂时只读',
|
|
373
|
+
closed: '远程已关闭',
|
|
374
|
+
notConnected: '尚未连接到服务端',
|
|
375
|
+
mismatch: '此远程会话属于其他主服务端',
|
|
376
|
+
unsupported: '此功能暂不支持远程会话',
|
|
377
|
+
referenceNotOnServer: '引用的会话不在服务端上,无法转发',
|
|
378
|
+
revoked: '配对令牌已被吊销,请在设置页重新配对',
|
|
379
|
+
unpaired: '尚未与服务端配对,请在设置页完成配对',
|
|
380
|
+
modelGroupRemote: '该模型分组属于远程服务端,无法在本机会话中使用',
|
|
381
|
+
modelGroupPickServer: (displayName: string) => `请选择「${displayName} · …」分组里的模型`,
|
|
382
|
+
modelGroupMismatch: '此模型分组属于其他主服务端',
|
|
383
|
+
},
|
|
384
|
+
en: {
|
|
385
|
+
writeOffline: 'Server offline — remote session is temporarily read-only',
|
|
386
|
+
closed: 'Remote closed',
|
|
387
|
+
notConnected: 'Not connected to the server yet',
|
|
388
|
+
mismatch: 'This remote session belongs to another server',
|
|
389
|
+
unsupported: 'This feature is not supported in remote sessions yet',
|
|
390
|
+
referenceNotOnServer: 'Referenced session is not on the server and cannot be forwarded',
|
|
391
|
+
revoked: 'Pairing token was revoked — please pair again in Settings',
|
|
392
|
+
unpaired: 'Not paired with the server yet — please pair in Settings',
|
|
393
|
+
modelGroupRemote: 'This model group belongs to a remote server and cannot be used in a local session',
|
|
394
|
+
modelGroupPickServer: (displayName: string) => `Select a model under "${displayName} · …"`,
|
|
395
|
+
modelGroupMismatch: 'This model group belongs to another server',
|
|
396
|
+
},
|
|
397
|
+
} as const
|
|
349
398
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
399
|
+
function resolveInterceptLang(lang: InterceptLang | (() => InterceptLang) | undefined): InterceptLang {
|
|
400
|
+
const value = typeof lang === 'function' ? lang() : lang
|
|
401
|
+
return value === 'en' ? 'en' : 'zh'
|
|
402
|
+
}
|
|
353
403
|
|
|
354
404
|
/**
|
|
355
405
|
* The RelayError codes that mean "the LINK went down under a live stream"
|
|
@@ -518,6 +568,26 @@ function releaseUplink(uplink: unknown): void {
|
|
|
518
568
|
}
|
|
519
569
|
}
|
|
520
570
|
|
|
571
|
+
/**
|
|
572
|
+
* The shape check one duplex stream's `uplinkWindow` must pass before it
|
|
573
|
+
* travels (TA-2) — the same rule the server re-applies on `/v1/stream`
|
|
574
|
+
* (relay-server.ts): positive safe-integer `maxBytes`, non-negative
|
|
575
|
+
* safe-integer `frameOverheadBytes`. Anything else — an older host's
|
|
576
|
+
* `undefined`, a non-object, a fractional or negative budget — comes back
|
|
577
|
+
* `undefined` and the stream forwards with the relay's own default window:
|
|
578
|
+
* the numbers are the host's contract with itself, garbage is dropped, never
|
|
579
|
+
* refused.
|
|
580
|
+
*/
|
|
581
|
+
function normalizeUplinkWindow(raw: unknown): { maxBytes: number; frameOverheadBytes: number } | undefined {
|
|
582
|
+
if (!isPlainObject(raw)) return undefined
|
|
583
|
+
const { maxBytes, frameOverheadBytes } = raw
|
|
584
|
+
if (typeof maxBytes !== 'number' || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) return undefined
|
|
585
|
+
if (typeof frameOverheadBytes !== 'number' || !Number.isSafeInteger(frameOverheadBytes) || frameOverheadBytes < 0) {
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
return { maxBytes, frameOverheadBytes }
|
|
589
|
+
}
|
|
590
|
+
|
|
521
591
|
/** One remote-call failure in the diagnostics ring. */
|
|
522
592
|
export interface InterceptFailureRecord {
|
|
523
593
|
/** ISO timestamp of the moment the failure was recorded. */
|
|
@@ -622,12 +692,21 @@ export interface InstallInterceptOptions {
|
|
|
622
692
|
/** The RAW gateway instance (`ctx.typertGateway[symbols.original]`), not
|
|
623
693
|
* the cordis proxy — own properties on the proxy are ignored by everyone. */
|
|
624
694
|
raw: object
|
|
625
|
-
/**
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
695
|
+
/** SINGLE-SERVER mode (the original wiring, kept for compatibility): the
|
|
696
|
+
* one client relay client the calls travel through. Either this +
|
|
697
|
+
* {@link getServerId}, or {@link pool} — never required together. */
|
|
698
|
+
relay?: RelayClient
|
|
699
|
+
/** SINGLE-SERVER mode: the CURRENT handshake's server id, read live per
|
|
700
|
+
* call; `undefined` (never handshook) makes every remote call a
|
|
701
|
+
* `remote-offline` — a different fact from pointing at the wrong server
|
|
702
|
+
* (`remote-mismatch`). */
|
|
703
|
+
getServerId?: () => string | undefined
|
|
704
|
+
/** MS-3 POOL mode: the multi-server relay pool (up to
|
|
705
|
+
* `MAX_PAIRED_SERVERS` paired servers). When present it replaces the
|
|
706
|
+
* single-relay routing: every call/stream/event routes by the virtual
|
|
707
|
+
* id's `serverId` to the matching pool entry, and the three global reads
|
|
708
|
+
* plus the model catalog merge ALL paired servers in pairing order. */
|
|
709
|
+
pool?: RelayPool
|
|
631
710
|
/** Progress logging, wired to the context logger by index.ts. */
|
|
632
711
|
log?: (format: string, ...args: unknown[]) => void
|
|
633
712
|
/** Clock/timers for the merged streams' reopen backoff (CP4); defaults to
|
|
@@ -645,10 +724,15 @@ export interface InstallInterceptOptions {
|
|
|
645
724
|
/** PB-25b: the inventory slot factory — one slot per merged global stream
|
|
646
725
|
* (only the workspace merger ever reports; the control merger has no
|
|
647
726
|
* session inventory). The wiring unions the live slots' shares into the
|
|
648
|
-
* set the plugin-route rewrite
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
|
|
727
|
+
* set the plugin-route rewrite consults. MS-3: the factory receives the
|
|
728
|
+
* slot's `serverId`, so a pool wiring can keep per-server inventories
|
|
729
|
+
* (the plain single-server wiring can ignore the argument). Absent: no
|
|
730
|
+
* shares are ever reported and the plugin-route value scan stays exactly
|
|
731
|
+
* the request-local behavior. */
|
|
732
|
+
createSessionInventorySlot?: (serverId?: string) => SessionInventorySlot
|
|
733
|
+
/** Language for error messages and sidebar group suffixes (`'zh'` default,
|
|
734
|
+
* `'en'` English). Accepts a getter for live resolution. */
|
|
735
|
+
lang?: InterceptLang | (() => InterceptLang) | undefined
|
|
652
736
|
}
|
|
653
737
|
|
|
654
738
|
export interface InterceptHandle {
|
|
@@ -677,6 +761,20 @@ export interface InterceptHandle {
|
|
|
677
761
|
* entries are dropped; a batch with nothing left is a no-op. Reaches every
|
|
678
762
|
* live merged `$events` stream, and the pending slot when none is open. */
|
|
679
763
|
nudgeSummarySync(ids: readonly string[]): void
|
|
764
|
+
/** MS-3 (pool mode only meaningful, harmless otherwise): the pool's entry
|
|
765
|
+
* set or any entry's identity moved — the wiring forwards the pool's
|
|
766
|
+
* `onServerRemoved` / `onStateChange` callbacks here. Every live merged
|
|
767
|
+
* stream re-scans `pool.entries()` NOW: slots for removed entries leave
|
|
768
|
+
* (only THAT server's shown groups and pending events are torn down),
|
|
769
|
+
* slots for new entries start, and renames re-title the groups. */
|
|
770
|
+
notePoolEvent(): void
|
|
771
|
+
/** P55-2: resolve which remote session (serverId + real sessionId) should
|
|
772
|
+
* serve a path-only `/api/file?path=...` request, or `undefined` when the
|
|
773
|
+
* local route should handle it. Read by the fetch-route wrap (its media
|
|
774
|
+
* mount only exists while the wiring hands THIS handle's method over), so
|
|
775
|
+
* a Markdown image of a remote session's reply loads from the server's
|
|
776
|
+
* disk instead of 404ing on the sub-client's own. */
|
|
777
|
+
resolveRemoteFileScope(path: string): { serverId: string; sessionId: string } | undefined
|
|
680
778
|
}
|
|
681
779
|
|
|
682
780
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -1369,6 +1467,7 @@ interface MergedGlobalStreamDeps {
|
|
|
1369
1467
|
* {@link SessionInventorySlot}). One slot per merged stream, disposed with
|
|
1370
1468
|
* it; absent when the wiring passed no factory. */
|
|
1371
1469
|
createSessionInventorySlot?: () => SessionInventorySlot
|
|
1470
|
+
lang?: InterceptLang | (() => InterceptLang) | undefined
|
|
1372
1471
|
}
|
|
1373
1472
|
|
|
1374
1473
|
/**
|
|
@@ -1413,6 +1512,7 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
|
|
|
1413
1512
|
serverName: initial?.serverName ?? '',
|
|
1414
1513
|
onDiagnostic,
|
|
1415
1514
|
now: clock.now,
|
|
1515
|
+
lang: deps.lang,
|
|
1416
1516
|
...(sessionSlot !== undefined ? { onKnownSessions: (ids: ReadonlySet<string>) => sessionSlot.replace(ids) } : {}),
|
|
1417
1517
|
})
|
|
1418
1518
|
|
|
@@ -1470,7 +1570,13 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
|
|
|
1470
1570
|
// away, and a navigation writes no relay frames. An `unavailable` read is
|
|
1471
1571
|
// skipped entirely: the merger falls back to the conservative tombstone
|
|
1472
1572
|
// behavior then.
|
|
1573
|
+
let lastLang: InterceptLang = resolveInterceptLang(deps.lang)
|
|
1473
1574
|
const informCurrentSession = (): void => {
|
|
1575
|
+
const nextLang = resolveInterceptLang(deps.lang)
|
|
1576
|
+
if (nextLang !== lastLang) {
|
|
1577
|
+
lastLang = nextLang
|
|
1578
|
+
for (const frame of merger.onStatusChanged()) channel.push(frame)
|
|
1579
|
+
}
|
|
1474
1580
|
const read = deps.getCurrentSession()
|
|
1475
1581
|
if (read.kind === 'unavailable') return
|
|
1476
1582
|
for (const frame of merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined)) channel.push(frame)
|
|
@@ -2495,241 +2601,1490 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2495
2601
|
}
|
|
2496
2602
|
}
|
|
2497
2603
|
|
|
2604
|
+
// ---- MS-3: the pool-mode merged streams ------------------------------------
|
|
2605
|
+
//
|
|
2606
|
+
// The pool-mode counterparts of the two merged streams above. They keep the
|
|
2607
|
+
// SAME outer contract (the local stream is the base; a remote death emits
|
|
2608
|
+
// nothing; the hold/reopen discipline) but run ONE leg per pooled server:
|
|
2609
|
+
// every slot is keyed by its handshake `serverId`, the slot set is
|
|
2610
|
+
// re-scanned on every pool event and every slot's own state change, and the
|
|
2611
|
+
// canonical merged frames compose the slots' contributions in PAIRING order
|
|
2612
|
+
// (pool.entries() order — the connect order never reorders the sidebar).
|
|
2613
|
+
// A removed entry tears down only ITS slot: only that server's shown
|
|
2614
|
+
// workspace groups leave the sidebar and only that server's pending
|
|
2615
|
+
// approvals get the synthesized cancels.
|
|
2616
|
+
|
|
2617
|
+
interface PoolGlobalSlot {
|
|
2618
|
+
kind: 'workspace' | 'control'
|
|
2619
|
+
serverId: string
|
|
2620
|
+
entryId: string
|
|
2621
|
+
client: RelayClient
|
|
2622
|
+
merger: WorkspaceMerger | ControlMerger
|
|
2623
|
+
/** The pool display name this slot's merger is currently titled with. */
|
|
2624
|
+
viewName: string
|
|
2625
|
+
controller: AbortController | undefined
|
|
2626
|
+
offState: () => void
|
|
2627
|
+
annotation: MergerAnnotation
|
|
2628
|
+
reopenDelayMs: number
|
|
2629
|
+
removed: boolean
|
|
2630
|
+
/** PB-25b, pool form: one inventory slot per WORKSPACE server, disposed
|
|
2631
|
+
* with the slot (the single-server stream's slot dies with the stream). */
|
|
2632
|
+
inventory: SessionInventorySlot | undefined
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
interface MergedGlobalStreamMultiDeps {
|
|
2636
|
+
endpoint: 'workspace/follow' | 'session/control'
|
|
2637
|
+
namespace: string
|
|
2638
|
+
method: string
|
|
2639
|
+
local: AsyncIterable<unknown>
|
|
2640
|
+
pool: RelayPool
|
|
2641
|
+
signal: AbortSignal | undefined
|
|
2642
|
+
recordFailure: (endpoint: string, code: string) => void
|
|
2643
|
+
log: ((format: string, ...args: unknown[]) => void) | undefined
|
|
2644
|
+
clock: RelayClock
|
|
2645
|
+
getCurrentSession: () => CurrentSessionRead
|
|
2646
|
+
watchCurrentSession(fn: () => void): () => void
|
|
2647
|
+
watchUninstall(fn: () => void): () => void
|
|
2648
|
+
summarySync: SessionSummarySync
|
|
2649
|
+
createSessionInventorySlot?: (serverId?: string) => SessionInventorySlot
|
|
2650
|
+
/** Subscribe to the wiring's pool-event broadcasts (handle.notePoolEvent).
|
|
2651
|
+
* Every broadcast re-scans the slot set. */
|
|
2652
|
+
watchPool(fn: () => void): () => void
|
|
2653
|
+
lang?: InterceptLang | (() => InterceptLang) | undefined
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2498
2656
|
/**
|
|
2499
|
-
*
|
|
2500
|
-
*
|
|
2501
|
-
*
|
|
2502
|
-
*
|
|
2657
|
+
* The pool-mode merged global stream. Per server slot the same rules as the
|
|
2658
|
+
* single-server merged stream apply; the slots' outputs differ only in the
|
|
2659
|
+
* GLOBAL frames (`order` / `archived` / `pinned` and the baseline's merged
|
|
2660
|
+
* lists), which are never taken from a slot's own merger — they are
|
|
2661
|
+
* composed canonically from every slot's contribution in pairing order
|
|
2662
|
+
* (composeMergedWorkspaceFrames). Per-workspace frames (upserts and
|
|
2663
|
+
* removes) are slot-local facts and pass as the merger produced them.
|
|
2503
2664
|
*/
|
|
2504
|
-
|
|
2505
|
-
const {
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
// reaches whichever $events generation is open.
|
|
2511
|
-
const summarySync = createSessionSummarySync()
|
|
2512
|
-
// PB-25b: the inventory slot factory (the wiring's union feed) — passed
|
|
2513
|
-
// down to every merged stream, which owns one slot for its lifetime.
|
|
2514
|
-
const createSessionInventorySlot = options.createSessionInventorySlot
|
|
2515
|
-
// T62: the current-session getter and the live-merged-stream listener
|
|
2516
|
-
// registry behind handle.currentSessionChanged().
|
|
2517
|
-
const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }) as const)
|
|
2518
|
-
const currentSessionListeners = new Set<() => void>()
|
|
2519
|
-
const watchCurrentSession = (fn: () => void): (() => void) => {
|
|
2520
|
-
currentSessionListeners.add(fn)
|
|
2521
|
-
return () => { currentSessionListeners.delete(fn) }
|
|
2665
|
+
async function* mergedGlobalStreamMulti(deps: MergedGlobalStreamMultiDeps): AsyncGenerator<unknown, void, undefined> {
|
|
2666
|
+
const { endpoint, namespace, method, local, pool, signal, recordFailure, log, clock } = deps
|
|
2667
|
+
if (signal?.aborted === true) return
|
|
2668
|
+
const isWorkspace = endpoint === 'workspace/follow'
|
|
2669
|
+
const onDiagnostic = (message: string): void => {
|
|
2670
|
+
log?.('client multi merge dropped a frame on %s: %s', endpoint, message)
|
|
2522
2671
|
}
|
|
2523
|
-
const
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2672
|
+
const channel = createFrameChannel()
|
|
2673
|
+
let alive = true
|
|
2674
|
+
const slots = new Map<string, PoolGlobalSlot>()
|
|
2675
|
+
const onlineWaiters: (() => void)[] = []
|
|
2676
|
+
const flushWaiters = (): void => {
|
|
2677
|
+
for (const wake of onlineWaiters.splice(0)) wake()
|
|
2527
2678
|
}
|
|
2528
|
-
const
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
const incompatible: IncompatibleCallRecord[] = []
|
|
2533
|
-
// The closed-session registry (T34): insertion-ordered, capped, virtual-id
|
|
2534
|
-
// keyed. Entries live until THEIR session proves reachable again (a
|
|
2535
|
-
// succeeding call for it) — a reconnect clears nothing (T34-fix): a link
|
|
2536
|
-
// that flapped under a still-standing closure would make the banner flicker
|
|
2537
|
-
// away and back.
|
|
2538
|
-
const closedSessions = new Map<string, ClosedSessionRecord['reason']>()
|
|
2539
|
-
// T73-fix: waiters for "a closed-session record was just cleared" — the
|
|
2540
|
-
// quiet session/follow hold (rewriteUpstream) ends its silence the moment
|
|
2541
|
-
// the session it holds for is served again, so a re-enabled remote comes
|
|
2542
|
-
// back with a fresh history instead of staying blind under its banner.
|
|
2543
|
-
const closedClearWaiters = new Set<() => void>()
|
|
2544
|
-
// The last successfully fetched SERVER model catalog (T52-fix), kept for
|
|
2545
|
-
// the offline merges of the `session/modelCatalog` route below — cleared
|
|
2546
|
-
// on a pairing wall (eagerly, by the T52-fix2 catalog watcher; the route's
|
|
2547
|
-
// own drop is the backstop) and never served for another server's id (see the
|
|
2548
|
-
// route's doc). Only an in-memory value: the groups are display data and
|
|
2549
|
-
// a reload refetches them anyway. Since T52-fix3 it feeds ONLY this route:
|
|
2550
|
-
// the modelSelection provider rewrites are unconditional again (the host's
|
|
2551
|
-
// projection store is first-write-wins per seq, so a catalog-gated rewrite
|
|
2552
|
-
// would poison the store — see virtualizeModelSelectionValue), and the
|
|
2553
|
-
// `groups` membership set is gone with the gate.
|
|
2554
|
-
let serverCatalogCache: { identity: MergerIdentity; value: unknown } | undefined
|
|
2555
|
-
/**
|
|
2556
|
-
* The serving generation, bumped on every pairing wall (`unpaired` /
|
|
2557
|
-
* `revoked`, T52-fix3): a catalog fetch that was IN FLIGHT when the wall
|
|
2558
|
-
* rose must not write its answer back — the wall's groups must not outlive
|
|
2559
|
-
* it, and a late write would resurrect them under the offline merge below.
|
|
2560
|
-
* Both cache writes (the proactive fetch here, the route's own fetch)
|
|
2561
|
-
* snapshot the counter before invoking and compare before storing; a
|
|
2562
|
-
* mismatched answer is silently dropped — it is not a fault, the next
|
|
2563
|
-
* serving period fetches fresh anyway.
|
|
2564
|
-
*/
|
|
2565
|
-
let catalogGeneration = 0
|
|
2566
|
-
const rememberServerCatalog = (identity: MergerIdentity, value: unknown, generation: number): void => {
|
|
2567
|
-
if (generation !== catalogGeneration) return
|
|
2568
|
-
serverCatalogCache = { identity, value }
|
|
2679
|
+
const waitAny = async (): Promise<void> => {
|
|
2680
|
+
await new Promise<void>((resolve) => {
|
|
2681
|
+
onlineWaiters.push(resolve)
|
|
2682
|
+
})
|
|
2569
2683
|
}
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
const
|
|
2578
|
-
const
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal')
|
|
2585
|
-
},
|
|
2586
|
-
)
|
|
2684
|
+
// The orchestrator's LOCAL truth, folded from every local frame — the one
|
|
2685
|
+
// source the canonical composition reads its local lists from (a slot's
|
|
2686
|
+
// merger keeps its own copy only for its discarded single-server frames).
|
|
2687
|
+
let localSeen = false
|
|
2688
|
+
let localBaselineFrame: unknown = undefined
|
|
2689
|
+
let localLists = { orderIds: [], archivedIds: [], pinnedIds: [] } as { orderIds: string[]; archivedIds: string[]; pinnedIds: string[] }
|
|
2690
|
+
|
|
2691
|
+
const annotationOf = (client: RelayClient): MergerAnnotation => {
|
|
2692
|
+
const state = client.state
|
|
2693
|
+
if (state === 'revoked') return 'revoked'
|
|
2694
|
+
if (state === 'unpaired') return 'unpaired'
|
|
2695
|
+
if (state !== 'online') return 'offline'
|
|
2696
|
+
const different = client.compat?.different
|
|
2697
|
+
return different !== undefined && different.length > 0 ? 'mismatch' : 'none'
|
|
2587
2698
|
}
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
// contained: the sync body cannot throw, and the fetch settles through
|
|
2598
|
-
// .then/.catch above.
|
|
2599
|
-
let offCatalogWatch: (() => void) | undefined
|
|
2600
|
-
try {
|
|
2601
|
-
offCatalogWatch = relay.subscribe((state) => {
|
|
2602
|
-
if (state === 'unpaired' || state === 'revoked') {
|
|
2603
|
-
catalogGeneration += 1
|
|
2604
|
-
serverCatalogCache = undefined
|
|
2605
|
-
return
|
|
2606
|
-
}
|
|
2607
|
-
if (state !== 'online') return
|
|
2608
|
-
const identity = relayIdentityOf(relay)
|
|
2609
|
-
if (identity !== undefined) fetchServerCatalog(identity)
|
|
2610
|
-
})
|
|
2611
|
-
// Install into an already-serving relay: subscribe replayed nothing, so
|
|
2612
|
-
// the transition-based fetch would wait for the NEXT flap — fill the
|
|
2613
|
-
// cache now instead (T52-fix3).
|
|
2614
|
-
if (relay.state === 'online') {
|
|
2615
|
-
const identity = relayIdentityOf(relay)
|
|
2616
|
-
if (identity !== undefined) fetchServerCatalog(identity)
|
|
2699
|
+
const workspaceMergerOf = (slot: PoolGlobalSlot): WorkspaceMerger | undefined =>
|
|
2700
|
+
slot.kind === 'workspace' ? (slot.merger as WorkspaceMerger) : undefined
|
|
2701
|
+
|
|
2702
|
+
const pushOut = (frames: readonly unknown[]): void => {
|
|
2703
|
+
if (frames.length === 0) return
|
|
2704
|
+
for (const frame of frames) channel.push(frame)
|
|
2705
|
+
if (isWorkspace) {
|
|
2706
|
+
const ids = virtualSessionIdsInWorkspaceFrames(frames)
|
|
2707
|
+
if (ids.length > 0) deps.summarySync.noteForwardedSessionIds(ids)
|
|
2617
2708
|
}
|
|
2618
|
-
} catch (error) {
|
|
2619
|
-
// Subscription failed: the cache still fills whenever the UI opens a
|
|
2620
|
-
// dropdown (the route's own fetch), only later.
|
|
2621
|
-
log?.('client catalog watcher unavailable: %s', messageOf(error))
|
|
2622
2709
|
}
|
|
2623
|
-
const
|
|
2624
|
-
|
|
2625
|
-
if (
|
|
2626
|
-
|
|
2627
|
-
if (
|
|
2710
|
+
const orderedRemoteParts = (): { slot: PoolGlobalSlot; remote: RemoteWorkspaceContribution }[] => {
|
|
2711
|
+
const out: { slot: PoolGlobalSlot; remote: RemoteWorkspaceContribution }[] = []
|
|
2712
|
+
if (!isWorkspace) return out
|
|
2713
|
+
for (const entry of pool.entries()) {
|
|
2714
|
+
if (entry.serverId === undefined) continue
|
|
2715
|
+
const slot = slots.get(entry.serverId)
|
|
2716
|
+
const merger = slot !== undefined ? workspaceMergerOf(slot) : undefined
|
|
2717
|
+
if (slot === undefined || merger === undefined) continue
|
|
2718
|
+
out.push({ slot, remote: merger.parts().remote })
|
|
2628
2719
|
}
|
|
2629
|
-
|
|
2720
|
+
return out
|
|
2630
2721
|
}
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
if (
|
|
2722
|
+
/** The canonical merged global frames over ALL current slots — pairing
|
|
2723
|
+
* order, local first. Only past the local baseline: before it the slots
|
|
2724
|
+
* cache their remote state and the frames would order workspaces the
|
|
2725
|
+
* local baseline would immediately erase. */
|
|
2726
|
+
const composedFrames = (): { order: unknown; archived: unknown; pinned: unknown } | undefined => {
|
|
2727
|
+
if (!localSeen || !isWorkspace) return undefined
|
|
2728
|
+
const parts = orderedRemoteParts()
|
|
2729
|
+
return composeMergedWorkspaceFrames(localLists, parts.map((part) => part.remote))
|
|
2637
2730
|
}
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
/**
|
|
2642
|
-
* Reload overlay, for the record (CP4): the theoretical hazard is installing
|
|
2643
|
-
* a NEW wrapper while an OLD one is still installed, so each captures the
|
|
2644
|
-
* other and the older survives as a live layer. Cordis `fiber.restart()`
|
|
2645
|
-
* disposes the old fiber FIRST (its effect calls {@link InterceptHandle.uninstall},
|
|
2646
|
-
* restoring the saved property) and only then applies the new row — the
|
|
2647
|
-
* overlay ordering never happens today, so there is nothing to absorb at
|
|
2648
|
-
* install time. Detection is also not cheaply implementable: a reloaded row
|
|
2649
|
-
* loads a FRESH module instance, so a stale wrapper shares no mark with the
|
|
2650
|
-
* new one — telling "ours, from a dead generation" apart from another
|
|
2651
|
-
* plugin's wrapper would take a process-wide registry, which the comment
|
|
2652
|
-
* above argues is not worth it while the ordering cannot occur. The
|
|
2653
|
-
* `installed === false → inert passthrough` rule below is the safety net
|
|
2654
|
-
* for the ordering that CAN happen: a later plugin wrapping OVER us and
|
|
2655
|
-
* keeping a reference past our uninstall.
|
|
2656
|
-
*
|
|
2657
|
-
* Save the CURRENT value per property (possibly another plugin's wrapper —
|
|
2658
|
-
* shape note "own-property"), falling back to the prototype method only
|
|
2659
|
-
* when the current value is not callable. Uninstall restores the saved
|
|
2660
|
-
* own state verbatim: value if there was one, absence if not.
|
|
2661
|
-
*/
|
|
2662
|
-
const saved: Record<string, { existed: boolean; value: unknown }> = {}
|
|
2663
|
-
const chainTarget: Record<string, unknown> = {}
|
|
2664
|
-
for (const name of ['openWireStream', 'dispatchRpc']) {
|
|
2665
|
-
const existed = Object.prototype.hasOwnProperty.call(gateway, name)
|
|
2666
|
-
const value = existed ? gateway[name] : undefined
|
|
2667
|
-
saved[name] = { existed, value }
|
|
2668
|
-
chainTarget[name] = typeof value === 'function' ? value : Object.getPrototypeOf(raw)?.[name]
|
|
2731
|
+
const pushCanonicalTrio = (): void => {
|
|
2732
|
+
const composed = composedFrames()
|
|
2733
|
+
if (composed !== undefined) pushOut([composed.order, composed.archived, composed.pinned])
|
|
2669
2734
|
}
|
|
2670
|
-
// Filled in below, once the wrapper functions exist: uninstall may only
|
|
2671
|
-
// touch a property that still holds OUR wrapper.
|
|
2672
|
-
const wrappers: Record<string, unknown> = {}
|
|
2673
2735
|
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2736
|
+
// -- current-session judging (T58/T62), across every slot ------------------
|
|
2737
|
+
let lastLang: InterceptLang = resolveInterceptLang(deps.lang)
|
|
2738
|
+
const informCurrentSession = (): void => {
|
|
2739
|
+
const nextLang = resolveInterceptLang(deps.lang)
|
|
2740
|
+
if (nextLang !== lastLang) {
|
|
2741
|
+
lastLang = nextLang
|
|
2742
|
+
if (isWorkspace) {
|
|
2743
|
+
let langUpserted = false
|
|
2744
|
+
for (const slot of [...slots.values()]) {
|
|
2745
|
+
const merger = workspaceMergerOf(slot)
|
|
2746
|
+
if (merger === undefined) continue
|
|
2747
|
+
const frames = merger.onStatusChanged()
|
|
2748
|
+
if (frames.length > 0) {
|
|
2749
|
+
pushOut(frames.filter((frame) => isPlainObject(frame) && frame.type === 'upsert'))
|
|
2750
|
+
langUpserted = true
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
if (langUpserted) pushCanonicalTrio()
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
const read = deps.getCurrentSession()
|
|
2757
|
+
if (read.kind === 'unavailable') return
|
|
2758
|
+
let changed = false
|
|
2759
|
+
for (const slot of [...slots.values()]) {
|
|
2760
|
+
if (slot.merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined).length > 0) changed = true
|
|
2761
|
+
}
|
|
2762
|
+
if (!changed || !localSeen || !isWorkspace) return
|
|
2763
|
+
const parts = orderedRemoteParts()
|
|
2764
|
+
pushOut([composeMergedWorkspaceFrames(localLists, parts.map((part) => part.remote)).archived])
|
|
2683
2765
|
}
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2766
|
+
const anyPendingHide = (): boolean => {
|
|
2767
|
+
for (const slot of [...slots.values()]) {
|
|
2768
|
+
if (slot.merger.hasPendingHide) return true
|
|
2769
|
+
}
|
|
2770
|
+
return false
|
|
2771
|
+
}
|
|
2772
|
+
let sessionPoll: unknown = undefined
|
|
2773
|
+
const pollCurrentSession = (): void => {
|
|
2774
|
+
sessionPoll = undefined
|
|
2775
|
+
if (!alive) return
|
|
2776
|
+
if (anyPendingHide()) informCurrentSession()
|
|
2777
|
+
if (alive && anyPendingHide()) sessionPoll = clock.setTimeout(pollCurrentSession, CURRENT_SESSION_POLL_MS)
|
|
2778
|
+
}
|
|
2779
|
+
const ensureSessionPoll = (): void => {
|
|
2780
|
+
if (sessionPoll === undefined && alive && anyPendingHide()) {
|
|
2781
|
+
sessionPoll = clock.setTimeout(pollCurrentSession, CURRENT_SESSION_POLL_MS)
|
|
2782
|
+
}
|
|
2695
2783
|
}
|
|
2696
2784
|
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
type Verdict = { ok: true; serverId: string } | { ok: false; code: string; message: string }
|
|
2707
|
-
function validate(endpoint: string, virtuals: VirtualParts[]): Verdict {
|
|
2708
|
-
const first = virtuals[0].serverId
|
|
2709
|
-
const current = getServerId()
|
|
2710
|
-
if (current === undefined) return { ok: false, code: 'remote-offline', message: '尚未连接到服务端' }
|
|
2711
|
-
if (virtuals.some((parts) => parts.serverId !== first) || current !== first) {
|
|
2712
|
-
return { ok: false, code: 'remote-mismatch', message: '此远程会话属于其他主服务端' }
|
|
2785
|
+
// -- slot lifecycle ----------------------------------------------------------
|
|
2786
|
+
const removeSlot = (serverId: string, slot: PoolGlobalSlot): void => {
|
|
2787
|
+
slots.delete(serverId)
|
|
2788
|
+
slot.removed = true
|
|
2789
|
+
slot.offState()
|
|
2790
|
+
slot.controller?.abort()
|
|
2791
|
+
if (slot.inventory !== undefined) {
|
|
2792
|
+
slot.inventory.dispose()
|
|
2793
|
+
slot.inventory = undefined
|
|
2713
2794
|
}
|
|
2714
|
-
|
|
2715
|
-
|
|
2795
|
+
// Only THIS server's shown groups leave; the canonical trio then re-airs
|
|
2796
|
+
// the merged order/archived/pinned over the surviving slots (the
|
|
2797
|
+
// merger's own trailing local-only restores are superseded by it).
|
|
2798
|
+
if (!isWorkspace) return
|
|
2799
|
+
const merger = slot.merger as WorkspaceMerger
|
|
2800
|
+
const gone = merger.onRemoteGone()
|
|
2801
|
+
pushOut(gone.filter((frame) => isPlainObject(frame) && frame.type === 'remove'))
|
|
2802
|
+
pushCanonicalTrio()
|
|
2803
|
+
}
|
|
2804
|
+
const applySlotAnnotation = (slot: PoolGlobalSlot, next: MergerAnnotation): void => {
|
|
2805
|
+
if (next === slot.annotation) return
|
|
2806
|
+
slot.annotation = next
|
|
2807
|
+
slot.merger.setStatus(next)
|
|
2808
|
+
if (!isWorkspace) return
|
|
2809
|
+
const merger = slot.merger as WorkspaceMerger
|
|
2810
|
+
const frames = merger.onStatusChanged()
|
|
2811
|
+
if (frames.length > 0) {
|
|
2812
|
+
pushOut(frames.filter((frame) => isPlainObject(frame) && frame.type === 'upsert'))
|
|
2813
|
+
pushCanonicalTrio()
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
const refreshOneViewName = (slot: PoolGlobalSlot, nextName: string): void => {
|
|
2817
|
+
slot.viewName = nextName
|
|
2818
|
+
slot.merger.retarget({ serverId: slot.serverId, serverName: nextName })
|
|
2819
|
+
if (!isWorkspace) return
|
|
2820
|
+
const merger = slot.merger as WorkspaceMerger
|
|
2821
|
+
const frames = merger.onServerRenamed()
|
|
2822
|
+
if (frames.length > 0) {
|
|
2823
|
+
pushOut(frames)
|
|
2824
|
+
pushCanonicalTrio()
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
const refreshViewNames = (): void => {
|
|
2828
|
+
for (const entry of pool.entries()) {
|
|
2829
|
+
const slot = entry.serverId !== undefined ? slots.get(entry.serverId) : undefined
|
|
2830
|
+
if (slot !== undefined && slot.viewName !== entry.displayName) refreshOneViewName(slot, entry.displayName)
|
|
2716
2831
|
}
|
|
2717
|
-
return { ok: true, serverId: first }
|
|
2718
2832
|
}
|
|
2719
2833
|
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2834
|
+
const pumpSlot = async (slot: PoolGlobalSlot): Promise<void> => {
|
|
2835
|
+
// Whole body guarded: ANY escape stops THIS server's leg only.
|
|
2836
|
+
try {
|
|
2837
|
+
while (alive && !slot.removed) {
|
|
2838
|
+
if (slot.client.state !== 'online') {
|
|
2839
|
+
await waitAny()
|
|
2840
|
+
continue
|
|
2841
|
+
}
|
|
2842
|
+
const controller = new AbortController()
|
|
2843
|
+
slot.controller = controller
|
|
2844
|
+
try {
|
|
2845
|
+
const stream = slot.client.openStream(namespace, method, {}, controller.signal)
|
|
2846
|
+
let served = false
|
|
2847
|
+
for await (const frame of stream) {
|
|
2848
|
+
// Post-abort lines the transport already decoded must not reach
|
|
2849
|
+
// the merger (same discipline as the single-server pumps).
|
|
2850
|
+
if (!alive || slot.removed || controller.signal.aborted) break
|
|
2851
|
+
if (!served) {
|
|
2852
|
+
served = true
|
|
2853
|
+
slot.reopenDelayMs = REMOTE_REOPEN_FIRST_MS
|
|
2854
|
+
}
|
|
2855
|
+
informCurrentSession()
|
|
2856
|
+
const out = slot.merger.onRemote(frame)
|
|
2857
|
+
if (out.length > 0) {
|
|
2858
|
+
if (isWorkspace) {
|
|
2859
|
+
pushOut(out.filter((outFrame) => isPlainObject(outFrame) && (outFrame.type === 'upsert' || outFrame.type === 'remove')))
|
|
2860
|
+
pushCanonicalTrio()
|
|
2861
|
+
} else {
|
|
2862
|
+
pushOut(out)
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
ensureSessionPoll()
|
|
2866
|
+
}
|
|
2867
|
+
} catch (error) {
|
|
2868
|
+
if (alive && !slot.removed && !controller.signal.aborted && signal?.aborted !== true) {
|
|
2869
|
+
recordFailure(endpoint, error instanceof RelayError ? error.code : 'internal')
|
|
2870
|
+
}
|
|
2871
|
+
} finally {
|
|
2872
|
+
if (slot.controller === controller) slot.controller = undefined
|
|
2873
|
+
}
|
|
2874
|
+
if (!alive || slot.removed) return
|
|
2875
|
+
slot.merger.onRemoteDown()
|
|
2876
|
+
if (slot.client.state === 'online') {
|
|
2877
|
+
await waitReopen(clock, waitAny, slot.reopenDelayMs)
|
|
2878
|
+
slot.reopenDelayMs = Math.min(slot.reopenDelayMs * 2, REMOTE_REOPEN_MAX_MS)
|
|
2879
|
+
continue
|
|
2880
|
+
}
|
|
2881
|
+
await waitAny()
|
|
2882
|
+
}
|
|
2883
|
+
} catch (error) {
|
|
2884
|
+
if (alive) onDiagnostic(`remote leg of ${slot.serverId} stopped: ${messageOf(error)}`)
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
/** Re-read pool.entries(): create slots for new handshook servers (in
|
|
2889
|
+
* pairing order), refresh display names, remove slots whose server left.
|
|
2890
|
+
* Also heals a re-handshake that renamed the entry's serverId. */
|
|
2891
|
+
const scanSlots = (): void => {
|
|
2892
|
+
const seen = new Set<string>()
|
|
2893
|
+
for (const entry of pool.entries()) {
|
|
2894
|
+
const serverId = entry.serverId
|
|
2895
|
+
if (serverId === undefined) continue
|
|
2896
|
+
seen.add(serverId)
|
|
2897
|
+
const existing = slots.get(serverId)
|
|
2898
|
+
if (existing !== undefined) {
|
|
2899
|
+
if (existing.viewName !== entry.displayName) refreshOneViewName(existing, entry.displayName)
|
|
2900
|
+
continue
|
|
2901
|
+
}
|
|
2902
|
+
const inventory = isWorkspace && deps.createSessionInventorySlot !== undefined
|
|
2903
|
+
? deps.createSessionInventorySlot(serverId)
|
|
2904
|
+
: undefined
|
|
2905
|
+
const merger = isWorkspace
|
|
2906
|
+
? createWorkspaceMerger({
|
|
2907
|
+
serverId,
|
|
2908
|
+
serverName: entry.displayName,
|
|
2909
|
+
onDiagnostic,
|
|
2910
|
+
now: clock.now,
|
|
2911
|
+
lang: deps.lang,
|
|
2912
|
+
...(inventory !== undefined ? { onKnownSessions: (ids: ReadonlySet<string>) => inventory.replace(ids) } : {}),
|
|
2913
|
+
})
|
|
2914
|
+
: createControlMerger({ serverId, serverName: entry.displayName, onDiagnostic })
|
|
2915
|
+
const slot: PoolGlobalSlot = {
|
|
2916
|
+
kind: isWorkspace ? 'workspace' : 'control',
|
|
2917
|
+
serverId,
|
|
2918
|
+
entryId: entry.entryId,
|
|
2919
|
+
client: entry.client,
|
|
2920
|
+
merger,
|
|
2921
|
+
viewName: entry.displayName,
|
|
2922
|
+
controller: undefined,
|
|
2923
|
+
offState: () => {},
|
|
2924
|
+
annotation: annotationOf(entry.client),
|
|
2925
|
+
reopenDelayMs: REMOTE_REOPEN_FIRST_MS,
|
|
2926
|
+
removed: false,
|
|
2927
|
+
inventory,
|
|
2928
|
+
}
|
|
2929
|
+
merger.setStatus(slot.annotation)
|
|
2930
|
+
slot.offState = slot.client.subscribe((state) => {
|
|
2931
|
+
try {
|
|
2932
|
+
// The set itself may have changed (a dedupe renamed the entry's
|
|
2933
|
+
// serverId, a rename moved the display name) — re-scan first.
|
|
2934
|
+
scanSlots()
|
|
2935
|
+
if (state === 'unpaired' || state === 'revoked') {
|
|
2936
|
+
applySlotAnnotation(slot, annotationOf(slot.client))
|
|
2937
|
+
slot.merger.onRemoteDown()
|
|
2938
|
+
slot.controller?.abort()
|
|
2939
|
+
return
|
|
2940
|
+
}
|
|
2941
|
+
applySlotAnnotation(slot, annotationOf(slot.client))
|
|
2942
|
+
if (state === 'online') refreshViewNames()
|
|
2943
|
+
} catch (error) {
|
|
2944
|
+
log?.('client multi merge slot state handler failed on %s: %s', serverId, messageOf(error))
|
|
2945
|
+
} finally {
|
|
2946
|
+
flushWaiters()
|
|
2947
|
+
}
|
|
2948
|
+
})
|
|
2949
|
+
slots.set(serverId, slot)
|
|
2950
|
+
// A slot born mid-stream missed the local baseline — replay it (its
|
|
2951
|
+
// output is discarded; the replay only sets the merger's localSeen and
|
|
2952
|
+
// local lists). Runs BEFORE the pump starts, so the buffer is empty.
|
|
2953
|
+
if (localBaselineFrame !== undefined) {
|
|
2954
|
+
for (const out of slot.merger.onLocal(localBaselineFrame)) void out
|
|
2955
|
+
}
|
|
2956
|
+
void pumpSlot(slot)
|
|
2957
|
+
}
|
|
2958
|
+
for (const [serverId, slot] of [...slots]) {
|
|
2959
|
+
if (!seen.has(serverId)) removeSlot(serverId, slot)
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
// -- local frames -------------------------------------------------------------
|
|
2964
|
+
const pushLocalWorkspace = (frame: unknown): void => {
|
|
2965
|
+
localLists = localWorkspaceLists(frame, localLists)
|
|
2966
|
+
// Every slot's merger must observe the local frames too — its localSeen
|
|
2967
|
+
// gate (nothing leaves its onRemote before the local baseline passed)
|
|
2968
|
+
// and its own local lists live there. Its OUTPUT is discarded: the
|
|
2969
|
+
// single-server global frames (its merged order/archived/pinned) are
|
|
2970
|
+
// superseded by the canonical composition over ALL slots.
|
|
2971
|
+
for (const slot of [...slots.values()]) {
|
|
2972
|
+
for (const out of slot.merger.onLocal(frame)) void out
|
|
2973
|
+
}
|
|
2974
|
+
if (!isPlainObject(frame)) {
|
|
2975
|
+
pushOut([frame])
|
|
2976
|
+
return
|
|
2977
|
+
}
|
|
2978
|
+
if (frame.type === 'baseline') {
|
|
2979
|
+
localSeen = true
|
|
2980
|
+
localBaselineFrame = frame
|
|
2981
|
+
const parts = orderedRemoteParts()
|
|
2982
|
+
const remotes = parts.map((part) => part.remote)
|
|
2983
|
+
const hasRemote = remotes.some(
|
|
2984
|
+
(remote) => remote.orderIds.length + remote.archivedIds.length + remote.pinnedIds.length + remote.workspaces.length > 0,
|
|
2985
|
+
)
|
|
2986
|
+
if (!hasRemote) {
|
|
2987
|
+
pushOut([frame])
|
|
2988
|
+
return
|
|
2989
|
+
}
|
|
2990
|
+
const value = isPlainObject(frame.value) ? frame.value : {}
|
|
2991
|
+
const items = Array.isArray(value.items) ? value.items : []
|
|
2992
|
+
const composed = composeMergedWorkspaceFrames(localLists, remotes)
|
|
2993
|
+
pushOut([
|
|
2994
|
+
{
|
|
2995
|
+
...frame,
|
|
2996
|
+
value: {
|
|
2997
|
+
...value,
|
|
2998
|
+
items: [...items, ...composed.workspaces],
|
|
2999
|
+
archivedSessionIds: composed.archived.archivedSessionIds,
|
|
3000
|
+
pinnedSessionIds: composed.pinned.pinnedSessionIds,
|
|
3001
|
+
},
|
|
3002
|
+
},
|
|
3003
|
+
...composed.workspaces.map((workspace) => ({ type: 'upsert', workspace })),
|
|
3004
|
+
])
|
|
3005
|
+
return
|
|
3006
|
+
}
|
|
3007
|
+
const remotes = orderedRemoteParts().map((part) => part.remote)
|
|
3008
|
+
if (frame.type === 'order') {
|
|
3009
|
+
if (!remotes.some((remote) => remote.orderIds.length > 0)) {
|
|
3010
|
+
pushOut([frame])
|
|
3011
|
+
return
|
|
3012
|
+
}
|
|
3013
|
+
pushOut([composeMergedWorkspaceFrames(localLists, remotes).order])
|
|
3014
|
+
return
|
|
3015
|
+
}
|
|
3016
|
+
if (frame.type === 'archived') {
|
|
3017
|
+
if (!remotes.some((remote) => remote.archivedIds.length > 0)) {
|
|
3018
|
+
pushOut([frame])
|
|
3019
|
+
return
|
|
3020
|
+
}
|
|
3021
|
+
pushOut([composeMergedWorkspaceFrames(localLists, remotes).archived])
|
|
3022
|
+
return
|
|
3023
|
+
}
|
|
3024
|
+
if (frame.type === 'pinned') {
|
|
3025
|
+
if (!remotes.some((remote) => remote.pinnedIds.length > 0)) {
|
|
3026
|
+
pushOut([frame])
|
|
3027
|
+
return
|
|
3028
|
+
}
|
|
3029
|
+
pushOut([composeMergedWorkspaceFrames(localLists, remotes).pinned])
|
|
3030
|
+
return
|
|
3031
|
+
}
|
|
3032
|
+
// upsert / remove / anything else local: purely local facts.
|
|
3033
|
+
pushOut([frame])
|
|
3034
|
+
}
|
|
3035
|
+
const pushLocalControl = (frame: unknown): void => {
|
|
3036
|
+
if (isPlainObject(frame) && frame.type === 'baseline') {
|
|
3037
|
+
pushOut([frame])
|
|
3038
|
+
// Each slot's buffered pre-baseline projections flush behind it (the
|
|
3039
|
+
// slots' own baseline copies are the SAME frame object — skip those).
|
|
3040
|
+
for (const slot of [...slots.values()]) {
|
|
3041
|
+
for (const out of slot.merger.onLocal(frame)) {
|
|
3042
|
+
if (out !== frame) pushOut([out])
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
return
|
|
3046
|
+
}
|
|
3047
|
+
pushOut([frame])
|
|
3048
|
+
for (const slot of [...slots.values()]) slot.merger.onLocal(frame)
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
const offCurrentSession = deps.watchCurrentSession(informCurrentSession)
|
|
3052
|
+
const offUninstall = deps.watchUninstall(() => {
|
|
3053
|
+
try {
|
|
3054
|
+
const read = deps.getCurrentSession()
|
|
3055
|
+
for (const slot of [...slots.values()]) {
|
|
3056
|
+
slot.merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined)
|
|
3057
|
+
applySlotAnnotation(slot, 'unpaired')
|
|
3058
|
+
slot.merger.onRemoteDown()
|
|
3059
|
+
slot.controller?.abort()
|
|
3060
|
+
}
|
|
3061
|
+
} catch {
|
|
3062
|
+
// ignore teardown errors
|
|
3063
|
+
} finally {
|
|
3064
|
+
flushWaiters()
|
|
3065
|
+
}
|
|
3066
|
+
})
|
|
3067
|
+
const offPool = deps.watchPool(() => {
|
|
3068
|
+
try {
|
|
3069
|
+
scanSlots()
|
|
3070
|
+
refreshViewNames()
|
|
3071
|
+
} catch (error) {
|
|
3072
|
+
log?.('client multi merge pool rescan failed on %s: %s', endpoint, messageOf(error))
|
|
3073
|
+
} finally {
|
|
3074
|
+
flushWaiters()
|
|
3075
|
+
}
|
|
3076
|
+
})
|
|
3077
|
+
|
|
3078
|
+
const onExternalAbort = (): void => {
|
|
3079
|
+
alive = false
|
|
3080
|
+
for (const slot of [...slots.values()]) slot.controller?.abort()
|
|
3081
|
+
flushWaiters()
|
|
3082
|
+
channel.end()
|
|
3083
|
+
}
|
|
3084
|
+
if (signal !== undefined) signal.addEventListener('abort', onExternalAbort)
|
|
3085
|
+
|
|
3086
|
+
scanSlots()
|
|
3087
|
+
informCurrentSession()
|
|
3088
|
+
|
|
3089
|
+
const localIterator = local[Symbol.asyncIterator]()
|
|
3090
|
+
const pumpLocal = async (): Promise<void> => {
|
|
3091
|
+
try {
|
|
3092
|
+
while (alive) {
|
|
3093
|
+
const result = await localIterator.next()
|
|
3094
|
+
if (result.done === true) break
|
|
3095
|
+
informCurrentSession()
|
|
3096
|
+
if (isWorkspace) pushLocalWorkspace(result.value)
|
|
3097
|
+
else pushLocalControl(result.value)
|
|
3098
|
+
ensureSessionPoll()
|
|
3099
|
+
}
|
|
3100
|
+
} catch (error) {
|
|
3101
|
+
// The local stream is the one the UI opened: its failure is the
|
|
3102
|
+
// merged stream's failure (a remote death never is).
|
|
3103
|
+
if (alive) channel.fail(error)
|
|
3104
|
+
return
|
|
3105
|
+
}
|
|
3106
|
+
if (!alive) return
|
|
3107
|
+
alive = false
|
|
3108
|
+
for (const slot of [...slots.values()]) slot.controller?.abort()
|
|
3109
|
+
flushWaiters()
|
|
3110
|
+
channel.end()
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
void pumpLocal()
|
|
3114
|
+
try {
|
|
3115
|
+
while (true) {
|
|
3116
|
+
const result = await channel.next()
|
|
3117
|
+
if (result.done === true) break
|
|
3118
|
+
yield result.value
|
|
3119
|
+
}
|
|
3120
|
+
} finally {
|
|
3121
|
+
alive = false
|
|
3122
|
+
if (sessionPoll !== undefined) clock.clearTimeout(sessionPoll)
|
|
3123
|
+
offCurrentSession()
|
|
3124
|
+
offUninstall()
|
|
3125
|
+
offPool()
|
|
3126
|
+
if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
|
|
3127
|
+
for (const slot of [...slots.values()]) {
|
|
3128
|
+
slot.removed = true
|
|
3129
|
+
slot.offState()
|
|
3130
|
+
slot.controller?.abort()
|
|
3131
|
+
slot.inventory?.dispose()
|
|
3132
|
+
}
|
|
3133
|
+
slots.clear()
|
|
3134
|
+
flushWaiters()
|
|
3135
|
+
void Promise.resolve(localIterator.return?.(undefined)).catch(() => {})
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
interface PoolEventSlot {
|
|
3140
|
+
serverId: string
|
|
3141
|
+
client: RelayClient
|
|
3142
|
+
controller: AbortController | undefined
|
|
3143
|
+
offState: () => void
|
|
3144
|
+
/** This server's shown-and-uncanceled virtual eventIds — the orphan-close
|
|
3145
|
+
* record. PER SLOT by design: a leg dying closes only its own prompts. */
|
|
3146
|
+
shownEvents: Set<string>
|
|
3147
|
+
reopenDelayMs: number
|
|
3148
|
+
removed: boolean
|
|
3149
|
+
displayName: string
|
|
3150
|
+
/** T52 refresh key for this slot (its identity while serving). */
|
|
3151
|
+
notedIdentity: string | undefined
|
|
3152
|
+
stateSeen: RelayState | undefined
|
|
3153
|
+
statusInFlight: boolean
|
|
3154
|
+
statusPending: boolean
|
|
3155
|
+
statusDeferred: boolean
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
interface MergedEventsStreamMultiDeps {
|
|
3159
|
+
local: AsyncIterable<unknown>
|
|
3160
|
+
pool: RelayPool
|
|
3161
|
+
signal: AbortSignal | undefined
|
|
3162
|
+
recordFailure: (endpoint: string, code: string) => void
|
|
3163
|
+
log: ((format: string, ...args: unknown[]) => void) | undefined
|
|
3164
|
+
clock: RelayClock
|
|
3165
|
+
summarySync: SessionSummarySync
|
|
3166
|
+
watchPool(fn: () => void): () => void
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
/**
|
|
3170
|
+
* The pool-mode merged `$events` stream: one `$zr/events` leg per pooled
|
|
3171
|
+
* server, each with its own orphan-close record. The T52 catalog refresh
|
|
3172
|
+
* fires whenever any slot's serving identity appears, changes, or leaves;
|
|
3173
|
+
* the T63/T65 summary sync and the T75 status sync fetch from the OWNING
|
|
3174
|
+
* server of each id (virtual ids carry the serverId), per slot on its own
|
|
3175
|
+
* serving periods.
|
|
3176
|
+
*/
|
|
3177
|
+
async function* mergedEventsStreamMulti(deps: MergedEventsStreamMultiDeps): AsyncGenerator<unknown, void, undefined> {
|
|
3178
|
+
const { local, pool, signal, recordFailure, log, clock } = deps
|
|
3179
|
+
if (signal?.aborted === true) return
|
|
3180
|
+
const channel = createFrameChannel()
|
|
3181
|
+
let alive = true
|
|
3182
|
+
const slots = new Map<string, PoolEventSlot>()
|
|
3183
|
+
const onlineWaiters: (() => void)[] = []
|
|
3184
|
+
const flushWaiters = (): void => {
|
|
3185
|
+
for (const wake of onlineWaiters.splice(0)) wake()
|
|
3186
|
+
}
|
|
3187
|
+
const waitAny = async (): Promise<void> => {
|
|
3188
|
+
await new Promise<void>((resolve) => {
|
|
3189
|
+
onlineWaiters.push(resolve)
|
|
3190
|
+
})
|
|
3191
|
+
}
|
|
3192
|
+
let localOpened = false
|
|
3193
|
+
let deferredRefresh = false
|
|
3194
|
+
const pushRefresh = (): void => {
|
|
3195
|
+
if (!localOpened) {
|
|
3196
|
+
deferredRefresh = true
|
|
3197
|
+
return
|
|
3198
|
+
}
|
|
3199
|
+
channel.push(catalogRefreshFrame())
|
|
3200
|
+
}
|
|
3201
|
+
const noteSlotIdentity = (slot: PoolEventSlot): void => {
|
|
3202
|
+
const key = slot.client.state === 'online' ? `${slot.serverId}\u0000${slot.displayName}` : undefined
|
|
3203
|
+
if (key === slot.notedIdentity) return
|
|
3204
|
+
slot.notedIdentity = key
|
|
3205
|
+
if (key !== undefined) pushRefresh()
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// -- T63/T65/T75 bookkeeping: global maps over VIRTUAL ids (globally
|
|
3209
|
+
// unique across servers), per-slot fetch rounds. Same discipline as the
|
|
3210
|
+
// single-server stream — see mergedEventsStream for the full story.
|
|
3211
|
+
const announced = new Set<string>()
|
|
3212
|
+
const askAttempts = new Map<string, number>()
|
|
3213
|
+
const askedForGood = new Set<string>()
|
|
3214
|
+
const retryAt = new Map<string, number>()
|
|
3215
|
+
const MAX_ASK_ATTEMPTS = 3
|
|
3216
|
+
const ASK_RETRY_FIRST_MS = 2_000
|
|
3217
|
+
const ASK_RETRY_SECOND_MS = 10_000
|
|
3218
|
+
let retryTimer: unknown = undefined
|
|
3219
|
+
let midFlightBatches: string[] = []
|
|
3220
|
+
let askingNow: string[] = []
|
|
3221
|
+
let summarySyncInFlight = false
|
|
3222
|
+
let deferredSummarySync = false
|
|
3223
|
+
const latestLiveStatus = new Map<string, boolean>()
|
|
3224
|
+
let statusSeq = 0
|
|
3225
|
+
const liveStatusSeq = new Map<string, number>()
|
|
3226
|
+
|
|
3227
|
+
const scheduleRetry = (): void => {
|
|
3228
|
+
if (retryTimer !== undefined || retryAt.size === 0) return
|
|
3229
|
+
const earliest = Math.min(...retryAt.values())
|
|
3230
|
+
retryTimer = clock.setTimeout(() => {
|
|
3231
|
+
retryTimer = undefined
|
|
3232
|
+
if (!alive) return
|
|
3233
|
+
const nowMs = clock.now()
|
|
3234
|
+
const due: string[] = []
|
|
3235
|
+
for (const [id, at] of retryAt) {
|
|
3236
|
+
if (at <= nowMs) {
|
|
3237
|
+
retryAt.delete(id)
|
|
3238
|
+
due.push(id)
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
if (due.length > 0) runSummarySync(due)
|
|
3242
|
+
}, Math.max(0, earliest - clock.now()))
|
|
3243
|
+
}
|
|
3244
|
+
const runSummarySync = (triggerIds: readonly string[] = []): void => {
|
|
3245
|
+
if (!alive) return
|
|
3246
|
+
if (!localOpened) {
|
|
3247
|
+
deferredSummarySync = true
|
|
3248
|
+
return
|
|
3249
|
+
}
|
|
3250
|
+
if (summarySyncInFlight) {
|
|
3251
|
+
midFlightBatches.push(...triggerIds)
|
|
3252
|
+
return
|
|
3253
|
+
}
|
|
3254
|
+
const rounds: { slot: PoolEventSlot; ids: string[] }[] = []
|
|
3255
|
+
if (triggerIds.length === 0) {
|
|
3256
|
+
for (const slot of [...slots.values()]) {
|
|
3257
|
+
if (slot.client.state === 'online') rounds.push({ slot, ids: [] })
|
|
3258
|
+
}
|
|
3259
|
+
} else {
|
|
3260
|
+
for (const id of triggerIds) {
|
|
3261
|
+
const parts = fromVirtual(id)
|
|
3262
|
+
if (parts === undefined) continue
|
|
3263
|
+
const slot = slots.get(parts.serverId)
|
|
3264
|
+
if (slot === undefined || slot.client.state !== 'online') continue
|
|
3265
|
+
let round = rounds.find((candidate) => candidate.slot === slot)
|
|
3266
|
+
if (round === undefined) {
|
|
3267
|
+
round = { slot, ids: [] }
|
|
3268
|
+
rounds.push(round)
|
|
3269
|
+
}
|
|
3270
|
+
round.ids.push(id)
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
if (rounds.length === 0) return
|
|
3274
|
+
summarySyncInFlight = true
|
|
3275
|
+
askingNow = [...triggerIds]
|
|
3276
|
+
for (const round of rounds) {
|
|
3277
|
+
for (const id of round.ids) askAttempts.set(id, (askAttempts.get(id) ?? 0) + 1)
|
|
3278
|
+
}
|
|
3279
|
+
void Promise.all(
|
|
3280
|
+
rounds.map((round) =>
|
|
3281
|
+
round.slot.client
|
|
3282
|
+
.invoke('session', 'list', { _request: {} }, undefined)
|
|
3283
|
+
.then(
|
|
3284
|
+
(value) => ({ round, value, error: undefined as unknown }),
|
|
3285
|
+
(error: unknown) => ({ round, value: undefined, error }),
|
|
3286
|
+
),
|
|
3287
|
+
),
|
|
3288
|
+
).then((results) => {
|
|
3289
|
+
summarySyncInFlight = false
|
|
3290
|
+
if (!alive) return
|
|
3291
|
+
askingNow = []
|
|
3292
|
+
for (const result of results) {
|
|
3293
|
+
if (result.error !== undefined) {
|
|
3294
|
+
recordFailure('session/list', result.error instanceof RelayError ? result.error.code : 'internal')
|
|
3295
|
+
continue
|
|
3296
|
+
}
|
|
3297
|
+
const frames = sessionSummaryAddedFrames(result.value, result.round.slot.serverId)
|
|
3298
|
+
const returned = new Set<string>()
|
|
3299
|
+
for (const frame of frames) {
|
|
3300
|
+
const row = (frame as { args: unknown[] }).args[0]
|
|
3301
|
+
const rowId = isPlainObject(row) && typeof row.sessionId === 'string' ? row.sessionId : undefined
|
|
3302
|
+
if (rowId === undefined) continue
|
|
3303
|
+
returned.add(rowId)
|
|
3304
|
+
if (announced.has(rowId)) continue
|
|
3305
|
+
announced.add(rowId)
|
|
3306
|
+
channel.push(frame)
|
|
3307
|
+
const liveRunning = latestLiveStatus.get(rowId)
|
|
3308
|
+
if (liveRunning !== undefined && liveRunning !== (isPlainObject(row) && row.running === true)) {
|
|
3309
|
+
channel.push({ type: 'emit', event: 'api-session/status', args: [rowId, liveRunning] })
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
for (const id of result.round.ids) {
|
|
3313
|
+
if (returned.has(id)) {
|
|
3314
|
+
retryAt.delete(id)
|
|
3315
|
+
continue
|
|
3316
|
+
}
|
|
3317
|
+
const attempts = askAttempts.get(id) ?? 0
|
|
3318
|
+
if (attempts >= MAX_ASK_ATTEMPTS) askedForGood.add(id)
|
|
3319
|
+
else retryAt.set(id, clock.now() + (attempts <= 1 ? ASK_RETRY_FIRST_MS : ASK_RETRY_SECOND_MS))
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
scheduleRetry()
|
|
3323
|
+
const queued = midFlightBatches
|
|
3324
|
+
midFlightBatches = []
|
|
3325
|
+
if (queued.length > 0) runSummarySync(queued)
|
|
3326
|
+
})
|
|
3327
|
+
}
|
|
3328
|
+
const handleForwardedBatch = (ids: readonly string[]): void => {
|
|
3329
|
+
if (!ids.some((id) => !announced.has(id) && !askedForGood.has(id) && !retryAt.has(id))) return
|
|
3330
|
+
runSummarySync(ids)
|
|
3331
|
+
}
|
|
3332
|
+
/** T75 per slot: re-push every row's running flag from a fresh snapshot
|
|
3333
|
+
* of THIS server when its serving period starts. */
|
|
3334
|
+
const runStatusSync = (slot: PoolEventSlot): void => {
|
|
3335
|
+
if (!alive) return
|
|
3336
|
+
if (!localOpened) {
|
|
3337
|
+
slot.statusDeferred = true
|
|
3338
|
+
return
|
|
3339
|
+
}
|
|
3340
|
+
if (slot.statusInFlight) {
|
|
3341
|
+
slot.statusPending = true
|
|
3342
|
+
return
|
|
3343
|
+
}
|
|
3344
|
+
if (slot.client.state !== 'online') return
|
|
3345
|
+
slot.statusInFlight = true
|
|
3346
|
+
// The race guard arms here (see the single-server stream): only frames
|
|
3347
|
+
// arriving after this point outrank the snapshot — older records have a
|
|
3348
|
+
// lower seq than startSeq and never block.
|
|
3349
|
+
const startSeq = ++statusSeq
|
|
3350
|
+
void slot.client.invoke('session', 'list', { _request: {} }, undefined).then(
|
|
3351
|
+
(value) => {
|
|
3352
|
+
slot.statusInFlight = false
|
|
3353
|
+
if (!alive) return
|
|
3354
|
+
const rows = isPlainObject(value) && Array.isArray(value.items) ? value.items : []
|
|
3355
|
+
for (const row of rows) {
|
|
3356
|
+
if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '') continue
|
|
3357
|
+
if ((liveStatusSeq.get(row.sessionId) ?? 0) > startSeq) continue
|
|
3358
|
+
channel.push({
|
|
3359
|
+
type: 'emit',
|
|
3360
|
+
event: 'api-session/status',
|
|
3361
|
+
args: [toVirtual(slot.serverId, row.sessionId), row.running === true],
|
|
3362
|
+
})
|
|
3363
|
+
}
|
|
3364
|
+
if (slot.statusPending) {
|
|
3365
|
+
slot.statusPending = false
|
|
3366
|
+
runStatusSync(slot)
|
|
3367
|
+
}
|
|
3368
|
+
},
|
|
3369
|
+
(error: unknown) => {
|
|
3370
|
+
slot.statusInFlight = false
|
|
3371
|
+
if (!alive) return
|
|
3372
|
+
recordFailure('session/list', error instanceof RelayError ? error.code : 'internal')
|
|
3373
|
+
if (slot.statusPending) {
|
|
3374
|
+
slot.statusPending = false
|
|
3375
|
+
runStatusSync(slot)
|
|
3376
|
+
}
|
|
3377
|
+
},
|
|
3378
|
+
)
|
|
3379
|
+
}
|
|
3380
|
+
/** One slot's fresh serving period: the ask bookkeeping gets another
|
|
3381
|
+
* chance, and this server's rows are re-synced (statuses first). */
|
|
3382
|
+
const noteFirstOnline = (slot: PoolEventSlot): void => {
|
|
3383
|
+
askedForGood.clear()
|
|
3384
|
+
askAttempts.clear()
|
|
3385
|
+
retryAt.clear()
|
|
3386
|
+
latestLiveStatus.clear()
|
|
3387
|
+
if (retryTimer !== undefined) {
|
|
3388
|
+
clock.clearTimeout(retryTimer)
|
|
3389
|
+
retryTimer = undefined
|
|
3390
|
+
}
|
|
3391
|
+
runStatusSync(slot)
|
|
3392
|
+
runSummarySync()
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
const pumpSlot = async (slot: PoolEventSlot): Promise<void> => {
|
|
3396
|
+
try {
|
|
3397
|
+
while (alive && !slot.removed) {
|
|
3398
|
+
if (slot.client.state !== 'online') {
|
|
3399
|
+
await waitAny()
|
|
3400
|
+
continue
|
|
3401
|
+
}
|
|
3402
|
+
const controller = new AbortController()
|
|
3403
|
+
slot.controller = controller
|
|
3404
|
+
try {
|
|
3405
|
+
const stream = slot.client.openStream('$zr', 'events', {}, controller.signal)
|
|
3406
|
+
let served = false
|
|
3407
|
+
for await (const frame of stream) {
|
|
3408
|
+
if (!alive || slot.removed || controller.signal.aborted) break
|
|
3409
|
+
if (!served) {
|
|
3410
|
+
served = true
|
|
3411
|
+
slot.reopenDelayMs = REMOTE_REOPEN_FIRST_MS
|
|
3412
|
+
}
|
|
3413
|
+
const rewritten = rewriteRemoteEventFrame(frame, slot.serverId)
|
|
3414
|
+
if (rewritten === null) continue
|
|
3415
|
+
// T75 race guard + PB-26a live status, exactly as the single
|
|
3416
|
+
// server stream keeps them — keyed by ids that are already
|
|
3417
|
+
// globally unique (virtual or original-then-virtualized).
|
|
3418
|
+
if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
|
|
3419
|
+
const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined
|
|
3420
|
+
if (typeof rowId === 'string' && rowId !== '') liveStatusSeq.set(rowId, ++statusSeq)
|
|
3421
|
+
const args = isPlainObject(rewritten) && Array.isArray(rewritten.args) ? rewritten.args : undefined
|
|
3422
|
+
const virtualId = args?.[0]
|
|
3423
|
+
const running = args?.[1]
|
|
3424
|
+
if (typeof virtualId === 'string' && typeof running === 'boolean') {
|
|
3425
|
+
latestLiveStatus.set(virtualId, running)
|
|
3426
|
+
if (!announced.has(virtualId)) handleForwardedBatch([virtualId])
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
const eventId =
|
|
3430
|
+
isPlainObject(rewritten) && typeof rewritten.eventId === 'string' ? rewritten.eventId : undefined
|
|
3431
|
+
if (eventId !== undefined) {
|
|
3432
|
+
if (isPlainObject(frame) && frame.type === 'waterfall') {
|
|
3433
|
+
if (slot.shownEvents.has(eventId)) continue
|
|
3434
|
+
slot.shownEvents.add(eventId)
|
|
3435
|
+
} else if (isPlainObject(frame) && frame.type === 'cancel') {
|
|
3436
|
+
slot.shownEvents.delete(eventId)
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
channel.push(rewritten)
|
|
3440
|
+
}
|
|
3441
|
+
} catch (error) {
|
|
3442
|
+
if (alive && !slot.removed && !controller.signal.aborted && signal?.aborted !== true) {
|
|
3443
|
+
recordFailure('$zr/events', error instanceof RelayError ? error.code : 'internal')
|
|
3444
|
+
}
|
|
3445
|
+
} finally {
|
|
3446
|
+
if (slot.controller === controller) slot.controller = undefined
|
|
3447
|
+
}
|
|
3448
|
+
// THIS leg is over — close only ITS prompts. A disconnect is not a
|
|
3449
|
+
// verdict; the reopen re-delivers still-pending events under the
|
|
3450
|
+
// SAME ids (the server prefixes them per subscription, so the
|
|
3451
|
+
// virtual ids differ — DSH shows them fresh, the old cancels already
|
|
3452
|
+
// closed the stale prompts).
|
|
3453
|
+
for (const eventId of slot.shownEvents) channel.push({ type: 'cancel', eventId })
|
|
3454
|
+
slot.shownEvents = new Set()
|
|
3455
|
+
if (!alive || slot.removed) return
|
|
3456
|
+
if (slot.client.state === 'online') {
|
|
3457
|
+
await waitReopen(clock, waitAny, slot.reopenDelayMs)
|
|
3458
|
+
slot.reopenDelayMs = Math.min(slot.reopenDelayMs * 2, REMOTE_REOPEN_MAX_MS)
|
|
3459
|
+
continue
|
|
3460
|
+
}
|
|
3461
|
+
await waitAny()
|
|
3462
|
+
}
|
|
3463
|
+
} catch (error) {
|
|
3464
|
+
if (alive) log?.('client events remote leg of %s stopped: %s', slot.serverId, messageOf(error))
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
const scanSlots = (): void => {
|
|
3469
|
+
const seen = new Set<string>()
|
|
3470
|
+
for (const entry of pool.entries()) {
|
|
3471
|
+
const serverId = entry.serverId
|
|
3472
|
+
if (serverId === undefined) continue
|
|
3473
|
+
seen.add(serverId)
|
|
3474
|
+
const existing = slots.get(serverId)
|
|
3475
|
+
if (existing !== undefined) {
|
|
3476
|
+
if (existing.displayName !== entry.displayName) {
|
|
3477
|
+
existing.displayName = entry.displayName
|
|
3478
|
+
noteSlotIdentity(existing)
|
|
3479
|
+
}
|
|
3480
|
+
continue
|
|
3481
|
+
}
|
|
3482
|
+
const slot: PoolEventSlot = {
|
|
3483
|
+
serverId,
|
|
3484
|
+
client: entry.client,
|
|
3485
|
+
controller: undefined,
|
|
3486
|
+
offState: () => {},
|
|
3487
|
+
shownEvents: new Set(),
|
|
3488
|
+
reopenDelayMs: REMOTE_REOPEN_FIRST_MS,
|
|
3489
|
+
removed: false,
|
|
3490
|
+
displayName: entry.displayName,
|
|
3491
|
+
notedIdentity: undefined,
|
|
3492
|
+
stateSeen: undefined,
|
|
3493
|
+
statusInFlight: false,
|
|
3494
|
+
statusPending: false,
|
|
3495
|
+
statusDeferred: false,
|
|
3496
|
+
}
|
|
3497
|
+
slot.offState = slot.client.subscribe((state) => {
|
|
3498
|
+
try {
|
|
3499
|
+
const firstOnline = state === 'online' && slot.stateSeen !== 'online'
|
|
3500
|
+
slot.stateSeen = state
|
|
3501
|
+
if (state === 'unpaired' || state === 'revoked') {
|
|
3502
|
+
slot.controller?.abort()
|
|
3503
|
+
noteSlotIdentity(slot)
|
|
3504
|
+
return
|
|
3505
|
+
}
|
|
3506
|
+
noteSlotIdentity(slot)
|
|
3507
|
+
if (state === 'online' && firstOnline) noteFirstOnline(slot)
|
|
3508
|
+
} catch (error) {
|
|
3509
|
+
log?.('client events merge slot state handler failed on %s: %s', serverId, messageOf(error))
|
|
3510
|
+
} finally {
|
|
3511
|
+
flushWaiters()
|
|
3512
|
+
}
|
|
3513
|
+
})
|
|
3514
|
+
slots.set(serverId, slot)
|
|
3515
|
+
void pumpSlot(slot)
|
|
3516
|
+
// Install into an already-serving server: subscribe replayed nothing.
|
|
3517
|
+
if (slot.client.state === 'online') {
|
|
3518
|
+
slot.stateSeen = 'online'
|
|
3519
|
+
noteSlotIdentity(slot)
|
|
3520
|
+
noteFirstOnline(slot)
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
for (const [serverId, slot] of [...slots]) {
|
|
3524
|
+
if (!seen.has(serverId)) {
|
|
3525
|
+
slots.delete(serverId)
|
|
3526
|
+
slot.removed = true
|
|
3527
|
+
slot.offState()
|
|
3528
|
+
slot.controller?.abort()
|
|
3529
|
+
// Only THIS server's pending prompts get the synthesized cancels.
|
|
3530
|
+
for (const eventId of slot.shownEvents) channel.push({ type: 'cancel', eventId })
|
|
3531
|
+
slot.shownEvents.clear()
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
const offSyncRequest = deps.summarySync.onForwardedSessionIds(handleForwardedBatch)
|
|
3537
|
+
const offPool = deps.watchPool(() => {
|
|
3538
|
+
try {
|
|
3539
|
+
scanSlots()
|
|
3540
|
+
} catch (error) {
|
|
3541
|
+
log?.('client events merge pool rescan failed: %s', messageOf(error))
|
|
3542
|
+
} finally {
|
|
3543
|
+
flushWaiters()
|
|
3544
|
+
}
|
|
3545
|
+
})
|
|
3546
|
+
|
|
3547
|
+
const onExternalAbort = (): void => {
|
|
3548
|
+
alive = false
|
|
3549
|
+
for (const slot of [...slots.values()]) slot.controller?.abort()
|
|
3550
|
+
flushWaiters()
|
|
3551
|
+
channel.end()
|
|
3552
|
+
}
|
|
3553
|
+
if (signal !== undefined) signal.addEventListener('abort', onExternalAbort)
|
|
3554
|
+
|
|
3555
|
+
scanSlots()
|
|
3556
|
+
|
|
3557
|
+
const localIterator = local[Symbol.asyncIterator]()
|
|
3558
|
+
const pumpLocal = async (): Promise<void> => {
|
|
3559
|
+
try {
|
|
3560
|
+
while (alive) {
|
|
3561
|
+
const result = await localIterator.next()
|
|
3562
|
+
if (result.done === true) break
|
|
3563
|
+
channel.push(result.value)
|
|
3564
|
+
if (!localOpened) {
|
|
3565
|
+
localOpened = true
|
|
3566
|
+
if (deferredRefresh) {
|
|
3567
|
+
deferredRefresh = false
|
|
3568
|
+
channel.push(catalogRefreshFrame())
|
|
3569
|
+
}
|
|
3570
|
+
const pendingBatch = deps.summarySync.consumePendingRequest()
|
|
3571
|
+
if (deferredSummarySync || pendingBatch.length > 0) {
|
|
3572
|
+
deferredSummarySync = false
|
|
3573
|
+
runSummarySync(pendingBatch)
|
|
3574
|
+
}
|
|
3575
|
+
for (const slot of [...slots.values()]) {
|
|
3576
|
+
if (slot.statusDeferred) {
|
|
3577
|
+
slot.statusDeferred = false
|
|
3578
|
+
runStatusSync(slot)
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
} catch (error) {
|
|
3584
|
+
if (alive) channel.fail(error)
|
|
3585
|
+
return
|
|
3586
|
+
}
|
|
3587
|
+
if (!alive) return
|
|
3588
|
+
alive = false
|
|
3589
|
+
for (const slot of [...slots.values()]) slot.controller?.abort()
|
|
3590
|
+
flushWaiters()
|
|
3591
|
+
channel.end()
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
void pumpLocal()
|
|
3595
|
+
try {
|
|
3596
|
+
while (true) {
|
|
3597
|
+
const result = await channel.next()
|
|
3598
|
+
if (result.done === true) break
|
|
3599
|
+
yield result.value
|
|
3600
|
+
}
|
|
3601
|
+
} finally {
|
|
3602
|
+
alive = false
|
|
3603
|
+
if (retryTimer !== undefined) clock.clearTimeout(retryTimer)
|
|
3604
|
+
offSyncRequest()
|
|
3605
|
+
offPool()
|
|
3606
|
+
if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
|
|
3607
|
+
for (const slot of [...slots.values()]) {
|
|
3608
|
+
slot.removed = true
|
|
3609
|
+
slot.offState()
|
|
3610
|
+
slot.controller?.abort()
|
|
3611
|
+
}
|
|
3612
|
+
slots.clear()
|
|
3613
|
+
flushWaiters()
|
|
3614
|
+
void Promise.resolve(localIterator.return?.(undefined)).catch(() => {})
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
/**
|
|
3619
|
+
* Install the two own-property wrappers on the raw gateway. Assumes
|
|
3620
|
+
* {@link checkGatewayShape} passed (the wiring gates on it) — this function
|
|
3621
|
+
* records the verdict but does not re-gate, so it stays usable in tests
|
|
3622
|
+
* that install over deliberately odd shapes.
|
|
3623
|
+
*/
|
|
3624
|
+
/**
|
|
3625
|
+
* P55-2: whether `filePath` lives at or under `cwd` — the /api/file
|
|
3626
|
+
* attribution test. Both paths are compared backslash-folded to `/` (a
|
|
3627
|
+
* server-Windows cwd meets a server-Windows image path, and the Markdown
|
|
3628
|
+
* renderer may hand either separator over), trailing separators of `cwd`
|
|
3629
|
+
* are dropped, and the match is exact-or-subpath (`cwd + '/'` prefix — a
|
|
3630
|
+
* directory merely PREFIXED by the cwd string is not a match). Windows
|
|
3631
|
+
* drive-rooted pairs compare case-insensitively WHOLE (`C:\Users` vs
|
|
3632
|
+
* `c:\users` — the drive letter is the visible case hazard, but NTFS is
|
|
3633
|
+
* case-insensitive end to end, and a missed match costs a permanently
|
|
3634
|
+
* broken image while an over-match needs two same-server cwds differing
|
|
3635
|
+
* only by case); everything else stays case-sensitive (POSIX is).
|
|
3636
|
+
* Deliberately NOT node:path — the two ends may run on different platforms,
|
|
3637
|
+
* so the rule is pure string arithmetic over the server's paths.
|
|
3638
|
+
*/
|
|
3639
|
+
export function pathMatchesCwd(filePath: string, cwd: string): boolean {
|
|
3640
|
+
const slashify = (value: string): string => value.replaceAll('\\', '/')
|
|
3641
|
+
const file = slashify(filePath)
|
|
3642
|
+
let root = slashify(cwd)
|
|
3643
|
+
while (root.length > 1 && root.endsWith('/')) root = root.slice(0, -1)
|
|
3644
|
+
if (root === '') return false
|
|
3645
|
+
const matches = (f: string, r: string): boolean => f === r || f.startsWith(`${r}/`)
|
|
3646
|
+
if (matches(file, root)) return true
|
|
3647
|
+
if (/^[a-z]:/i.test(root) && /^[a-z]:/i.test(file)) return matches(file.toLowerCase(), root.toLowerCase())
|
|
3648
|
+
return false
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
export function installIntercept(options: InstallInterceptOptions): InterceptHandle {
|
|
3652
|
+
const { raw, log } = options
|
|
3653
|
+
const clock = options.clock ?? defaultClock
|
|
3654
|
+
// MS-3: exactly one mode — the pool when given, the single row client
|
|
3655
|
+
// otherwise. Installing with neither is a wiring bug; refuse loudly at
|
|
3656
|
+
// install instead of crashing on the first remote call.
|
|
3657
|
+
const pool = options.pool
|
|
3658
|
+
const relay = options.relay
|
|
3659
|
+
const getServerId = options.getServerId
|
|
3660
|
+
if (pool === undefined && (relay === undefined || getServerId === undefined)) {
|
|
3661
|
+
throw new Error('installIntercept requires either `relay` + `getServerId` (single server) or `pool`')
|
|
3662
|
+
}
|
|
3663
|
+
// The single-server facts, proven present by the check above. The
|
|
3664
|
+
// single-server code paths read THESE (never the optional originals), so
|
|
3665
|
+
// the old behavior stays byte-identical without a narrowing dance.
|
|
3666
|
+
const singleRelay = relay as RelayClient
|
|
3667
|
+
const singleGetServerId = getServerId as () => string | undefined
|
|
3668
|
+
// T63: the summary-sync hub shared by the merged streams — the workspace
|
|
3669
|
+
// route nudges, the events leg fetches-and-announces. One per install: it
|
|
3670
|
+
// outlives individual stream generations so a nudge from a workspace leg
|
|
3671
|
+
// reaches whichever $events generation is open.
|
|
3672
|
+
const summarySync = createSessionSummarySync()
|
|
3673
|
+
// PB-25b: the inventory slot factory (the wiring's union feed) — passed
|
|
3674
|
+
// down to every merged stream, which owns one slot for its lifetime.
|
|
3675
|
+
const createSessionInventorySlot = options.createSessionInventorySlot
|
|
3676
|
+
// MS-3: the pool-event hub behind handle.notePoolEvent() — the wiring
|
|
3677
|
+
// forwards the pool's onStateChange/onServerRemoved callbacks here, and
|
|
3678
|
+
// every live merged stream (plus the pool-mode catalog watcher below)
|
|
3679
|
+
// re-scans on the broadcast.
|
|
3680
|
+
const poolListeners = new Set<() => void>()
|
|
3681
|
+
const watchPool = (fn: () => void): (() => void) => {
|
|
3682
|
+
poolListeners.add(fn)
|
|
3683
|
+
return () => {
|
|
3684
|
+
poolListeners.delete(fn)
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
// T62: the current-session getter and the live-merged-stream listener
|
|
3688
|
+
// registry behind handle.currentSessionChanged().
|
|
3689
|
+
const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }) as const)
|
|
3690
|
+
const currentSessionListeners = new Set<() => void>()
|
|
3691
|
+
const watchCurrentSession = (fn: () => void): (() => void) => {
|
|
3692
|
+
currentSessionListeners.add(fn)
|
|
3693
|
+
return () => { currentSessionListeners.delete(fn) }
|
|
3694
|
+
}
|
|
3695
|
+
const uninstallListeners = new Set<() => void>()
|
|
3696
|
+
const watchUninstall = (fn: () => void): (() => void) => {
|
|
3697
|
+
uninstallListeners.add(fn)
|
|
3698
|
+
return () => { uninstallListeners.delete(fn) }
|
|
3699
|
+
}
|
|
3700
|
+
const gateway = raw as Record<string | symbol, unknown>
|
|
3701
|
+
const shape = checkGatewayShape(raw)
|
|
3702
|
+
const counters = { openWireStream: 0, dispatchRpc: 0 }
|
|
3703
|
+
const failures: InterceptFailureRecord[] = []
|
|
3704
|
+
const incompatible: IncompatibleCallRecord[] = []
|
|
3705
|
+
// The closed-session registry (T34): insertion-ordered, capped, virtual-id
|
|
3706
|
+
// keyed. Entries live until THEIR session proves reachable again (a
|
|
3707
|
+
// succeeding call for it) — a reconnect clears nothing (T34-fix): a link
|
|
3708
|
+
// that flapped under a still-standing closure would make the banner flicker
|
|
3709
|
+
// away and back.
|
|
3710
|
+
const closedSessions = new Map<string, ClosedSessionRecord['reason']>()
|
|
3711
|
+
// T73-fix: waiters for "a closed-session record was just cleared" — the
|
|
3712
|
+
// quiet session/follow hold (rewriteUpstream) ends its silence the moment
|
|
3713
|
+
// the session it holds for is served again, so a re-enabled remote comes
|
|
3714
|
+
// back with a fresh history instead of staying blind under its banner.
|
|
3715
|
+
const closedClearWaiters = new Set<() => void>()
|
|
3716
|
+
// P55-2: the /api/file attribution state. `remoteSessionCwds` maps each
|
|
3717
|
+
// remote session's VIRTUAL id to its cwd as last seen (snapshot header,
|
|
3718
|
+
// session/list rows); `activeRemoteFollows` holds every live remote
|
|
3719
|
+
// `session/follow` this process opened (symbol-keyed so concurrent pages
|
|
3720
|
+
// each hold their own); `lastFollowedSession` is the follow the wrapper
|
|
3721
|
+
// answered MOST RECENTLY, local or remote — together they resolve a
|
|
3722
|
+
// path-only image request to its owning session without waiting for the
|
|
3723
|
+
// browser's 1s current-session report. All in-memory, session-scale
|
|
3724
|
+
// (the cwd map is capped like the closed-session registry). The
|
|
3725
|
+
// `session/control` merge is deliberately NOT a source: its frame
|
|
3726
|
+
// vocabulary (RT dsh-api-session-controller lib/typert.remote-client.js —
|
|
3727
|
+
// `baseline` with value.projections, `projection` with sessionId/seq/
|
|
3728
|
+
// changes) carries no session summary and no cwd, so a scan there could
|
|
3729
|
+
// never record anything.
|
|
3730
|
+
type RemoteSessionCwd = { serverId: string; sessionId: string; cwd: string }
|
|
3731
|
+
const remoteSessionCwds = new Map<string, RemoteSessionCwd>()
|
|
3732
|
+
const activeRemoteFollows = new Map<symbol, { serverId: string; sessionId: string }>()
|
|
3733
|
+
let lastFollowedSession: { kind: 'remote'; serverId: string; sessionId: string } | { kind: 'local' } | undefined
|
|
3734
|
+
const MAX_REMOTE_SESSION_CWDS = 200
|
|
3735
|
+
const recordRemoteSessionCwd = (serverId: string, rawSessionId: unknown, cwd: unknown): void => {
|
|
3736
|
+
if (typeof rawSessionId !== 'string' || rawSessionId === '') return
|
|
3737
|
+
if (typeof cwd !== 'string' || cwd === '') return
|
|
3738
|
+
const key = toVirtual(serverId, rawSessionId)
|
|
3739
|
+
// Re-insert to refresh the recency order, then cap like `closedSessions`.
|
|
3740
|
+
remoteSessionCwds.delete(key)
|
|
3741
|
+
if (remoteSessionCwds.size >= MAX_REMOTE_SESSION_CWDS) {
|
|
3742
|
+
const oldest = remoteSessionCwds.keys().next()
|
|
3743
|
+
if (oldest.done !== true) remoteSessionCwds.delete(oldest.value)
|
|
3744
|
+
}
|
|
3745
|
+
remoteSessionCwds.set(key, { serverId, sessionId: rawSessionId, cwd })
|
|
3746
|
+
}
|
|
3747
|
+
/** Record every cwd a remote `session/list` result carries (both merge
|
|
3748
|
+
* modes call this on the RAW remote value, before ids go virtual). */
|
|
3749
|
+
const noteRemoteSessionListCwds = (remoteResult: unknown, serverId: string): void => {
|
|
3750
|
+
if (!isPlainObject(remoteResult) || !Array.isArray(remoteResult.items)) return
|
|
3751
|
+
for (const item of remoteResult.items) {
|
|
3752
|
+
if (!isPlainObject(item)) continue
|
|
3753
|
+
const id = typeof item.sessionId === 'string' && item.sessionId !== ''
|
|
3754
|
+
? item.sessionId
|
|
3755
|
+
: typeof item.id === 'string' && item.id !== ''
|
|
3756
|
+
? item.id
|
|
3757
|
+
: undefined
|
|
3758
|
+
if (id === undefined) continue
|
|
3759
|
+
recordRemoteSessionCwd(serverId, id, item.cwd)
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
/**
|
|
3763
|
+
* The resolution the /api/file wrap buys with one call. Steps, in order:
|
|
3764
|
+
* A) the ACTIVE remote session — the browser-reported current session
|
|
3765
|
+
* (unless this process's newest follow went LOCAL, which is the newer
|
|
3766
|
+
* fact), else the newest follow, else the newest live remote follow;
|
|
3767
|
+
* B) a precise cwd match — the active session's own cwd first, then any
|
|
3768
|
+
* known cwd the path falls under (a session with a LIVE follow preferred,
|
|
3769
|
+
* else the most recently refreshed match);
|
|
3770
|
+
* C) no cwd claim anywhere → the active remote (so a just-opened page's
|
|
3771
|
+
* first image resolves with zero cwd knowledge), or `undefined` when no
|
|
3772
|
+
* remote session is active — a local session's images stay local.
|
|
3773
|
+
*/
|
|
3774
|
+
const resolveRemoteFileScope = (rawPath: string): { serverId: string; sessionId: string } | undefined => {
|
|
3775
|
+
if (typeof rawPath !== 'string' || rawPath === '') return undefined
|
|
3776
|
+
let activeRemote: { serverId: string; sessionId: string } | undefined
|
|
3777
|
+
const current = getCurrentSession()
|
|
3778
|
+
if (current.kind === 'open') {
|
|
3779
|
+
const parts = fromVirtual(current.sessionId)
|
|
3780
|
+
if (parts !== undefined && lastFollowedSession?.kind !== 'local') {
|
|
3781
|
+
activeRemote = { serverId: parts.serverId, sessionId: parts.id }
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
if (activeRemote === undefined && lastFollowedSession?.kind === 'remote') {
|
|
3785
|
+
activeRemote = { serverId: lastFollowedSession.serverId, sessionId: lastFollowedSession.sessionId }
|
|
3786
|
+
}
|
|
3787
|
+
if (activeRemote === undefined && activeRemoteFollows.size > 0) {
|
|
3788
|
+
const values = [...activeRemoteFollows.values()]
|
|
3789
|
+
const last = values[values.length - 1]
|
|
3790
|
+
if (last !== undefined) activeRemote = { ...last }
|
|
3791
|
+
}
|
|
3792
|
+
if (activeRemote !== undefined) {
|
|
3793
|
+
const own = remoteSessionCwds.get(toVirtual(activeRemote.serverId, activeRemote.sessionId))
|
|
3794
|
+
if (own !== undefined && pathMatchesCwd(rawPath, own.cwd)) {
|
|
3795
|
+
return { serverId: own.serverId, sessionId: own.sessionId }
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
const followedVirtuals = new Set([...activeRemoteFollows.values()].map((entry) => toVirtual(entry.serverId, entry.sessionId)))
|
|
3799
|
+
let latestMatch: RemoteSessionCwd | undefined
|
|
3800
|
+
let followedMatch: RemoteSessionCwd | undefined
|
|
3801
|
+
for (const entry of remoteSessionCwds.values()) {
|
|
3802
|
+
if (!pathMatchesCwd(rawPath, entry.cwd)) continue
|
|
3803
|
+
latestMatch = entry
|
|
3804
|
+
if (followedVirtuals.has(toVirtual(entry.serverId, entry.sessionId))) followedMatch = entry
|
|
3805
|
+
}
|
|
3806
|
+
if (followedMatch !== undefined) return { serverId: followedMatch.serverId, sessionId: followedMatch.sessionId }
|
|
3807
|
+
if (latestMatch !== undefined) return { serverId: latestMatch.serverId, sessionId: latestMatch.sessionId }
|
|
3808
|
+
return activeRemote
|
|
3809
|
+
}
|
|
3810
|
+
// The last successfully fetched SERVER model catalog (T52-fix), kept for
|
|
3811
|
+
// the offline merges of the `session/modelCatalog` route below — cleared
|
|
3812
|
+
// on a pairing wall (eagerly, by the T52-fix2 catalog watcher; the route's
|
|
3813
|
+
// own drop is the backstop) and never served for another server's id (see the
|
|
3814
|
+
// route's doc). Only an in-memory value: the groups are display data and
|
|
3815
|
+
// a reload refetches them anyway. Since T52-fix3 it feeds ONLY this route:
|
|
3816
|
+
// the modelSelection provider rewrites are unconditional again (the host's
|
|
3817
|
+
// projection store is first-write-wins per seq, so a catalog-gated rewrite
|
|
3818
|
+
// would poison the store — see virtualizeModelSelectionValue), and the
|
|
3819
|
+
// `groups` membership set is gone with the gate.
|
|
3820
|
+
let serverCatalogCache: { identity: MergerIdentity; value: unknown } | undefined
|
|
3821
|
+
/**
|
|
3822
|
+
* The serving generation, bumped on every pairing wall (`unpaired` /
|
|
3823
|
+
* `revoked`, T52-fix3): a catalog fetch that was IN FLIGHT when the wall
|
|
3824
|
+
* rose must not write its answer back — the wall's groups must not outlive
|
|
3825
|
+
* it, and a late write would resurrect them under the offline merge below.
|
|
3826
|
+
* Both cache writes (the proactive fetch here, the route's own fetch)
|
|
3827
|
+
* snapshot the counter before invoking and compare before storing; a
|
|
3828
|
+
* mismatched answer is silently dropped — it is not a fault, the next
|
|
3829
|
+
* serving period fetches fresh anyway.
|
|
3830
|
+
*/
|
|
3831
|
+
let catalogGeneration = 0
|
|
3832
|
+
const rememberServerCatalog = (identity: MergerIdentity, value: unknown, generation: number): void => {
|
|
3833
|
+
if (generation !== catalogGeneration) return
|
|
3834
|
+
serverCatalogCache = { identity, value }
|
|
3835
|
+
}
|
|
3836
|
+
/** MS-3 pool mode: the per-server form of the same cache — one entry per
|
|
3837
|
+
* known `serverId`, keyed by it. An offline entry keeps serving its cache
|
|
3838
|
+
* (the groups must not go blank mid-outage); an entry that LEAVES the pool
|
|
3839
|
+
* takes its cache with it (its groups must not outlive the pairing). */
|
|
3840
|
+
const serverCatalogCaches = new Map<string, { identity: MergerIdentity; value: unknown }>()
|
|
3841
|
+
const catalogGenerations = new Map<string, number>()
|
|
3842
|
+
/** One server-catalog fetch whose answer only feeds the cache (T52-fix2):
|
|
3843
|
+
* the proactive pull that runs as soon as the relay serves — and once at
|
|
3844
|
+
* install when the relay already serves (subscribe replays no state —
|
|
3845
|
+
* relay-client.ts setState returns on a same-state write — so the
|
|
3846
|
+
* subscription alone would miss an install-into-online). Failures are
|
|
3847
|
+
* diagnostics-ring entries, nothing else (the route's own fetch is the
|
|
3848
|
+
* user-visible path and answers local-only there). */
|
|
3849
|
+
const fetchServerCatalog = (identity: MergerIdentity): void => {
|
|
3850
|
+
const generation = catalogGeneration
|
|
3851
|
+
void singleRelay.invoke('session', 'modelCatalog', {}, undefined).then(
|
|
3852
|
+
(value) => {
|
|
3853
|
+
rememberServerCatalog(identity, value, generation)
|
|
3854
|
+
},
|
|
3855
|
+
(error: unknown) => {
|
|
3856
|
+
recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal')
|
|
3857
|
+
},
|
|
3858
|
+
)
|
|
3859
|
+
}
|
|
3860
|
+
/** MS-3 pool mode: the per-server proactive fetch. The identity's display
|
|
3861
|
+
* name is the POOL's (the `-1`/`-2` disambiguated one) — it titles the
|
|
3862
|
+
* merged catalog's virtual groups. Generation-guarded per server, like
|
|
3863
|
+
* the single-server fetch above. */
|
|
3864
|
+
const fetchServerCatalogFor = (entry: { serverId: string; displayName: string; client: RelayClient }): void => {
|
|
3865
|
+
const serverId = entry.serverId
|
|
3866
|
+
const generation = catalogGenerations.get(serverId) ?? 0
|
|
3867
|
+
void entry.client.invoke('session', 'modelCatalog', {}, undefined).then(
|
|
3868
|
+
(value) => {
|
|
3869
|
+
if ((catalogGenerations.get(serverId) ?? 0) !== generation) return
|
|
3870
|
+
serverCatalogCaches.set(serverId, { identity: { serverId, serverName: entry.displayName }, value })
|
|
3871
|
+
},
|
|
3872
|
+
(error: unknown) => {
|
|
3873
|
+
recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal')
|
|
3874
|
+
},
|
|
3875
|
+
)
|
|
3876
|
+
}
|
|
3877
|
+
// The catalog watcher (T52-fix2). The subscription fires on the real
|
|
3878
|
+
// online transitions (first handshake, every recovery) — and since T59
|
|
3879
|
+
// also on name-only notifications, which arrive with the state still
|
|
3880
|
+
// `online` and are deduped by the same refreshKey/stateSeen gates above.
|
|
3881
|
+
// The install-time check below covers an install into an already-online
|
|
3882
|
+
// relay. `unpaired` / `revoked`
|
|
3883
|
+
// drop the cache the same way the catalog route does lazily — the wall's
|
|
3884
|
+
// groups must not outlive it — and bump the generation so an in-flight
|
|
3885
|
+
// fetch cannot write the old catalog back (T52-fix3). Faults stay
|
|
3886
|
+
// contained: the sync body cannot throw, and the fetch settles through
|
|
3887
|
+
// .then/.catch above.
|
|
3888
|
+
let offCatalogWatch: (() => void) | undefined
|
|
3889
|
+
// MS-3 pool mode: one client subscription per pool entry (the map below),
|
|
3890
|
+
// created and retired by the watchPool rescan. MS-5: uninstall tears them
|
|
3891
|
+
// ALL down — `offCatalogWatch` only unsubscribes the rescan itself, and a
|
|
3892
|
+
// left-behind per-entry subscription would keep firing into this dead
|
|
3893
|
+
// generation's caches on every later state change.
|
|
3894
|
+
const catalogWatched = new Map<string, () => void>()
|
|
3895
|
+
if (pool === undefined) {
|
|
3896
|
+
try {
|
|
3897
|
+
offCatalogWatch = singleRelay.subscribe((state) => {
|
|
3898
|
+
if (state === 'unpaired' || state === 'revoked') {
|
|
3899
|
+
catalogGeneration += 1
|
|
3900
|
+
serverCatalogCache = undefined
|
|
3901
|
+
return
|
|
3902
|
+
}
|
|
3903
|
+
if (state !== 'online') return
|
|
3904
|
+
const identity = relayIdentityOf(singleRelay)
|
|
3905
|
+
if (identity !== undefined) fetchServerCatalog(identity)
|
|
3906
|
+
})
|
|
3907
|
+
// Install into an already-serving relay: subscribe replayed nothing, so
|
|
3908
|
+
// the transition-based fetch would wait for the NEXT flap — fill the
|
|
3909
|
+
// cache now instead (T52-fix3).
|
|
3910
|
+
if (singleRelay.state === 'online') {
|
|
3911
|
+
const identity = relayIdentityOf(singleRelay)
|
|
3912
|
+
if (identity !== undefined) fetchServerCatalog(identity)
|
|
3913
|
+
}
|
|
3914
|
+
} catch (error) {
|
|
3915
|
+
// Subscription failed: the cache still fills whenever the UI opens a
|
|
3916
|
+
// dropdown (the route's own fetch), only later.
|
|
3917
|
+
log?.('client catalog watcher unavailable: %s', messageOf(error))
|
|
3918
|
+
}
|
|
3919
|
+
} else {
|
|
3920
|
+
// MS-3 pool mode: one watcher per entry, rescanned on every pool event.
|
|
3921
|
+
// `unpaired` / `revoked` drop THAT server's cache and bump its
|
|
3922
|
+
// generation; `online` proactively refetches it (an entry appearing
|
|
3923
|
+
// already online is fetched at attach, same as the single-server
|
|
3924
|
+
// install-into-online above).
|
|
3925
|
+
offCatalogWatch = watchPool(() => {
|
|
3926
|
+
const seen = new Set<string>()
|
|
3927
|
+
for (const entry of pool.entries()) {
|
|
3928
|
+
seen.add(entry.entryId)
|
|
3929
|
+
if (catalogWatched.has(entry.entryId)) continue
|
|
3930
|
+
const unsubscribe = entry.client.subscribe((state) => {
|
|
3931
|
+
// The entry's serverId is read LIVE — a re-handshake may have
|
|
3932
|
+
// renamed it since this listener attached.
|
|
3933
|
+
const live = pool.entries().find((candidate) => candidate.entryId === entry.entryId)
|
|
3934
|
+
const serverId = live?.serverId
|
|
3935
|
+
if (serverId === undefined) return
|
|
3936
|
+
if (state === 'unpaired' || state === 'revoked') {
|
|
3937
|
+
catalogGenerations.set(serverId, (catalogGenerations.get(serverId) ?? 0) + 1)
|
|
3938
|
+
serverCatalogCaches.delete(serverId)
|
|
3939
|
+
return
|
|
3940
|
+
}
|
|
3941
|
+
if (state !== 'online') return
|
|
3942
|
+
const current = live ?? entry
|
|
3943
|
+
if (current.serverId !== undefined) {
|
|
3944
|
+
fetchServerCatalogFor({ serverId: current.serverId, displayName: current.displayName, client: current.client })
|
|
3945
|
+
}
|
|
3946
|
+
})
|
|
3947
|
+
catalogWatched.set(entry.entryId, unsubscribe)
|
|
3948
|
+
if (entry.client.state === 'online' && entry.serverId !== undefined) {
|
|
3949
|
+
fetchServerCatalogFor({ serverId: entry.serverId, displayName: entry.displayName, client: entry.client })
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
for (const [entryId, unsubscribe] of [...catalogWatched]) {
|
|
3953
|
+
if (!seen.has(entryId)) {
|
|
3954
|
+
unsubscribe()
|
|
3955
|
+
catalogWatched.delete(entryId)
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
})
|
|
3959
|
+
// Run the initial scan through the hub so the same rescan logic runs.
|
|
3960
|
+
for (const listener of [...poolListeners]) listener()
|
|
3961
|
+
}
|
|
3962
|
+
const registerClosed = (sessionId: string, reason: ClosedSessionRecord['reason']): void => {
|
|
3963
|
+
closedSessions.delete(sessionId)
|
|
3964
|
+
if (closedSessions.size >= MAX_CLOSED_SESSIONS) {
|
|
3965
|
+
const oldest = closedSessions.keys().next()
|
|
3966
|
+
if (oldest.done !== true) closedSessions.delete(oldest.value)
|
|
3967
|
+
}
|
|
3968
|
+
closedSessions.set(sessionId, reason)
|
|
3969
|
+
}
|
|
3970
|
+
const clearClosed = (virtuals: VirtualParts[]): void => {
|
|
3971
|
+
let cleared = false
|
|
3972
|
+
for (const parts of virtuals) {
|
|
3973
|
+
if (closedSessions.delete(toVirtual(parts.serverId, parts.id))) cleared = true
|
|
3974
|
+
}
|
|
3975
|
+
if (cleared) for (const waiter of [...closedClearWaiters]) waiter()
|
|
3976
|
+
}
|
|
3977
|
+
let installed = true
|
|
3978
|
+
let selfCheck: SelfCheckResult | undefined
|
|
3979
|
+
|
|
3980
|
+
/**
|
|
3981
|
+
* Reload overlay, for the record (CP4): the theoretical hazard is installing
|
|
3982
|
+
* a NEW wrapper while an OLD one is still installed, so each captures the
|
|
3983
|
+
* other and the older survives as a live layer. Cordis `fiber.restart()`
|
|
3984
|
+
* disposes the old fiber FIRST (its effect calls {@link InterceptHandle.uninstall},
|
|
3985
|
+
* restoring the saved property) and only then applies the new row — the
|
|
3986
|
+
* overlay ordering never happens today, so there is nothing to absorb at
|
|
3987
|
+
* install time. Detection is also not cheaply implementable: a reloaded row
|
|
3988
|
+
* loads a FRESH module instance, so a stale wrapper shares no mark with the
|
|
3989
|
+
* new one — telling "ours, from a dead generation" apart from another
|
|
3990
|
+
* plugin's wrapper would take a process-wide registry, which the comment
|
|
3991
|
+
* above argues is not worth it while the ordering cannot occur. The
|
|
3992
|
+
* `installed === false → inert passthrough` rule below is the safety net
|
|
3993
|
+
* for the ordering that CAN happen: a later plugin wrapping OVER us and
|
|
3994
|
+
* keeping a reference past our uninstall.
|
|
3995
|
+
*
|
|
3996
|
+
* Save the CURRENT value per property (possibly another plugin's wrapper —
|
|
3997
|
+
* shape note "own-property"), falling back to the prototype method only
|
|
3998
|
+
* when the current value is not callable. Uninstall restores the saved
|
|
3999
|
+
* own state verbatim: value if there was one, absence if not.
|
|
4000
|
+
*/
|
|
4001
|
+
const saved: Record<string, { existed: boolean; value: unknown }> = {}
|
|
4002
|
+
const chainTarget: Record<string, unknown> = {}
|
|
4003
|
+
for (const name of ['openWireStream', 'dispatchRpc']) {
|
|
4004
|
+
const existed = Object.prototype.hasOwnProperty.call(gateway, name)
|
|
4005
|
+
const value = existed ? gateway[name] : undefined
|
|
4006
|
+
saved[name] = { existed, value }
|
|
4007
|
+
chainTarget[name] = typeof value === 'function' ? value : Object.getPrototypeOf(raw)?.[name]
|
|
4008
|
+
}
|
|
4009
|
+
// Filled in below, once the wrapper functions exist: uninstall may only
|
|
4010
|
+
// touch a property that still holds OUR wrapper.
|
|
4011
|
+
const wrappers: Record<string, unknown> = {}
|
|
4012
|
+
|
|
4013
|
+
const recordFailure = (endpoint: string, code: string): void => {
|
|
4014
|
+
if (failures.length >= MAX_FAILURES) failures.shift()
|
|
4015
|
+
failures.push({ time: new Date().toISOString(), endpoint, code })
|
|
4016
|
+
// T42: validation-shaped refusals additionally land in the incompatible
|
|
4017
|
+
// ring — every remote failure funnels through this one function, so the
|
|
4018
|
+
// classification cannot drift between the invoke and stream routes.
|
|
4019
|
+
if (!INCOMPATIBLE_CALL_CODES.has(code)) return
|
|
4020
|
+
if (incompatible.length >= MAX_INCOMPATIBLE_CALLS) incompatible.shift()
|
|
4021
|
+
incompatible.push({ time: Date.now(), endpoint, code })
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
const failEnvelope = (
|
|
4025
|
+
endpoint: string,
|
|
4026
|
+
code: string,
|
|
4027
|
+
text: string,
|
|
4028
|
+
): { ok: false; error: { code: string; message: string; details: Record<string, unknown> } } => {
|
|
4029
|
+
recordFailure(endpoint, code)
|
|
4030
|
+
// `details: {}` is load-bearing: the UI's server-response parser throws
|
|
4031
|
+
// `invalid server-response failure` on a details-less error (and the
|
|
4032
|
+
// gateway's rpcErrorSchema demands the same shape).
|
|
4033
|
+
return { ok: false, error: { code, message: text, details: {} } }
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
/**
|
|
4037
|
+
* The shared validation both routes apply before forwarding — now (MS-3)
|
|
4038
|
+
* resolving the TARGET SERVER as well as the call's shape. The single
|
|
4039
|
+
* `relay` mode keeps the original verdict order byte-for-byte (offline →
|
|
4040
|
+
* mismatch → unsupported); pool mode routes by the virtual id's serverId:
|
|
4041
|
+
* no handshook server in the pool answers `remote-offline`, a handshook
|
|
4042
|
+
* pool without the target server answers `remote-mismatch`, and the
|
|
4043
|
+
* matched entry's client is the forwarding leg. A refusal is recorded
|
|
4044
|
+
* once, here, by the caller passing it through {@link failEnvelope}.
|
|
4045
|
+
*/
|
|
4046
|
+
const copy = () => INTERCEPT_COPY[resolveInterceptLang(options.lang)]
|
|
4047
|
+
type Verdict = { ok: true; client: RelayClient; serverId: string } | { ok: false; code: string; message: string }
|
|
4048
|
+
function validate(endpoint: string, virtuals: VirtualParts[]): Verdict {
|
|
4049
|
+
const first = virtuals[0].serverId
|
|
4050
|
+
const c = copy()
|
|
4051
|
+
if (pool === undefined) {
|
|
4052
|
+
const current = singleGetServerId()
|
|
4053
|
+
if (current === undefined) return { ok: false, code: 'remote-offline', message: c.notConnected }
|
|
4054
|
+
if (virtuals.some((parts) => parts.serverId !== first) || current !== first) {
|
|
4055
|
+
return { ok: false, code: 'remote-mismatch', message: c.mismatch }
|
|
4056
|
+
}
|
|
4057
|
+
if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
|
|
4058
|
+
return { ok: false, code: 'remote-unsupported', message: c.unsupported }
|
|
4059
|
+
}
|
|
4060
|
+
return { ok: true, client: singleRelay, serverId: first }
|
|
4061
|
+
}
|
|
4062
|
+
if (virtuals.some((parts) => parts.serverId !== first)) {
|
|
4063
|
+
return { ok: false, code: 'remote-mismatch', message: c.mismatch }
|
|
4064
|
+
}
|
|
4065
|
+
const known = pool.entries().filter((entry) => entry.serverId !== undefined)
|
|
4066
|
+
if (known.length === 0) return { ok: false, code: 'remote-offline', message: c.notConnected }
|
|
4067
|
+
const target = known.find((entry) => entry.serverId === first)
|
|
4068
|
+
if (target === undefined) return { ok: false, code: 'remote-mismatch', message: c.mismatch }
|
|
4069
|
+
if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
|
|
4070
|
+
return { ok: false, code: 'remote-unsupported', message: c.unsupported }
|
|
4071
|
+
}
|
|
4072
|
+
return { ok: true, client: target.client, serverId: first }
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
/**
|
|
4076
|
+
* The T32 `$events/result` split: an answer whose eventId is VIRTUAL
|
|
4077
|
+
* belongs to a forwarded remote event and must NOT reach the local
|
|
4078
|
+
* gateway (its own `$events` never delivered it — the local dispatcher
|
|
4079
|
+
* would answer with its `no active event stream` failure). The virtual
|
|
4080
|
+
* id goes back to the original, the local payload's clientId (the local
|
|
4081
|
+
* stream's own) is discarded — the relay server composes the gateway
|
|
4082
|
+
* payload from ITS subscription's clientId — and the outcome rides
|
|
4083
|
+
* verbatim. Anything else, including a malformed payload, is the local
|
|
4084
|
+
* gateway's business.
|
|
4085
|
+
*/
|
|
4086
|
+
function forwardEventResult(payload: unknown, signal: AbortSignal | undefined, peer: unknown): unknown {
|
|
4087
|
+
const args = argsOf(payload)
|
|
2733
4088
|
const eventId = isPlainObject(args) ? args.eventId : undefined
|
|
2734
4089
|
const virtual = fromVirtual(eventId)
|
|
2735
4090
|
if (virtual === undefined) {
|
|
@@ -2742,7 +4097,18 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2742
4097
|
)
|
|
2743
4098
|
}
|
|
2744
4099
|
const endpoint = '$events/result'
|
|
2745
|
-
const
|
|
4100
|
+
const route = ((): { client: RelayClient } | 'offline' | 'missing' | 'mismatch' => {
|
|
4101
|
+
if (pool === undefined) {
|
|
4102
|
+
const current = singleGetServerId()
|
|
4103
|
+
if (current === undefined || singleRelay.state !== 'online') return 'offline'
|
|
4104
|
+
if (virtual.serverId !== current) return 'mismatch'
|
|
4105
|
+
return { client: singleRelay }
|
|
4106
|
+
}
|
|
4107
|
+
const target = pool.getByServerId(virtual.serverId)
|
|
4108
|
+
if (target === undefined) return 'missing'
|
|
4109
|
+
if (target.client.state !== 'online') return 'offline'
|
|
4110
|
+
return { client: target.client }
|
|
4111
|
+
})()
|
|
2746
4112
|
// Offline (T34-fix): a refusal envelope would RESTART the UI's whole
|
|
2747
4113
|
// `$events` generation — the client face's answer() throws on a
|
|
2748
4114
|
// `!response.ok` body and the pump treats that as a delivery failure
|
|
@@ -2751,15 +4117,25 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2751
4117
|
// synthesized cancels the dying leg already sent have closed these
|
|
2752
4118
|
// prompts anyway. Answer the silent ok DSH itself gives a stale result,
|
|
2753
4119
|
// and record the refusal in the diagnostics ring instead — the
|
|
2754
|
-
// non-online write-refusal message, so the banner's word matches.
|
|
2755
|
-
|
|
2756
|
-
|
|
4120
|
+
// non-online write-refusal message, so the banner's word matches. In
|
|
4121
|
+
// pool mode a leg the pool no longer serves (`missing` — unpaired away,
|
|
4122
|
+
// deduped, never paired) reads the same way: nothing can settle those
|
|
4123
|
+
// events anymore, so the silent ok wins there too. The single-server
|
|
4124
|
+
// mismatch keeps its original REFUSAL envelope (wrong server is a
|
|
4125
|
+
// different fact from a leg that just left).
|
|
4126
|
+
if (route === 'offline') {
|
|
4127
|
+
failEnvelope(endpoint, 'remote-offline', copy().writeOffline)
|
|
4128
|
+
return { ok: true, value: undefined }
|
|
4129
|
+
}
|
|
4130
|
+
if (route === 'missing') {
|
|
4131
|
+
failEnvelope(endpoint, 'remote-mismatch', copy().mismatch)
|
|
2757
4132
|
return { ok: true, value: undefined }
|
|
2758
4133
|
}
|
|
2759
|
-
if (
|
|
4134
|
+
if (route === 'mismatch') return failEnvelope(endpoint, 'remote-mismatch', copy().mismatch)
|
|
4135
|
+
const client = route.client
|
|
2760
4136
|
return (async (): Promise<DispatchEnvelope> => {
|
|
2761
4137
|
try {
|
|
2762
|
-
await
|
|
4138
|
+
await client.postEventResult(virtual.id, isPlainObject(args) ? args.outcome : undefined, signal)
|
|
2763
4139
|
return { ok: true, value: undefined }
|
|
2764
4140
|
} catch (error) {
|
|
2765
4141
|
const code = error instanceof RelayError ? error.code : 'internal'
|
|
@@ -2802,11 +4178,13 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2802
4178
|
// A WRITE while the relay is not serving (T34, whitelist rule per
|
|
2803
4179
|
// T34-fix): refused here, never sent — the call could only fail out
|
|
2804
4180
|
// there, and the read paths answer for themselves (forwarded as today,
|
|
2805
|
-
// failing with their own transport error).
|
|
2806
|
-
|
|
2807
|
-
|
|
4181
|
+
// failing with their own transport error). Pool mode (MS-3): the verdict
|
|
4182
|
+
// names the TARGET leg — its state is the one that counts.
|
|
4183
|
+
if (isRemoteWrite(endpoint) && verdict.client.state !== 'online') {
|
|
4184
|
+
return failEnvelope(endpoint, 'remote-offline', copy().writeOffline)
|
|
2808
4185
|
}
|
|
2809
4186
|
const serverId = verdict.serverId
|
|
4187
|
+
const client = verdict.client
|
|
2810
4188
|
const slash = endpoint.indexOf('/')
|
|
2811
4189
|
const namespace = slash === -1 ? endpoint : endpoint.slice(0, slash)
|
|
2812
4190
|
const method = slash === -1 ? '' : endpoint.slice(slash + 1)
|
|
@@ -2832,9 +4210,9 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2832
4210
|
const references = restoreSessionReferences(clone, namespace, method, serverId)
|
|
2833
4211
|
clone = references.args
|
|
2834
4212
|
if (!references.ok) {
|
|
2835
|
-
return failEnvelope(endpoint, 'remote-unsupported',
|
|
4213
|
+
return failEnvelope(endpoint, 'remote-unsupported', copy().referenceNotOnServer)
|
|
2836
4214
|
}
|
|
2837
|
-
const value = await
|
|
4215
|
+
const value = await client.invoke(namespace, method, clone, signal)
|
|
2838
4216
|
// The session answered: any closed-session entry for it is stale
|
|
2839
4217
|
// (re-shared and served again) — the banner may go.
|
|
2840
4218
|
clearClosed(virtuals)
|
|
@@ -2863,7 +4241,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2863
4241
|
recordFailure(endpoint, code)
|
|
2864
4242
|
// A call on a closed session reads like the banner, not like the
|
|
2865
4243
|
// relay's bare code (T73).
|
|
2866
|
-
const message = code === 'not-shared' ?
|
|
4244
|
+
const message = code === 'not-shared' ? copy().closed : messageOf(error)
|
|
2867
4245
|
return { ok: false, error: { code, message, details: error instanceof RelayError && isPlainObject(error.details) ? error.details : {} } }
|
|
2868
4246
|
}
|
|
2869
4247
|
}
|
|
@@ -2897,20 +4275,20 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2897
4275
|
* can never reach us (subscribe's contract keeps listener faults contained
|
|
2898
4276
|
* on the client side).
|
|
2899
4277
|
*/
|
|
2900
|
-
function waitForOnline(signal: AbortSignal | undefined): Promise<ServeWait> {
|
|
4278
|
+
function waitForOnline(client: RelayClient, signal: AbortSignal | undefined): Promise<ServeWait> {
|
|
2901
4279
|
return new Promise<ServeWait>((resolve) => {
|
|
2902
4280
|
if (signal?.aborted === true) {
|
|
2903
4281
|
resolve('aborted')
|
|
2904
4282
|
return
|
|
2905
4283
|
}
|
|
2906
|
-
if (
|
|
4284
|
+
if (client.state === 'online') {
|
|
2907
4285
|
resolve('online')
|
|
2908
4286
|
return
|
|
2909
4287
|
}
|
|
2910
4288
|
// The wall may already be down when the wait starts (the leg died and
|
|
2911
4289
|
// the state flipped before this call) — same verdict as below.
|
|
2912
|
-
if (
|
|
2913
|
-
resolve(
|
|
4290
|
+
if (client.state === 'revoked' || client.state === 'unpaired') {
|
|
4291
|
+
resolve(client.state)
|
|
2914
4292
|
return
|
|
2915
4293
|
}
|
|
2916
4294
|
let off: (() => void) | undefined
|
|
@@ -2922,7 +4300,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2922
4300
|
resolve(verdict)
|
|
2923
4301
|
}
|
|
2924
4302
|
const onAbort = (): void => settle('aborted')
|
|
2925
|
-
off =
|
|
4303
|
+
off = client.subscribe((state) => {
|
|
2926
4304
|
if (state === 'online') settle('online')
|
|
2927
4305
|
else if (state === 'revoked' || state === 'unpaired') settle(state)
|
|
2928
4306
|
})
|
|
@@ -2955,14 +4333,14 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2955
4333
|
* current backoff step. `aborted` only on caller abort; `revoked` /
|
|
2956
4334
|
* `unpaired` when the pairing wall came down (CP5).
|
|
2957
4335
|
*/
|
|
2958
|
-
async function waitServeAgain(signal: AbortSignal | undefined, backoffMs: number): Promise<ServeWait> {
|
|
2959
|
-
if (
|
|
4336
|
+
async function waitServeAgain(client: RelayClient, signal: AbortSignal | undefined, backoffMs: number): Promise<ServeWait> {
|
|
4337
|
+
if (client.state === 'online') {
|
|
2960
4338
|
await delayWait(signal, backoffMs)
|
|
2961
4339
|
if (signal?.aborted === true) return 'aborted'
|
|
2962
|
-
if (
|
|
4340
|
+
if (client.state !== 'online') return waitForOnline(client, signal)
|
|
2963
4341
|
return 'online'
|
|
2964
4342
|
}
|
|
2965
|
-
return waitForOnline(signal)
|
|
4343
|
+
return waitForOnline(client, signal)
|
|
2966
4344
|
}
|
|
2967
4345
|
|
|
2968
4346
|
/**
|
|
@@ -3007,6 +4385,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3007
4385
|
*/
|
|
3008
4386
|
async function* rewriteUpstream(
|
|
3009
4387
|
endpoint: string,
|
|
4388
|
+
client: RelayClient,
|
|
3010
4389
|
upstream: AsyncIterable<unknown>,
|
|
3011
4390
|
serverId: string,
|
|
3012
4391
|
claimed: VirtualParts[],
|
|
@@ -3033,6 +4412,12 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3033
4412
|
// closed-session entry for it is stale (T34).
|
|
3034
4413
|
clearClosed(claimed)
|
|
3035
4414
|
}
|
|
4415
|
+
// P55-2: the snapshot's header carries the session identity and its
|
|
4416
|
+
// cwd in ORIGINAL ids — record before the rewrite below virtualizes
|
|
4417
|
+
// them, so /api/file can attribute the session's own image paths.
|
|
4418
|
+
if (endpoint === 'session/follow' && isPlainObject(frame) && frame.type === 'snapshot' && isPlainObject(frame.header)) {
|
|
4419
|
+
recordRemoteSessionCwd(serverId, frame.header.id, frame.header.cwd)
|
|
4420
|
+
}
|
|
3036
4421
|
yield rewriteFrame(endpoint, frame, serverId)
|
|
3037
4422
|
}
|
|
3038
4423
|
return
|
|
@@ -3090,7 +4475,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3090
4475
|
if (onCleared !== undefined) closedClearWaiters.delete(onCleared)
|
|
3091
4476
|
return
|
|
3092
4477
|
}
|
|
3093
|
-
throw new CodedStreamError(code,
|
|
4478
|
+
throw new CodedStreamError(code, copy().closed)
|
|
3094
4479
|
}
|
|
3095
4480
|
recordFailure(endpoint, code)
|
|
3096
4481
|
// A link-down fact (CP4), scoped by CP5: only the carrier-retry
|
|
@@ -3103,7 +4488,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3103
4488
|
if (!SESSION_STREAM_HOLD_ENDPOINTS.has(endpoint)) {
|
|
3104
4489
|
throw new CodedStreamError(code, messageOf(error))
|
|
3105
4490
|
}
|
|
3106
|
-
const serve = await waitServeAgain(signal, reopenDelayMs)
|
|
4491
|
+
const serve = await waitServeAgain(client, signal, reopenDelayMs)
|
|
3107
4492
|
if (serve === 'aborted') return
|
|
3108
4493
|
// The pairing wall came down while held (CP5): no ladder climbs back
|
|
3109
4494
|
// from `revoked`/`unpaired` — only the settings page can — so the
|
|
@@ -3112,9 +4497,10 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3112
4497
|
// would freeze the panel with no verdict and no wake-up.
|
|
3113
4498
|
if (serve !== 'online') {
|
|
3114
4499
|
recordFailure(endpoint, serve)
|
|
4500
|
+
const c = copy()
|
|
3115
4501
|
throw new CodedStreamError(
|
|
3116
4502
|
serve,
|
|
3117
|
-
serve === 'revoked' ?
|
|
4503
|
+
serve === 'revoked' ? c.revoked : c.unpaired,
|
|
3118
4504
|
)
|
|
3119
4505
|
}
|
|
3120
4506
|
if (callerAborted()) return
|
|
@@ -3131,6 +4517,11 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3131
4517
|
* virtualized session ids. Paged requests, an offline relay, a non-ok local
|
|
3132
4518
|
* envelope and a failed remote call all answer the local result untouched —
|
|
3133
4519
|
* only a remote failure is recorded.
|
|
4520
|
+
*
|
|
4521
|
+
* MS-3 pool mode: EVERY online server's first page is fetched
|
|
4522
|
+
* concurrently and folded in `pool.entries()` order (pairing order), one
|
|
4523
|
+
* `mergeSessionList` per server. A failing leg is recorded and skipped —
|
|
4524
|
+
* the other servers' rows still merge.
|
|
3134
4525
|
*/
|
|
3135
4526
|
async function mergedSessionListCall(
|
|
3136
4527
|
endpoint: string,
|
|
@@ -3151,10 +4542,35 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3151
4542
|
// Only the first page merges: a cursor pages the local list alone.
|
|
3152
4543
|
const request = isPlainObject(args) ? args._request : undefined
|
|
3153
4544
|
if (isPlainObject(request) && request.cursor !== undefined) return localEnvelope
|
|
3154
|
-
|
|
3155
|
-
|
|
4545
|
+
if (pool !== undefined) {
|
|
4546
|
+
const online = pool.entries().filter((entry) => entry.serverId !== undefined && entry.client.state === 'online')
|
|
4547
|
+
if (online.length === 0) return localEnvelope
|
|
4548
|
+
const settled = await Promise.all(
|
|
4549
|
+
online.map((entry) =>
|
|
4550
|
+
entry.client.invoke('session', 'list', isPlainObject(args) ? args : {}, signal).then(
|
|
4551
|
+
(value) => ({ ok: true as const, value, serverId: entry.serverId as string }),
|
|
4552
|
+
(error: unknown) => ({ ok: false as const, error, serverId: entry.serverId as string }),
|
|
4553
|
+
),
|
|
4554
|
+
),
|
|
4555
|
+
)
|
|
4556
|
+
let value = localEnvelope.value
|
|
4557
|
+
for (const result of settled) {
|
|
4558
|
+
if (!result.ok) {
|
|
4559
|
+
recordFailure(endpoint, result.error instanceof RelayError ? result.error.code : 'internal')
|
|
4560
|
+
continue
|
|
4561
|
+
}
|
|
4562
|
+
// P55-2: the RAW remote rows (original ids) feed the /api/file cwd
|
|
4563
|
+
// registry before the merge virtualizes them.
|
|
4564
|
+
noteRemoteSessionListCwds(result.value, result.serverId)
|
|
4565
|
+
value = mergeSessionList(value, result.value, result.serverId)
|
|
4566
|
+
}
|
|
4567
|
+
return { ok: true, value }
|
|
4568
|
+
}
|
|
4569
|
+
const identity = relayIdentityOf(singleRelay)
|
|
4570
|
+
if (identity === undefined || singleRelay.state !== 'online') return localEnvelope
|
|
3156
4571
|
try {
|
|
3157
|
-
const remote = await
|
|
4572
|
+
const remote = await singleRelay.invoke('session', 'list', isPlainObject(args) ? args : {}, signal)
|
|
4573
|
+
noteRemoteSessionListCwds(remote, identity.serverId)
|
|
3158
4574
|
return { ok: true, value: mergeSessionList(localEnvelope.value, remote, identity.serverId) }
|
|
3159
4575
|
} catch (error) {
|
|
3160
4576
|
recordFailure(endpoint, error instanceof RelayError ? error.code : 'internal')
|
|
@@ -3197,8 +4613,52 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3197
4613
|
)) as unknown
|
|
3198
4614
|
// A local failure is the answer — there is nothing to merge into.
|
|
3199
4615
|
if (!isPlainObject(localEnvelope) || localEnvelope.ok !== true) return localEnvelope
|
|
3200
|
-
|
|
3201
|
-
|
|
4616
|
+
if (pool !== undefined) {
|
|
4617
|
+
// MS-3: every PAIRED server contributes its own groups, folded in
|
|
4618
|
+
// pairing order; each server's last fetched catalog is cached by its
|
|
4619
|
+
// serverId so an offline server keeps its groups while the online ones
|
|
4620
|
+
// refetch. A cache whose server left the pool dies with the entry.
|
|
4621
|
+
const entries = pool.entries().filter((entry) => entry.serverId !== undefined)
|
|
4622
|
+
if (entries.length === 0) return localEnvelope
|
|
4623
|
+
const online = entries.filter((entry) => entry.client.state === 'online')
|
|
4624
|
+
const settled =
|
|
4625
|
+
online.length === 0
|
|
4626
|
+
? []
|
|
4627
|
+
: await Promise.all(
|
|
4628
|
+
online.map((entry) =>
|
|
4629
|
+
entry.client.invoke('session', 'modelCatalog', {}, signal).then(
|
|
4630
|
+
(value) => ({ ok: true as const, value, serverId: entry.serverId as string }),
|
|
4631
|
+
(error: unknown) => ({ ok: false as const, error, serverId: entry.serverId as string }),
|
|
4632
|
+
),
|
|
4633
|
+
),
|
|
4634
|
+
)
|
|
4635
|
+
const resultsByServer = new Map(settled.map((result) => [result.serverId, result]))
|
|
4636
|
+
let acc = localEnvelope.value
|
|
4637
|
+
for (const entry of entries) {
|
|
4638
|
+
const serverId = entry.serverId as string
|
|
4639
|
+
const identity: MergerIdentity = { serverId, serverName: entry.displayName }
|
|
4640
|
+
const result = resultsByServer.get(serverId)
|
|
4641
|
+
if (result === undefined) {
|
|
4642
|
+
const cached = serverCatalogCaches.get(serverId)
|
|
4643
|
+
if (cached !== undefined) acc = mergeModelCatalogs(acc, cached.value, cached.identity)
|
|
4644
|
+
continue
|
|
4645
|
+
}
|
|
4646
|
+
if (!result.ok) {
|
|
4647
|
+
recordFailure(endpoint, result.error instanceof RelayError ? result.error.code : 'internal')
|
|
4648
|
+
const cached = serverCatalogCaches.get(serverId)
|
|
4649
|
+
if (cached !== undefined) acc = mergeModelCatalogs(acc, cached.value, cached.identity)
|
|
4650
|
+
continue
|
|
4651
|
+
}
|
|
4652
|
+
serverCatalogCaches.set(serverId, { identity, value: result.value })
|
|
4653
|
+
acc = mergeModelCatalogs(acc, result.value, identity)
|
|
4654
|
+
}
|
|
4655
|
+
for (const serverId of [...serverCatalogCaches.keys()]) {
|
|
4656
|
+
if (!entries.some((entry) => entry.serverId === serverId)) serverCatalogCaches.delete(serverId)
|
|
4657
|
+
}
|
|
4658
|
+
return { ok: true, value: acc }
|
|
4659
|
+
}
|
|
4660
|
+
const identity = relayIdentityOf(singleRelay)
|
|
4661
|
+
if (identity !== undefined && singleRelay.state === 'online') {
|
|
3202
4662
|
try {
|
|
3203
4663
|
// The method has NO parameters (RT lib/typert.remote-client.js:1019) —
|
|
3204
4664
|
// the relay route allows it field-less (relay-access.ts), filters the
|
|
@@ -3209,7 +4669,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3209
4669
|
// the counter and the answer never re-enters the cache — the caller
|
|
3210
4670
|
// still gets its (live) answer, it asked while the link served.
|
|
3211
4671
|
const generation = catalogGeneration
|
|
3212
|
-
const remote = await
|
|
4672
|
+
const remote = await singleRelay.invoke('session', 'modelCatalog', {}, signal)
|
|
3213
4673
|
rememberServerCatalog(identity, remote, generation)
|
|
3214
4674
|
return { ok: true, value: mergeModelCatalogs(localEnvelope.value, remote, identity) }
|
|
3215
4675
|
} catch (error) {
|
|
@@ -3219,7 +4679,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3219
4679
|
}
|
|
3220
4680
|
// Not serving normally. A pairing wall is a verdict about the server
|
|
3221
4681
|
// itself — its groups must not outlive it.
|
|
3222
|
-
if (
|
|
4682
|
+
if (singleRelay.state === 'unpaired' || singleRelay.state === 'revoked') {
|
|
3223
4683
|
serverCatalogCache = undefined
|
|
3224
4684
|
return localEnvelope
|
|
3225
4685
|
}
|
|
@@ -3277,16 +4737,26 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3277
4737
|
const sessionParts = fromVirtual(sessionId)
|
|
3278
4738
|
const providerParts = fromVirtual(provider)
|
|
3279
4739
|
if (sessionParts === undefined && providerParts === undefined) return undefined
|
|
4740
|
+
const c = copy()
|
|
3280
4741
|
if (sessionParts === undefined) {
|
|
3281
|
-
return { code: 'remote-unsupported', message:
|
|
4742
|
+
return { code: 'remote-unsupported', message: c.modelGroupRemote }
|
|
3282
4743
|
}
|
|
3283
|
-
|
|
4744
|
+
// MS-3: the session's server is looked up in the POOL (by the virtual
|
|
4745
|
+
// id's serverId); a pool without that server falls through to the
|
|
4746
|
+
// generic path's verdict (offline / mismatch). The message names the
|
|
4747
|
+
// pool's display name — the same `-1`/`-2` disambiguated one the merged
|
|
4748
|
+
// catalog groups are titled with.
|
|
4749
|
+
const displayName = ((): string | undefined => {
|
|
4750
|
+
if (pool !== undefined) return pool.getByServerId(sessionParts.serverId)?.displayName
|
|
4751
|
+
if (singleGetServerId() !== sessionParts.serverId) return undefined
|
|
4752
|
+
return relayIdentityOf(singleRelay)?.serverName
|
|
4753
|
+
})()
|
|
4754
|
+
if (displayName === undefined) return undefined
|
|
3284
4755
|
if (providerParts === undefined) {
|
|
3285
|
-
|
|
3286
|
-
return { code: 'remote-unsupported', message: `请选择「${serverName} · …」分组里的模型` }
|
|
4756
|
+
return { code: 'remote-unsupported', message: c.modelGroupPickServer(displayName) }
|
|
3287
4757
|
}
|
|
3288
4758
|
if (providerParts.serverId !== sessionParts.serverId) {
|
|
3289
|
-
return { code: 'remote-mismatch', message:
|
|
4759
|
+
return { code: 'remote-mismatch', message: c.modelGroupMismatch }
|
|
3290
4760
|
}
|
|
3291
4761
|
return { args: { ...args, request: { ...request, provider: providerParts.id } } }
|
|
3292
4762
|
}
|
|
@@ -3379,13 +4849,32 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3379
4849
|
return (async (): Promise<unknown> => {
|
|
3380
4850
|
const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow)
|
|
3381
4851
|
if (!isAsyncIterable(local)) return local
|
|
3382
|
-
|
|
4852
|
+
if (pool !== undefined) {
|
|
4853
|
+
return mergedEventsStreamMulti({ local, pool, signal, recordFailure, log, clock, summarySync, watchPool })
|
|
4854
|
+
}
|
|
4855
|
+
return mergedEventsStream({ local, relay: singleRelay, signal, recordFailure, log, clock, summarySync })
|
|
3383
4856
|
})()
|
|
3384
4857
|
}
|
|
3385
4858
|
const args = argsOf(payload)
|
|
3386
4859
|
const fields = CLIENT_METHOD_FIELDS[endpoint]
|
|
3387
4860
|
const virtuals = collectVirtuals(fields, args)
|
|
3388
4861
|
if (virtuals.length === 0) {
|
|
4862
|
+
// P55-2: a LOCAL session/follow (a well-formed session address whose id
|
|
4863
|
+
// is not virtual) is the newest fact about which page the user is on —
|
|
4864
|
+
// recorded at the same instant the wrap is reached, so a later image
|
|
4865
|
+
// request never resolves against the remote session just left.
|
|
4866
|
+
if (endpoint === 'session/follow') {
|
|
4867
|
+
const address = isPlainObject(args) && isPlainObject(args.request) ? args.request.address : undefined
|
|
4868
|
+
if (
|
|
4869
|
+
isPlainObject(address) &&
|
|
4870
|
+
address.kind === 'session' &&
|
|
4871
|
+
typeof address.sessionId === 'string' &&
|
|
4872
|
+
address.sessionId !== '' &&
|
|
4873
|
+
!isVirtual(address.sessionId)
|
|
4874
|
+
) {
|
|
4875
|
+
lastFollowedSession = { kind: 'local' }
|
|
4876
|
+
}
|
|
4877
|
+
}
|
|
3389
4878
|
if (endpoint === 'workspace/follow' || endpoint === 'session/control') {
|
|
3390
4879
|
// The startup self-check probe wants the LOCAL baseline only — no
|
|
3391
4880
|
// remote leg, no merge (its payload is registered in probePayloads).
|
|
@@ -3414,12 +4903,32 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3414
4903
|
// it); the remote leg carries no uplink.
|
|
3415
4904
|
const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow)
|
|
3416
4905
|
if (!isAsyncIterable(local)) return local
|
|
4906
|
+
if (pool !== undefined) {
|
|
4907
|
+
return mergedGlobalStreamMulti({
|
|
4908
|
+
endpoint,
|
|
4909
|
+
namespace,
|
|
4910
|
+
method,
|
|
4911
|
+
local,
|
|
4912
|
+
pool,
|
|
4913
|
+
signal,
|
|
4914
|
+
recordFailure,
|
|
4915
|
+
log,
|
|
4916
|
+
clock,
|
|
4917
|
+
getCurrentSession,
|
|
4918
|
+
watchCurrentSession,
|
|
4919
|
+
watchUninstall,
|
|
4920
|
+
summarySync,
|
|
4921
|
+
createSessionInventorySlot,
|
|
4922
|
+
watchPool,
|
|
4923
|
+
lang: options.lang,
|
|
4924
|
+
})
|
|
4925
|
+
}
|
|
3417
4926
|
return mergedGlobalStream({
|
|
3418
4927
|
endpoint,
|
|
3419
4928
|
namespace,
|
|
3420
4929
|
method,
|
|
3421
4930
|
local,
|
|
3422
|
-
relay,
|
|
4931
|
+
relay: singleRelay,
|
|
3423
4932
|
signal,
|
|
3424
4933
|
recordFailure,
|
|
3425
4934
|
log,
|
|
@@ -3428,7 +4937,10 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3428
4937
|
watchCurrentSession,
|
|
3429
4938
|
watchUninstall,
|
|
3430
4939
|
summarySync,
|
|
3431
|
-
|
|
4940
|
+
lang: options.lang,
|
|
4941
|
+
...(createSessionInventorySlot !== undefined
|
|
4942
|
+
? { createSessionInventorySlot: () => createSessionInventorySlot(undefined) }
|
|
4943
|
+
: {}),
|
|
3432
4944
|
})
|
|
3433
4945
|
})()
|
|
3434
4946
|
}
|
|
@@ -3444,22 +4956,39 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3444
4956
|
)
|
|
3445
4957
|
}
|
|
3446
4958
|
const verdict = validate(endpoint, virtuals)
|
|
3447
|
-
if (!verdict.ok)
|
|
4959
|
+
if (!verdict.ok) {
|
|
4960
|
+
// TA-2: a refusal must not leak the host's UplinkInbox — half-close it
|
|
4961
|
+
// before the coded throw, the same courtesy the forwarded path's own
|
|
4962
|
+
// failure paths extend below.
|
|
4963
|
+
releaseUplink(uplink)
|
|
4964
|
+
throwRecorded(endpoint, verdict.code, verdict.message)
|
|
4965
|
+
}
|
|
3448
4966
|
// A WRITE stream while the relay is not serving (CP4, the invoke route's
|
|
3449
|
-
// T34 rule applied to the stream route):
|
|
3450
|
-
//
|
|
3451
|
-
// terminal
|
|
3452
|
-
//
|
|
3453
|
-
|
|
3454
|
-
|
|
4967
|
+
// T34 rule applied to the stream route): the write-shaped STREAMS of the
|
|
4968
|
+
// table — `terminal/follow`, and its 0.2.2 duplex successor
|
|
4969
|
+
// `terminal/attach` (REMOTE_UPLINK_STREAM_METHODS) — take over the
|
|
4970
|
+
// terminal's input control, so opening them into a dead link must refuse
|
|
4971
|
+
// here, exactly like every other mutation. MS-3: the TARGET leg's state
|
|
4972
|
+
// is the one that counts.
|
|
4973
|
+
if (isRemoteWrite(endpoint) && verdict.client.state !== 'online') {
|
|
4974
|
+
releaseUplink(uplink)
|
|
4975
|
+
throwRecorded(endpoint, 'remote-offline', copy().writeOffline)
|
|
3455
4976
|
}
|
|
3456
4977
|
// The multiplexed wire channel ALWAYS hands us an UplinkInbox here —
|
|
3457
|
-
// never undefined.
|
|
3458
|
-
// is half-closed and the stream forwarded (the
|
|
3459
|
-
// `$events` subscription, gateway releaseUplink).
|
|
3460
|
-
//
|
|
3461
|
-
|
|
4978
|
+
// never undefined. One-directional remote streams consume no uplink
|
|
4979
|
+
// items, so the uplink is half-closed and the stream forwarded (the
|
|
4980
|
+
// host's own move for its `$events` subscription, gateway releaseUplink).
|
|
4981
|
+
// TA-2: a DUPLEX member of REMOTE_UPLINK_STREAM_METHODS keeps its inbox —
|
|
4982
|
+
// the host's TerminalInput write/resize commands ride it, so the iterable
|
|
4983
|
+
// and the mux's uplink window (shape-checked, garbage dropped) travel to
|
|
4984
|
+
// the relay client's pump instead; the pump releases the iterable itself
|
|
4985
|
+
// when the stream ends. Rejecting uplinks would reject every remote
|
|
4986
|
+
// stream the UI opens.
|
|
4987
|
+
const forwardsUplink = REMOTE_UPLINK_STREAM_METHODS.has(endpoint) && isAsyncIterable(uplink)
|
|
4988
|
+
if (!forwardsUplink) releaseUplink(uplink)
|
|
4989
|
+
const normalizedWindow = forwardsUplink ? normalizeUplinkWindow(uplinkWindow) : undefined
|
|
3462
4990
|
const serverId = verdict.serverId
|
|
4991
|
+
const client = verdict.client
|
|
3463
4992
|
const slash = endpoint.indexOf('/')
|
|
3464
4993
|
const namespace = slash === -1 ? endpoint : endpoint.slice(0, slash)
|
|
3465
4994
|
const method = slash === -1 ? '' : endpoint.slice(slash + 1)
|
|
@@ -3475,8 +5004,22 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3475
5004
|
// through the outage and reopens the leg when the relay serves again
|
|
3476
5005
|
// (CP4); every other endpoint's first read fails out with the original
|
|
3477
5006
|
// code (CP5 scope, the pre-CP4 behavior).
|
|
3478
|
-
upstream =
|
|
5007
|
+
upstream = forwardsUplink
|
|
5008
|
+
? client.openStream(
|
|
5009
|
+
namespace,
|
|
5010
|
+
method,
|
|
5011
|
+
clone,
|
|
5012
|
+
signal,
|
|
5013
|
+
{
|
|
5014
|
+
uplink: uplink as AsyncIterable<unknown>,
|
|
5015
|
+
...(normalizedWindow !== undefined ? { uplinkWindow: normalizedWindow } : {}),
|
|
5016
|
+
},
|
|
5017
|
+
)
|
|
5018
|
+
: client.openStream(namespace, method, clone, signal)
|
|
3479
5019
|
} catch (error) {
|
|
5020
|
+
// TA-2: a synchronous openStream failure (an unpaired relay) strands
|
|
5021
|
+
// the forwarded inbox — the pump never started, so half-close it here.
|
|
5022
|
+
if (forwardsUplink) releaseUplink(uplink)
|
|
3480
5023
|
const code = error instanceof RelayError ? error.code : 'internal'
|
|
3481
5024
|
throwRecorded(endpoint, code, messageOf(error))
|
|
3482
5025
|
}
|
|
@@ -3489,9 +5032,28 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3489
5032
|
// the endpoints OUTSIDE the list end with the original code at once,
|
|
3490
5033
|
// their consumers rendering a clean end as a terminal (CP5,
|
|
3491
5034
|
// SESSION_STREAM_HOLD_ENDPOINTS).
|
|
3492
|
-
|
|
3493
|
-
|
|
5035
|
+
const held = rewriteUpstream(endpoint, client, upstream, serverId, virtuals, signal, () =>
|
|
5036
|
+
client.openStream(namespace, method, clone, signal),
|
|
3494
5037
|
)
|
|
5038
|
+
// P55-2: a remote session/follow is recorded the instant the wrap is
|
|
5039
|
+
// reached (before any frame, before any browser report) — the first
|
|
5040
|
+
// <img> of the page cannot race a 1s poll. The follow stays in
|
|
5041
|
+
// `activeRemoteFollows` for the generator's whole life and leaves in its
|
|
5042
|
+
// finally, so the map holds exactly the LIVE follows.
|
|
5043
|
+
if (endpoint === 'session/follow') {
|
|
5044
|
+
const claimed = virtuals[0]
|
|
5045
|
+
lastFollowedSession = { kind: 'remote', serverId: claimed.serverId, sessionId: claimed.id }
|
|
5046
|
+
const token = Symbol()
|
|
5047
|
+
activeRemoteFollows.set(token, { serverId: claimed.serverId, sessionId: claimed.id })
|
|
5048
|
+
return (async function* () {
|
|
5049
|
+
try {
|
|
5050
|
+
yield* held
|
|
5051
|
+
} finally {
|
|
5052
|
+
activeRemoteFollows.delete(token)
|
|
5053
|
+
}
|
|
5054
|
+
})()
|
|
5055
|
+
}
|
|
5056
|
+
return held
|
|
3495
5057
|
}
|
|
3496
5058
|
|
|
3497
5059
|
|
|
@@ -3505,11 +5067,22 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3505
5067
|
uninstall() {
|
|
3506
5068
|
if (!installed) return
|
|
3507
5069
|
installed = false
|
|
5070
|
+
// P55-2: the attribution state dies with the wrap — the fetch-route
|
|
5071
|
+
// half reads the wiring's live `interceptHandle` slot, which the
|
|
5072
|
+
// uninstall clears, so this is hygiene; a stale map must never
|
|
5073
|
+
// outlive the interception that fed it.
|
|
5074
|
+
remoteSessionCwds.clear()
|
|
5075
|
+
activeRemoteFollows.clear()
|
|
5076
|
+
lastFollowedSession = undefined
|
|
3508
5077
|
for (const listener of [...uninstallListeners]) listener()
|
|
3509
5078
|
// The catalog watcher dies with the wrap (T52-fix2): a late online
|
|
3510
|
-
// transition after this point belongs to another generation.
|
|
5079
|
+
// transition after this point belongs to another generation. MS-5: in
|
|
5080
|
+
// pool mode that includes every per-entry client subscription the
|
|
5081
|
+
// rescan created — the rescan's own unsubscribe is not enough.
|
|
3511
5082
|
offCatalogWatch?.()
|
|
3512
5083
|
offCatalogWatch = undefined
|
|
5084
|
+
for (const unsubscribe of catalogWatched.values()) unsubscribe()
|
|
5085
|
+
catalogWatched.clear()
|
|
3513
5086
|
for (const name of ['openWireStream', 'dispatchRpc']) {
|
|
3514
5087
|
// Only replace/delete while the property still holds OUR wrapper.
|
|
3515
5088
|
// A later plugin may have wrapped OVER us: its wrapper captured this
|
|
@@ -3544,6 +5117,13 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3544
5117
|
const virtualIds = ids.filter((id) => typeof id === 'string' && id !== '' && isVirtual(id))
|
|
3545
5118
|
if (virtualIds.length > 0) summarySync.noteForwardedSessionIds(virtualIds)
|
|
3546
5119
|
},
|
|
5120
|
+
notePoolEvent() {
|
|
5121
|
+
// MS-3: broadcast to every pool watcher — the live merged streams'
|
|
5122
|
+
// re-scans and the pool-mode catalog watcher (already registered as
|
|
5123
|
+
// one of the listeners in pool mode). No-op in single-server mode.
|
|
5124
|
+
for (const listener of [...poolListeners]) listener()
|
|
5125
|
+
},
|
|
5126
|
+
resolveRemoteFileScope,
|
|
3547
5127
|
}
|
|
3548
5128
|
}
|
|
3549
5129
|
|