mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
fix(ssh): bound PTY model admission
This commit is contained in:
@@ -9,6 +9,7 @@ export type PendingPtyData = {
|
||||
droppedOutput?: true
|
||||
droppedMode2031Data?: string
|
||||
droppedMode2031ScanState?: Mode2031ReplyScanState
|
||||
projectionAdmissionIds?: readonly string[]
|
||||
}
|
||||
|
||||
export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked'
|
||||
|
||||
+298
-129
@@ -158,6 +158,9 @@ import {
|
||||
unmarkHiddenRendererPty
|
||||
} from './pty-hidden-delivery-gate'
|
||||
import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue'
|
||||
import { SshPtyOutputIntake } from './ssh-pty-output-intake'
|
||||
import { installSshPtyOutputIntake } from './ssh-pty-output-intake-registry'
|
||||
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
|
||||
import {
|
||||
clearNativeWindowsConptyPty,
|
||||
isNativeWindowsLocalPtySpawn,
|
||||
@@ -1467,6 +1470,7 @@ let rendererDidStartLoadingHandler: (() => void) | null = null
|
||||
|
||||
// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops.
|
||||
let rebindProviderListeners: (() => void) | null = null
|
||||
let sshOutputIntakeCleanup: (() => void) | null = null
|
||||
|
||||
export function rebindLocalProviderListeners(): void {
|
||||
rebindProviderListeners?.()
|
||||
@@ -1835,6 +1839,7 @@ export function registerPtyHandlers(
|
||||
},
|
||||
() => isHiddenPtyDeliveryGateEnabled(getSettings?.())
|
||||
)
|
||||
let sshOutputIntake: SshPtyOutputIntake | null = null
|
||||
// Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit.
|
||||
const rendererExitingPtyIds = new Set<string>()
|
||||
const rendererDeliveryRestoreNeededPtys = new Set<string>()
|
||||
@@ -1995,6 +2000,14 @@ export function registerPtyHandlers(
|
||||
}
|
||||
|
||||
function clearPendingPtyData(): void {
|
||||
for (const pending of pendingData.values()) {
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(
|
||||
pending.projectionAdmissionIds,
|
||||
'renderer-lifecycle-reset'
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingData.clear()
|
||||
}
|
||||
|
||||
@@ -2260,6 +2273,9 @@ export function registerPtyHandlers(
|
||||
accounting.lastAckAtMs = Date.now()
|
||||
}
|
||||
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged)
|
||||
if (acknowledged > 0) {
|
||||
sshOutputIntake?.settleProjectionPrefix(id, acknowledged)
|
||||
}
|
||||
return acknowledged
|
||||
}
|
||||
|
||||
@@ -2331,6 +2347,12 @@ export function registerPtyHandlers(
|
||||
// Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore.
|
||||
const pending = pendingData.get(id)
|
||||
if (pending) {
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(
|
||||
pending.projectionAdmissionIds,
|
||||
'renderer-delivery-writeoff'
|
||||
)
|
||||
}
|
||||
pendingDroppedChars += pending.data.length
|
||||
deletePendingPtyData(id)
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
@@ -2360,7 +2382,11 @@ export function registerPtyHandlers(
|
||||
return writtenOff
|
||||
}
|
||||
|
||||
function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): boolean {
|
||||
function sendPtyDataToRenderer(
|
||||
id: string,
|
||||
payload: PtyDataPayload,
|
||||
projectionAdmissionIds?: readonly string[]
|
||||
): boolean {
|
||||
const charCount = getPtyPayloadCharCount(payload)
|
||||
const accounting = rendererDeliveryAccountingByPty.get(id)
|
||||
const hadAccounting = accounting !== undefined
|
||||
@@ -2395,6 +2421,9 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
rendererDeliveryRestoreNeededPtys.add(id)
|
||||
if (projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed')
|
||||
}
|
||||
mainDeliveryBreadcrumbs.record('pty-data-send-failed', {
|
||||
id: redactPtyIdForDiagnostics(id),
|
||||
chars: charCount
|
||||
@@ -2402,6 +2431,17 @@ export function registerPtyHandlers(
|
||||
console.error('[pty] renderer data send failed; payload will not be retried', error)
|
||||
return false
|
||||
}
|
||||
if (projectionAdmissionIds) {
|
||||
try {
|
||||
sshOutputIntake?.publishProjectionPrefix(
|
||||
projectionAdmissionIds,
|
||||
payload.data.length,
|
||||
charCount
|
||||
)
|
||||
} catch {
|
||||
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'projection-publish-failed')
|
||||
}
|
||||
}
|
||||
if (rendererDeliveryRestoreNeededPtys.has(id)) {
|
||||
try {
|
||||
sendModelRestoreNeededMarker(id, 'delivery-heal', runtime?.getPtyOutputSequence(id))
|
||||
@@ -2516,6 +2556,9 @@ export function registerPtyHandlers(
|
||||
pendingOverflowMarkedPtys.add(id)
|
||||
}
|
||||
pendingDroppedChars += pending.data.length
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap')
|
||||
}
|
||||
const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE)
|
||||
// Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along).
|
||||
return {
|
||||
@@ -2534,10 +2577,14 @@ export function registerPtyHandlers(
|
||||
preservesSeq: boolean,
|
||||
containsBackgroundOutput: boolean,
|
||||
rawLength = data.length,
|
||||
transformed = false
|
||||
transformed = false,
|
||||
projectionSemanticsId?: string
|
||||
): PendingPtyData {
|
||||
// Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode.
|
||||
if (existing?.droppedOutput === true) {
|
||||
if (projectionSemanticsId) {
|
||||
sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap')
|
||||
}
|
||||
const mode2031 = scanDroppedMode2031Data(
|
||||
data,
|
||||
existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
@@ -2562,7 +2609,8 @@ export function registerPtyHandlers(
|
||||
...(typeof startSeq === 'number' ? { startSeq } : {}),
|
||||
...(rawLength !== data.length ? { rawLength } : {}),
|
||||
...(transformed ? { transformed: true } : {}),
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}),
|
||||
...(projectionSemanticsId ? { projectionAdmissionIds: [projectionSemanticsId] } : {})
|
||||
})
|
||||
}
|
||||
const existingRawLength = existing.rawLength ?? existing.data.length
|
||||
@@ -2571,7 +2619,15 @@ export function registerPtyHandlers(
|
||||
...(!preservesSeq || existing.transformed || transformed
|
||||
? { rawLength: existingRawLength + rawLength, transformed: true as const }
|
||||
: {}),
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}),
|
||||
...((existing.projectionAdmissionIds?.length ?? 0) > 0 || projectionSemanticsId
|
||||
? {
|
||||
projectionAdmissionIds: [
|
||||
...(existing.projectionAdmissionIds ?? []),
|
||||
...(projectionSemanticsId ? [projectionSemanticsId] : [])
|
||||
]
|
||||
}
|
||||
: {})
|
||||
}
|
||||
if (typeof existing.startSeq === 'number') {
|
||||
next.startSeq = existing.startSeq
|
||||
@@ -2656,6 +2712,9 @@ export function registerPtyHandlers(
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
updateProducerFlowControl(id)
|
||||
const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length)
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
|
||||
}
|
||||
warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id))
|
||||
@@ -2671,11 +2730,15 @@ export function registerPtyHandlers(
|
||||
updateProducerFlowControl(id)
|
||||
// Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only).
|
||||
if (
|
||||
!sendPtyDataToRenderer(id, {
|
||||
!sendPtyDataToRenderer(
|
||||
id,
|
||||
data: pending.data + getDroppedMode2031RendererData(pending),
|
||||
droppedOutput: true
|
||||
})
|
||||
{
|
||||
id,
|
||||
data: pending.data + getDroppedMode2031RendererData(pending),
|
||||
droppedOutput: true
|
||||
},
|
||||
pending.projectionAdmissionIds
|
||||
)
|
||||
) {
|
||||
sendFailed = true
|
||||
break
|
||||
@@ -2695,6 +2758,9 @@ export function registerPtyHandlers(
|
||||
if (pending.containsBackgroundOutput === true) {
|
||||
nextPending.containsBackgroundOutput = true
|
||||
}
|
||||
if (pending.projectionAdmissionIds) {
|
||||
nextPending.projectionAdmissionIds = pending.projectionAdmissionIds
|
||||
}
|
||||
pendingData.replaceWithRemainder(selection, nextPending)
|
||||
} else {
|
||||
pendingData.remove(selection)
|
||||
@@ -2711,7 +2777,8 @@ export function registerPtyHandlers(
|
||||
pending.containsBackgroundOutput,
|
||||
pending.rawLength,
|
||||
pending.transformed
|
||||
)
|
||||
),
|
||||
pending.projectionAdmissionIds
|
||||
)
|
||||
) {
|
||||
sendFailed = true
|
||||
@@ -2792,11 +2859,15 @@ export function registerPtyHandlers(
|
||||
if (remaining) {
|
||||
if (remaining.droppedOutput === true) {
|
||||
// Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped.
|
||||
sendPtyDataToRenderer(payload.id, {
|
||||
id: payload.id,
|
||||
data: remaining.data,
|
||||
droppedOutput: true
|
||||
})
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
{
|
||||
id: payload.id,
|
||||
data: remaining.data,
|
||||
droppedOutput: true
|
||||
},
|
||||
remaining.projectionAdmissionIds
|
||||
)
|
||||
} else {
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
@@ -2807,10 +2878,12 @@ export function registerPtyHandlers(
|
||||
remaining.containsBackgroundOutput,
|
||||
remaining.rawLength,
|
||||
remaining.transformed
|
||||
)
|
||||
),
|
||||
remaining.projectionAdmissionIds
|
||||
)
|
||||
}
|
||||
}
|
||||
sshOutputIntake?.transferPtyProjections(payload.id, 'pty-exit')
|
||||
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
|
||||
producerFlowControl.release(payload.id)
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
@@ -2845,6 +2918,212 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
function acceptPtyDataForRenderer(
|
||||
payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
},
|
||||
outputSeq: number | undefined,
|
||||
projection?: LegacySshProjectionSemantics
|
||||
): void {
|
||||
const rawLength = payload.sequenceChars ?? payload.data.length
|
||||
const preservesSeq = !payload.transformed && rawLength === payload.data.length
|
||||
const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
|
||||
const projectionId = projection?.identity.projectionSemanticsId
|
||||
if (mainWindow.isDestroyed()) {
|
||||
if (projectionId) {
|
||||
sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed')
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
producerFlowControl.releaseAll()
|
||||
clearDeliveryResyncProbe()
|
||||
clearPendingPtyData()
|
||||
pendingOverflowMarkedPtys.clear()
|
||||
rendererDeliveryAccountingByPty.clear()
|
||||
rendererInFlightTotalChars = 0
|
||||
clearDispatcherReadyWatchdog()
|
||||
return
|
||||
}
|
||||
if (rendererExitingPtyIds.has(payload.id)) {
|
||||
if (projectionId) {
|
||||
sshOutputIntake?.transferProjections([projectionId], 'pty-exiting')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) {
|
||||
if (projectionId) {
|
||||
sshOutputIntake?.transferProjections([projectionId], 'hidden-drop')
|
||||
}
|
||||
const droppedChars = projection ? rawLength : payload.data.length
|
||||
const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars)
|
||||
warnIfDroppingHiddenBytesForVisiblePty(payload.id, droppedChars)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (payload.data.length === 0 && !payload.transformed) {
|
||||
if (projectionId) {
|
||||
sshOutputIntake?.transferProjections([projectionId], 'empty-projection')
|
||||
}
|
||||
return
|
||||
}
|
||||
const containsBackgroundOutput =
|
||||
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
|
||||
if (containsBackgroundOutput) {
|
||||
markHiddenRendererResizeOutputDelivered(payload.id)
|
||||
}
|
||||
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
|
||||
const pending = appendPendingPtyData(
|
||||
payload.id,
|
||||
pendingData.get(payload.id),
|
||||
payload.data,
|
||||
startSeq,
|
||||
preservesSeq,
|
||||
containsBackgroundOutput,
|
||||
rawLength,
|
||||
payload.transformed === true,
|
||||
projectionId
|
||||
)
|
||||
const shouldEmitPendingCapRestoreMarker =
|
||||
pending.droppedOutput === true &&
|
||||
!overflowMarkedBeforeAppend &&
|
||||
pendingOverflowMarkedPtys.has(payload.id)
|
||||
const nextData = pending.data + getDroppedMode2031RendererData(pending)
|
||||
const isInteractiveOutput = shouldSendInteractiveOutputNow(
|
||||
payload.id,
|
||||
nextData,
|
||||
performance.now()
|
||||
)
|
||||
if (isInteractiveOutput && rendererPtyDispatcherReady) {
|
||||
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
|
||||
setPendingPtyData(payload.id, pending)
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
updateProducerFlowControl(payload.id)
|
||||
requestDeliveryResyncForGatedPty()
|
||||
return
|
||||
}
|
||||
deletePendingPtyData(payload.id)
|
||||
clearFlushTimerIfIdle()
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
try {
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
{
|
||||
id: payload.id,
|
||||
data: nextData,
|
||||
...(typeof pending.startSeq === 'number'
|
||||
? {
|
||||
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
|
||||
rawLength: pending.rawLength ?? nextData.length
|
||||
}
|
||||
: {}),
|
||||
...(pending.transformed ? { transformed: true } : {}),
|
||||
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
|
||||
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
|
||||
},
|
||||
pending.projectionAdmissionIds
|
||||
)
|
||||
} finally {
|
||||
updateProducerFlowControl(payload.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
setPendingPtyData(payload.id, pending)
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
updateProducerFlowControl(payload.id)
|
||||
if (
|
||||
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
|
||||
) {
|
||||
requestDeliveryResyncForGatedPty()
|
||||
}
|
||||
if (!flushTimer) {
|
||||
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
sshOutputIntakeCleanup?.()
|
||||
sshOutputIntake = new SshPtyOutputIntake({
|
||||
getModelSequence: (id) => runtime?.getPtyOutputSequence(id) ?? 0,
|
||||
acceptModel: (event) => {
|
||||
if (!runtime) {
|
||||
throw new Error('SSH PTY output requires the main terminal model')
|
||||
}
|
||||
return runtime.acceptPtyDataBounded(
|
||||
event.id,
|
||||
event.data,
|
||||
Date.now(),
|
||||
event.rawLength,
|
||||
event.transformed
|
||||
)
|
||||
},
|
||||
project: (event, projection) =>
|
||||
acceptPtyDataForRenderer(
|
||||
{
|
||||
id: event.id,
|
||||
data: event.data,
|
||||
sequenceChars: event.rawLength,
|
||||
transformed: event.transformed
|
||||
},
|
||||
projection.identity.sequenceEnd,
|
||||
projection
|
||||
),
|
||||
finalizeExit: (event) => {
|
||||
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation)
|
||||
sendPtyExitToRenderer(event)
|
||||
},
|
||||
pauseProvider: (_generation, id) => {
|
||||
const provider = tryGetProviderForPty(id) as
|
||||
| (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean })
|
||||
| undefined
|
||||
if (!provider?.hasPtyDeliveryPauseAdapter?.()) {
|
||||
return false
|
||||
}
|
||||
provider.pauseProducer?.(id)
|
||||
return true
|
||||
},
|
||||
resumeProvider: (_generation, id) => tryGetProviderForPty(id)?.resumeProducer?.(id),
|
||||
closeProvider: (generation, reason) => {
|
||||
for (const provider of sshProviders.values()) {
|
||||
if ((provider as { providerGeneration?: number }).providerGeneration === generation) {
|
||||
;(
|
||||
provider as IPtyProvider & { closeOutputIntake?: (reason: string) => void }
|
||||
).closeOutputIntake?.(reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
onGenerationClosed: (providerGeneration) => {
|
||||
for (const id of pendingData.keys()) {
|
||||
const pending = pendingData.get(id)
|
||||
if (
|
||||
pending?.projectionAdmissionIds &&
|
||||
sshOutputIntake?.hasProjectionFromGeneration(
|
||||
pending.projectionAdmissionIds,
|
||||
providerGeneration
|
||||
)
|
||||
) {
|
||||
pendingData.delete(id)
|
||||
updateProducerFlowControl(id)
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
sshOutputIntakeCleanup = installSshPtyOutputIntake(sshOutputIntake)
|
||||
|
||||
async function shutdownProviderAndDetectExit(
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
@@ -2928,120 +3207,7 @@ export function registerPtyHandlers(
|
||||
const outputSeq = isLocalProvider
|
||||
? runtime?.getPtyOutputSequence(payload.id)
|
||||
: runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed)
|
||||
const rendererData = payload.data
|
||||
const preservesSeq = !payload.transformed && rawLength === payload.data.length
|
||||
const startSeq =
|
||||
typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
|
||||
if (mainWindow.isDestroyed()) {
|
||||
// Why clear the flush timer: macOS app re-activation otherwise leaks orphaned timers from the previous window's registration.
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
producerFlowControl.releaseAll()
|
||||
clearDeliveryResyncProbe()
|
||||
clearPendingPtyData()
|
||||
pendingOverflowMarkedPtys.clear()
|
||||
rendererDeliveryAccountingByPty.clear()
|
||||
rendererInFlightTotalChars = 0
|
||||
clearDispatcherReadyWatchdog()
|
||||
return
|
||||
}
|
||||
if (rendererExitingPtyIds.size > 0 && rendererExitingPtyIds.has(payload.id)) {
|
||||
return
|
||||
}
|
||||
const settings = getSettings?.()
|
||||
// Why drop before the interactive bypass: runtime already ingested the chunk, so gated PTYs skip both renderer paths and reveal restores from the snapshot.
|
||||
if (shouldDropHiddenRendererPtyData(payload.id, settings)) {
|
||||
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
|
||||
warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (rendererData.length === 0 && !payload.transformed) {
|
||||
return
|
||||
}
|
||||
const containsBackgroundOutput =
|
||||
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
|
||||
if (containsBackgroundOutput) {
|
||||
markHiddenRendererResizeOutputDelivered(payload.id)
|
||||
}
|
||||
const existing = pendingData.get(payload.id)
|
||||
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
|
||||
const pending = appendPendingPtyData(
|
||||
payload.id,
|
||||
existing,
|
||||
rendererData,
|
||||
startSeq,
|
||||
preservesSeq,
|
||||
containsBackgroundOutput,
|
||||
rawLength,
|
||||
payload.transformed === true
|
||||
)
|
||||
const shouldEmitPendingCapRestoreMarker =
|
||||
pending.droppedOutput === true &&
|
||||
!overflowMarkedBeforeAppend &&
|
||||
pendingOverflowMarkedPtys.has(payload.id)
|
||||
const nextData = pending.data + getDroppedMode2031RendererData(pending)
|
||||
const isInteractiveOutput = shouldSendInteractiveOutputNow(
|
||||
payload.id,
|
||||
nextData,
|
||||
performance.now()
|
||||
)
|
||||
// Why gate the fast path on the handshake too: else boot-window keystroke echo is sent into a listener-less page and pins the gate.
|
||||
if (isInteractiveOutput && rendererPtyDispatcherReady) {
|
||||
// Why the reserve: keep input echo from being pinned behind unrelated bulk output; it's bounded and the per-PTY cap still prevents an active TUI runaway.
|
||||
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
|
||||
setPendingPtyData(payload.id, pending)
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
updateProducerFlowControl(payload.id)
|
||||
requestDeliveryResyncForGatedPty()
|
||||
return
|
||||
}
|
||||
deletePendingPtyData(payload.id)
|
||||
clearFlushTimerIfIdle()
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
// Why immediate: agent TUIs redraw small prompt regions per keystroke; the throughput batch timer would add visible input latency.
|
||||
try {
|
||||
sendPtyDataToRenderer(payload.id, {
|
||||
id: payload.id,
|
||||
data: nextData,
|
||||
...(typeof pending.startSeq === 'number'
|
||||
? {
|
||||
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
|
||||
rawLength: pending.rawLength ?? nextData.length
|
||||
}
|
||||
: {}),
|
||||
...(pending.transformed ? { transformed: true } : {}),
|
||||
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
|
||||
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
|
||||
})
|
||||
} finally {
|
||||
updateProducerFlowControl(payload.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
setPendingPtyData(payload.id, pending)
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
updateProducerFlowControl(payload.id)
|
||||
// Why probe on data arrival (not flush skips): new output for a fully gated PTY is the moment stuck delivery becomes observable.
|
||||
if (
|
||||
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
|
||||
) {
|
||||
requestDeliveryResyncForGatedPty()
|
||||
}
|
||||
if (!flushTimer) {
|
||||
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
|
||||
}
|
||||
acceptPtyDataForRenderer(payload, outputSeq)
|
||||
})
|
||||
localExitUnsub = localProvider.onExit((payload) => {
|
||||
if (!isCurrentPtyExit(payload)) {
|
||||
@@ -5623,6 +5789,9 @@ export function registerPtyHandlers(
|
||||
const pending = pendingData.get(args.id)
|
||||
if (pending && transition.droppable) {
|
||||
pendingData.delete(args.id)
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
|
||||
}
|
||||
updateProducerFlowControl(args.id)
|
||||
pendingOverflowMarkedPtys.delete(args.id)
|
||||
const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
Mode2031ReplyDecision,
|
||||
Mode2031ReplyScanState
|
||||
} from '../../shared/terminal-color-scheme-protocol'
|
||||
import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../shared/terminal-color-scheme-protocol'
|
||||
|
||||
export type LegacySshProjectionIdentity = Readonly<{
|
||||
projectionSemanticsId: string
|
||||
ptyId: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
displayStart: number
|
||||
displayEnd: number
|
||||
sequenceEnd: number
|
||||
rawLength: number
|
||||
transformed: boolean
|
||||
}>
|
||||
|
||||
export type LegacySshProjectionSemantics = Readonly<{
|
||||
identity: LegacySshProjectionIdentity
|
||||
beforeScanner: Readonly<Mode2031ReplyScanState>
|
||||
afterScanner: Readonly<Mode2031ReplyScanState>
|
||||
decision: Mode2031ReplyDecision
|
||||
}>
|
||||
|
||||
export type ProjectionState = 'reserved' | 'committed' | 'published'
|
||||
|
||||
export type ProjectionRecord = {
|
||||
semantics: LegacySshProjectionSemantics
|
||||
state: ProjectionState
|
||||
publishedDisplay: number
|
||||
publishedAccounting: number
|
||||
settledAccounting: number
|
||||
}
|
||||
|
||||
export type PtyProjectionCursor = {
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
displayEnd: number
|
||||
scanner: Mode2031ReplyScanState
|
||||
}
|
||||
|
||||
export type LegacySshProjectionReservation = Readonly<{
|
||||
semantics: LegacySshProjectionSemantics
|
||||
}>
|
||||
|
||||
export type LegacySshProjectionDebugSnapshot = {
|
||||
reserved: number
|
||||
committed: number
|
||||
published: number
|
||||
settled: number
|
||||
transferred: number
|
||||
rolledBack: number
|
||||
records: number
|
||||
cursors: number
|
||||
}
|
||||
|
||||
export function scannerSnapshot(state: Mode2031ReplyScanState): Readonly<Mode2031ReplyScanState> {
|
||||
return Object.freeze({ tail: state.tail, pendingSubscribe: state.pendingSubscribe })
|
||||
}
|
||||
|
||||
export function projectionError(code: string): Error {
|
||||
return Object.assign(new Error(code), { code })
|
||||
}
|
||||
|
||||
export function projectionDebugSnapshot(
|
||||
records: ReadonlyMap<string, ProjectionRecord>,
|
||||
cursors: ReadonlyMap<string, PtyProjectionCursor>,
|
||||
terminalCounts: { settled: number; transferred: number; rolledBack: number }
|
||||
): LegacySshProjectionDebugSnapshot {
|
||||
const result = {
|
||||
reserved: 0,
|
||||
committed: 0,
|
||||
published: 0,
|
||||
...terminalCounts,
|
||||
records: records.size,
|
||||
cursors: cursors.size
|
||||
}
|
||||
for (const record of records.values()) {
|
||||
result[record.state]++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function reclaimProjectionRecord(
|
||||
records: Map<string, ProjectionRecord>,
|
||||
idsByPty: Map<string, string[]>,
|
||||
id: string,
|
||||
ptyId: string
|
||||
): void {
|
||||
records.delete(id)
|
||||
const ids = idsByPty.get(ptyId)
|
||||
if (!ids) {
|
||||
return
|
||||
}
|
||||
const index = ids.indexOf(id)
|
||||
if (index >= 0) {
|
||||
ids.splice(index, 1)
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
idsByPty.delete(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
export function requireProjectionRecord(
|
||||
records: ReadonlyMap<string, ProjectionRecord>,
|
||||
id: string
|
||||
): ProjectionRecord {
|
||||
const record = records.get(id)
|
||||
if (!record) {
|
||||
throw projectionError('ssh_projection_reservation_missing')
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
export function rollbackUnpublishedProjection(
|
||||
records: Map<string, ProjectionRecord>,
|
||||
cursors: ReadonlyMap<string, PtyProjectionCursor>,
|
||||
idsByPty: Map<string, string[]>,
|
||||
id: string
|
||||
): boolean {
|
||||
const record = records.get(id)
|
||||
if (!record || record.state !== 'committed') {
|
||||
return false
|
||||
}
|
||||
const { identity, beforeScanner } = record.semantics
|
||||
const cursor = cursors.get(identity.ptyId)
|
||||
if (
|
||||
!cursor ||
|
||||
cursor.providerGeneration !== identity.providerGeneration ||
|
||||
cursor.ptyIncarnation !== identity.ptyIncarnation ||
|
||||
cursor.displayEnd !== identity.displayEnd
|
||||
) {
|
||||
return false
|
||||
}
|
||||
cursor.displayEnd = identity.displayStart
|
||||
cursor.scanner = { ...beforeScanner }
|
||||
reclaimProjectionRecord(records, idsByPty, id, identity.ptyId)
|
||||
return true
|
||||
}
|
||||
|
||||
export function closeProjectionPty(
|
||||
cursors: Map<string, PtyProjectionCursor>,
|
||||
ptyId: string,
|
||||
providerGeneration: number,
|
||||
ptyIncarnation: string,
|
||||
beforeDelete: () => void
|
||||
): void {
|
||||
const cursor = cursors.get(ptyId)
|
||||
if (
|
||||
!cursor ||
|
||||
cursor.providerGeneration !== providerGeneration ||
|
||||
cursor.ptyIncarnation !== ptyIncarnation
|
||||
) {
|
||||
return
|
||||
}
|
||||
beforeDelete()
|
||||
cursors.delete(ptyId)
|
||||
}
|
||||
|
||||
export function getOrCreateProjectionCursor(
|
||||
cursors: Map<string, PtyProjectionCursor>,
|
||||
args: { ptyId: string; providerGeneration: number; ptyIncarnation: string },
|
||||
replaceGeneration: (providerGeneration: number) => void
|
||||
): PtyProjectionCursor {
|
||||
const existing = cursors.get(args.ptyId)
|
||||
if (!existing) {
|
||||
const cursor = {
|
||||
providerGeneration: args.providerGeneration,
|
||||
ptyIncarnation: args.ptyIncarnation,
|
||||
displayEnd: 0,
|
||||
scanner: { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
|
||||
}
|
||||
cursors.set(args.ptyId, cursor)
|
||||
return cursor
|
||||
}
|
||||
if (args.providerGeneration < existing.providerGeneration) {
|
||||
throw projectionError('ssh_projection_stale_generation')
|
||||
}
|
||||
if (
|
||||
args.providerGeneration === existing.providerGeneration &&
|
||||
args.ptyIncarnation !== existing.ptyIncarnation
|
||||
) {
|
||||
throw projectionError('ssh_projection_stale_incarnation')
|
||||
}
|
||||
if (args.providerGeneration > existing.providerGeneration) {
|
||||
replaceGeneration(existing.providerGeneration)
|
||||
existing.providerGeneration = args.providerGeneration
|
||||
existing.ptyIncarnation = args.ptyIncarnation
|
||||
existing.scanner = { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
|
||||
}
|
||||
return existing
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SshPtyLegacyProjectionLedger } from './ssh-pty-legacy-projection'
|
||||
|
||||
function reserve(
|
||||
ledger: SshPtyLegacyProjectionLedger,
|
||||
overrides: Partial<Parameters<SshPtyLegacyProjectionLedger['reserve']>[0]> = {}
|
||||
) {
|
||||
return ledger.reserve({
|
||||
ptyId: 'pty-1',
|
||||
providerGeneration: 3,
|
||||
ptyIncarnation: 'incarnation-1',
|
||||
data: 'abc',
|
||||
sequenceEnd: 3,
|
||||
rawLength: 3,
|
||||
transformed: false,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('SshPtyLegacyProjectionLedger', () => {
|
||||
it('rolls back scanner and display reservations before commit', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const partial = reserve(ledger, { data: '\x1b[?20', rawLength: 5, sequenceEnd: 5 })
|
||||
expect(ledger.rollback(partial)).toBe(true)
|
||||
|
||||
const next = reserve(ledger, { data: '31h', sequenceEnd: 3 })
|
||||
expect(next.semantics.identity.displayStart).toBe(0)
|
||||
expect(next.semantics.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
|
||||
expect(next.semantics.decision).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps immutable generation, incarnation, display, sequence, raw length, and scanner facts', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const first = reserve(ledger, {
|
||||
data: '\x1b[?2031h',
|
||||
rawLength: 11,
|
||||
sequenceEnd: 11
|
||||
})
|
||||
const semantics = ledger.commit(first)
|
||||
|
||||
expect(semantics.identity).toMatchObject({
|
||||
providerGeneration: 3,
|
||||
ptyIncarnation: 'incarnation-1',
|
||||
displayStart: 0,
|
||||
displayEnd: 8,
|
||||
sequenceEnd: 11,
|
||||
rawLength: 11
|
||||
})
|
||||
expect(semantics.decision).toBe('subscribed')
|
||||
expect(Object.isFrozen(semantics)).toBe(true)
|
||||
expect(Object.isFrozen(semantics.identity)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects stale generations and resets cross-chunk scanner state on gaps', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
ledger.commit(reserve(ledger, { data: '\x1b[?20', rawLength: 5, sequenceEnd: 5 }))
|
||||
ledger.resetForGap('pty-1')
|
||||
const next = reserve(ledger, { data: '31h', sequenceEnd: 8 })
|
||||
expect(next.semantics.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
|
||||
|
||||
expect(() => reserve(ledger, { providerGeneration: 2 })).toThrow(
|
||||
'ssh_projection_stale_generation'
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes, settles, and transfers explicit ranges', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const first = ledger.commit(reserve(ledger))
|
||||
ledger.publishPrefix([first.identity.projectionSemanticsId], 3, 3)
|
||||
expect(ledger.settlePublishedPrefix('pty-1', 2)).toBe(2)
|
||||
expect(ledger.transfer([first.identity.projectionSemanticsId], 'renderer-reload')).toBe(1)
|
||||
expect(ledger.getDebugSnapshot()).toMatchObject({ transferred: 1, records: 0 })
|
||||
})
|
||||
|
||||
it('publishes and settles transformed source accounting with no display text', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const projection = ledger.commit(
|
||||
reserve(ledger, {
|
||||
data: '',
|
||||
sequenceEnd: 9,
|
||||
rawLength: 9,
|
||||
transformed: true
|
||||
})
|
||||
)
|
||||
|
||||
ledger.publishPrefix([projection.identity.projectionSemanticsId], 0, 9)
|
||||
expect(ledger.settlePublishedPrefix('pty-1', 9)).toBe(9)
|
||||
expect(ledger.getDebugSnapshot()).toMatchObject({ settled: 1, records: 0 })
|
||||
})
|
||||
|
||||
it('reclaims a closed PTY cursor so its id can be reused by a new incarnation', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
ledger.commit(reserve(ledger))
|
||||
ledger.closePty('pty-1', 3, 'incarnation-1', 'pty-exit')
|
||||
|
||||
const next = reserve(ledger, {
|
||||
ptyIncarnation: 'incarnation-2',
|
||||
data: 'next',
|
||||
sequenceEnd: 4,
|
||||
rawLength: 4
|
||||
})
|
||||
expect(next.semantics.identity).toMatchObject({
|
||||
ptyIncarnation: 'incarnation-2',
|
||||
displayStart: 0
|
||||
})
|
||||
expect(ledger.getDebugSnapshot()).toMatchObject({ records: 1, cursors: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
scanMode2031ReplyDecision
|
||||
} from '../../shared/terminal-color-scheme-protocol'
|
||||
import {
|
||||
closeProjectionPty,
|
||||
getOrCreateProjectionCursor,
|
||||
projectionDebugSnapshot,
|
||||
projectionError,
|
||||
reclaimProjectionRecord,
|
||||
requireProjectionRecord,
|
||||
rollbackUnpublishedProjection,
|
||||
scannerSnapshot,
|
||||
type LegacySshProjectionDebugSnapshot,
|
||||
type LegacySshProjectionReservation,
|
||||
type LegacySshProjectionSemantics,
|
||||
type ProjectionRecord,
|
||||
type PtyProjectionCursor
|
||||
} from './ssh-pty-legacy-projection-record'
|
||||
|
||||
export type {
|
||||
LegacySshProjectionIdentity,
|
||||
LegacySshProjectionReservation,
|
||||
LegacySshProjectionSemantics
|
||||
} from './ssh-pty-legacy-projection-record'
|
||||
|
||||
export class SshPtyLegacyProjectionLedger {
|
||||
private nextId = 1
|
||||
private readonly records = new Map<string, ProjectionRecord>()
|
||||
private readonly cursorByPty = new Map<string, PtyProjectionCursor>()
|
||||
private readonly idsByPty = new Map<string, string[]>()
|
||||
private settledCount = 0
|
||||
private transferredCount = 0
|
||||
private rolledBackCount = 0
|
||||
|
||||
reserve(args: {
|
||||
ptyId: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
data: string
|
||||
sequenceEnd: number
|
||||
rawLength: number
|
||||
transformed: boolean
|
||||
}): LegacySshProjectionReservation {
|
||||
const cursor = getOrCreateProjectionCursor(this.cursorByPty, args, (generation) =>
|
||||
this.transferGeneration(generation, 'provider-generation-replaced')
|
||||
)
|
||||
if (
|
||||
cursor.providerGeneration !== args.providerGeneration ||
|
||||
cursor.ptyIncarnation !== args.ptyIncarnation
|
||||
) {
|
||||
throw projectionError('ssh_projection_stale_generation')
|
||||
}
|
||||
const scan = scanMode2031ReplyDecision(cursor.scanner, args.data)
|
||||
const projectionSemanticsId = `ssh-projection:${args.providerGeneration}:${this.nextId++}`
|
||||
const identity = Object.freeze({
|
||||
projectionSemanticsId,
|
||||
ptyId: args.ptyId,
|
||||
providerGeneration: args.providerGeneration,
|
||||
ptyIncarnation: args.ptyIncarnation,
|
||||
displayStart: cursor.displayEnd,
|
||||
displayEnd: cursor.displayEnd + args.data.length,
|
||||
sequenceEnd: args.sequenceEnd,
|
||||
rawLength: args.rawLength,
|
||||
transformed: args.transformed
|
||||
})
|
||||
const semantics = Object.freeze({
|
||||
identity,
|
||||
beforeScanner: scannerSnapshot(cursor.scanner),
|
||||
afterScanner: scannerSnapshot(scan.state),
|
||||
decision: scan.decision
|
||||
})
|
||||
this.records.set(projectionSemanticsId, {
|
||||
semantics,
|
||||
state: 'reserved',
|
||||
publishedDisplay: 0,
|
||||
publishedAccounting: 0,
|
||||
settledAccounting: 0
|
||||
})
|
||||
return Object.freeze({ semantics })
|
||||
}
|
||||
|
||||
commit(reservation: LegacySshProjectionReservation): LegacySshProjectionSemantics {
|
||||
const record = requireProjectionRecord(
|
||||
this.records,
|
||||
reservation.semantics.identity.projectionSemanticsId
|
||||
)
|
||||
if (record.state !== 'reserved') {
|
||||
throw projectionError('ssh_projection_commit_invalid')
|
||||
}
|
||||
const { identity, afterScanner } = record.semantics
|
||||
const cursor = this.cursorByPty.get(identity.ptyId)
|
||||
if (
|
||||
!cursor ||
|
||||
cursor.providerGeneration !== identity.providerGeneration ||
|
||||
cursor.ptyIncarnation !== identity.ptyIncarnation ||
|
||||
cursor.displayEnd !== identity.displayStart
|
||||
) {
|
||||
this.records.delete(identity.projectionSemanticsId)
|
||||
throw projectionError('ssh_projection_commit_stale')
|
||||
}
|
||||
cursor.displayEnd = identity.displayEnd
|
||||
cursor.scanner = { ...afterScanner }
|
||||
record.state = 'committed'
|
||||
const ids = this.idsByPty.get(identity.ptyId) ?? []
|
||||
ids.push(identity.projectionSemanticsId)
|
||||
this.idsByPty.set(identity.ptyId, ids)
|
||||
return record.semantics
|
||||
}
|
||||
|
||||
rollback(reservation: LegacySshProjectionReservation): boolean {
|
||||
const id = reservation.semantics.identity.projectionSemanticsId
|
||||
const record = this.records.get(id)
|
||||
if (!record || record.state !== 'reserved') {
|
||||
return false
|
||||
}
|
||||
this.records.delete(id)
|
||||
this.rolledBackCount++
|
||||
return true
|
||||
}
|
||||
|
||||
publishPrefix(ids: readonly string[], displayChars: number, accountingChars: number): void {
|
||||
let displayRemaining = Math.max(0, displayChars)
|
||||
let accountingRemaining = Math.max(0, accountingChars)
|
||||
for (const id of ids) {
|
||||
const record = this.records.get(id)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const displayLength =
|
||||
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
|
||||
const unpublishedDisplay = displayLength - record.publishedDisplay
|
||||
const unpublishedAccounting = record.semantics.identity.rawLength - record.publishedAccounting
|
||||
if (displayLength === 0 && unpublishedAccounting > 0) {
|
||||
const publishAccounting = Math.min(accountingRemaining, unpublishedAccounting)
|
||||
if (publishAccounting !== unpublishedAccounting) {
|
||||
throw projectionError('ssh_projection_indivisible_split')
|
||||
}
|
||||
record.publishedAccounting += publishAccounting
|
||||
record.state = 'published'
|
||||
accountingRemaining -= publishAccounting
|
||||
continue
|
||||
}
|
||||
if (unpublishedDisplay <= 0) {
|
||||
continue
|
||||
}
|
||||
const publishDisplay = Math.min(displayRemaining, unpublishedDisplay)
|
||||
if (publishDisplay <= 0) {
|
||||
break
|
||||
}
|
||||
const indivisible = record.semantics.identity.transformed
|
||||
if (indivisible && publishDisplay !== unpublishedDisplay) {
|
||||
throw projectionError('ssh_projection_indivisible_split')
|
||||
}
|
||||
const publishAccounting =
|
||||
publishDisplay === unpublishedDisplay
|
||||
? Math.min(accountingRemaining, unpublishedAccounting)
|
||||
: publishDisplay
|
||||
record.publishedDisplay += publishDisplay
|
||||
record.publishedAccounting += publishAccounting
|
||||
record.state = 'published'
|
||||
displayRemaining -= publishDisplay
|
||||
accountingRemaining -= publishAccounting
|
||||
}
|
||||
if (displayRemaining !== 0 || accountingRemaining !== 0) {
|
||||
throw projectionError('ssh_projection_publish_range_mismatch')
|
||||
}
|
||||
}
|
||||
|
||||
settlePublishedPrefix(ptyId: string, accountingChars: number): number {
|
||||
let remaining = Math.max(0, accountingChars)
|
||||
let settled = 0
|
||||
for (const id of this.idsByPty.get(ptyId)?.slice() ?? []) {
|
||||
const record = this.records.get(id)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const available = record.publishedAccounting - record.settledAccounting
|
||||
if (available <= 0) {
|
||||
continue
|
||||
}
|
||||
const take = Math.min(remaining, available)
|
||||
record.settledAccounting += take
|
||||
settled += take
|
||||
remaining -= take
|
||||
if (
|
||||
record.settledAccounting === record.semantics.identity.rawLength &&
|
||||
record.publishedDisplay ===
|
||||
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
|
||||
) {
|
||||
this.settledCount++
|
||||
reclaimProjectionRecord(this.records, this.idsByPty, id, ptyId)
|
||||
}
|
||||
if (remaining === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return settled
|
||||
}
|
||||
|
||||
transfer(ids: readonly string[], _reason: string): number {
|
||||
let transferred = 0
|
||||
for (const id of ids.slice()) {
|
||||
const record = this.records.get(id)
|
||||
if (!record || record.state === 'reserved') {
|
||||
continue
|
||||
}
|
||||
this.transferredCount++
|
||||
reclaimProjectionRecord(this.records, this.idsByPty, id, record.semantics.identity.ptyId)
|
||||
transferred++
|
||||
}
|
||||
return transferred
|
||||
}
|
||||
|
||||
transferUnpublished(id: string, _reason: string): boolean {
|
||||
const transferred = rollbackUnpublishedProjection(
|
||||
this.records,
|
||||
this.cursorByPty,
|
||||
this.idsByPty,
|
||||
id
|
||||
)
|
||||
this.transferredCount += Number(transferred)
|
||||
return transferred
|
||||
}
|
||||
|
||||
transferGeneration(providerGeneration: number, reason: string): number {
|
||||
const ids: string[] = []
|
||||
for (const [id, record] of this.records) {
|
||||
if (record.semantics.identity.providerGeneration === providerGeneration) {
|
||||
ids.push(id)
|
||||
}
|
||||
}
|
||||
return this.transfer(ids, reason)
|
||||
}
|
||||
|
||||
transferPty(ptyId: string, reason: string): number {
|
||||
return this.transfer(this.idsByPty.get(ptyId) ?? [], reason)
|
||||
}
|
||||
|
||||
closePty(
|
||||
ptyId: string,
|
||||
providerGeneration: number,
|
||||
ptyIncarnation: string,
|
||||
reason: string
|
||||
): void {
|
||||
closeProjectionPty(this.cursorByPty, ptyId, providerGeneration, ptyIncarnation, () => {
|
||||
this.transferPty(ptyId, reason)
|
||||
this.idsByPty.delete(ptyId)
|
||||
})
|
||||
}
|
||||
|
||||
closeGeneration(providerGeneration: number, reason: string): void {
|
||||
for (const [id, record] of this.records) {
|
||||
if (record.semantics.identity.providerGeneration !== providerGeneration) {
|
||||
continue
|
||||
}
|
||||
if (record.state === 'reserved') {
|
||||
this.records.delete(id)
|
||||
this.rolledBackCount++
|
||||
} else {
|
||||
this.transfer([id], reason)
|
||||
}
|
||||
}
|
||||
for (const [ptyId, cursor] of this.cursorByPty) {
|
||||
if (cursor.providerGeneration === providerGeneration) {
|
||||
this.cursorByPty.delete(ptyId)
|
||||
this.idsByPty.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetForGap(ptyId: string): void {
|
||||
const cursor = this.cursorByPty.get(ptyId)
|
||||
if (cursor) {
|
||||
cursor.scanner = { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): LegacySshProjectionSemantics | undefined {
|
||||
return this.records.get(id)?.semantics
|
||||
}
|
||||
|
||||
getDebugSnapshot(): LegacySshProjectionDebugSnapshot {
|
||||
return projectionDebugSnapshot(this.records, this.cursorByPty, {
|
||||
settled: this.settledCount,
|
||||
transferred: this.transferredCount,
|
||||
rolledBack: this.rolledBackCount
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type SshPtyModelAdmissionKey = Readonly<{
|
||||
ptyId: string
|
||||
providerGeneration: number
|
||||
}>
|
||||
|
||||
export type SshPtyModelAdmissionReceipt = Readonly<{
|
||||
ptyId: string
|
||||
providerGeneration: number
|
||||
sequence: number
|
||||
}>
|
||||
|
||||
export type SshPtyModelAdmissionDebugSnapshot = Readonly<{
|
||||
sourceUnits: number
|
||||
bytes: number
|
||||
pressureFrames: number
|
||||
pressureBytes: number
|
||||
pausedPtys: number
|
||||
}>
|
||||
|
||||
export type SshPtyModelAdmissionOptions = {
|
||||
perPtyHighSourceUnits?: number
|
||||
perPtyHighBytes?: number
|
||||
perPtyLowSourceUnits?: number
|
||||
perPtyLowBytes?: number
|
||||
globalHighSourceUnits?: number
|
||||
globalHighBytes?: number
|
||||
globalLowSourceUnits?: number
|
||||
globalLowBytes?: number
|
||||
pressureMaxFrames?: number
|
||||
pressureMaxBytes?: number
|
||||
pauseProvider?: (key: SshPtyModelAdmissionKey) => boolean
|
||||
resumeProvider?: (key: SshPtyModelAdmissionKey) => void
|
||||
closeProvider?: (providerGeneration: number, reason: string) => void
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
SshPtyModelAdmissionKey,
|
||||
SshPtyModelAdmissionReceipt
|
||||
} from './ssh-pty-model-admission-contract'
|
||||
import type { SshPtyModelAdmissionLimits } from './ssh-pty-model-admission-limits'
|
||||
|
||||
export type AdmissionCharge = { sourceUnits: number; bytes: number }
|
||||
|
||||
export type AdmissionEntry = {
|
||||
key: SshPtyModelAdmissionKey
|
||||
charge: AdmissionCharge
|
||||
run: () => { sequence: number; completion: Promise<void> }
|
||||
resolve: (receipt: SshPtyModelAdmissionReceipt) => void
|
||||
reject: (error: Error) => void
|
||||
state: 'queued' | 'running' | 'pressure'
|
||||
}
|
||||
|
||||
export type PtyUsage = AdmissionCharge & {
|
||||
queued: AdmissionEntry[]
|
||||
running: AdmissionEntry | null
|
||||
}
|
||||
|
||||
export function admissionError(code: string): Error {
|
||||
return Object.assign(new Error(code), { code })
|
||||
}
|
||||
|
||||
export function retainedBytes(data: string): number {
|
||||
return Math.max(Buffer.byteLength(data, 'utf8'), 2 * data.length) + 128
|
||||
}
|
||||
|
||||
export function admissionKeyId(key: SshPtyModelAdmissionKey): string {
|
||||
return `${key.providerGeneration}\0${key.ptyId}`
|
||||
}
|
||||
|
||||
export function canReserveAdmission(args: {
|
||||
key: SshPtyModelAdmissionKey
|
||||
charge: AdmissionCharge
|
||||
limits: SshPtyModelAdmissionLimits
|
||||
usageByPty: ReadonlyMap<string, PtyUsage>
|
||||
closingGenerations: ReadonlySet<number>
|
||||
globalSourceUnits: number
|
||||
globalBytes: number
|
||||
}): boolean {
|
||||
if (args.closingGenerations.has(args.key.providerGeneration)) {
|
||||
return false
|
||||
}
|
||||
const usage = args.usageByPty.get(admissionKeyId(args.key))
|
||||
return (
|
||||
(usage?.sourceUnits ?? 0) + args.charge.sourceUnits <= args.limits.perPtyHighSourceUnits &&
|
||||
(usage?.bytes ?? 0) + args.charge.bytes <= args.limits.perPtyHighBytes &&
|
||||
args.globalSourceUnits + args.charge.sourceUnits <= args.limits.globalHighSourceUnits &&
|
||||
args.globalBytes + args.charge.bytes <= args.limits.globalHighBytes
|
||||
)
|
||||
}
|
||||
|
||||
export function pressureHasAdmissionKey(
|
||||
entries: readonly AdmissionEntry[],
|
||||
key: SshPtyModelAdmissionKey
|
||||
): boolean {
|
||||
const id = admissionKeyId(key)
|
||||
return entries.some((entry) => admissionKeyId(entry.key) === id)
|
||||
}
|
||||
|
||||
export function takePressureEntriesForGeneration(
|
||||
entries: AdmissionEntry[],
|
||||
providerGeneration: number
|
||||
): AdmissionEntry[] {
|
||||
const removed: AdmissionEntry[] = []
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
if (entries[index]!.key.providerGeneration === providerGeneration) {
|
||||
removed.unshift(entries.splice(index, 1)[0]!)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
export function takePausedGeneration(
|
||||
paused: Map<string, SshPtyModelAdmissionKey>,
|
||||
providerGeneration: number
|
||||
): SshPtyModelAdmissionKey[] {
|
||||
const removed: SshPtyModelAdmissionKey[] = []
|
||||
for (const [id, key] of paused) {
|
||||
if (key.providerGeneration === providerGeneration) {
|
||||
paused.delete(id)
|
||||
removed.push(key)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
export function resolveAdmissionIdleWaiters(
|
||||
usageByPty: ReadonlyMap<string, PtyUsage>,
|
||||
pressure: readonly AdmissionEntry[],
|
||||
waitersById: Map<string, Set<() => void>>,
|
||||
id: string
|
||||
): void {
|
||||
const usage = usageByPty.get(id)
|
||||
const stillPressured = pressure.some((entry) => admissionKeyId(entry.key) === id)
|
||||
if (usage?.running || (usage?.queued.length ?? 0) > 0 || stillPressured) {
|
||||
return
|
||||
}
|
||||
const waiters = waitersById.get(id)
|
||||
if (!waiters) {
|
||||
return
|
||||
}
|
||||
waitersById.delete(id)
|
||||
for (const resolve of waiters) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SshPtyModelAdmissionOptions } from './ssh-pty-model-admission-contract'
|
||||
|
||||
export type SshPtyModelAdmissionLimits = Readonly<{
|
||||
perPtyHighSourceUnits: number
|
||||
perPtyHighBytes: number
|
||||
perPtyLowSourceUnits: number
|
||||
perPtyLowBytes: number
|
||||
globalHighSourceUnits: number
|
||||
globalHighBytes: number
|
||||
globalLowSourceUnits: number
|
||||
globalLowBytes: number
|
||||
pressureMaxFrames: number
|
||||
pressureMaxBytes: number
|
||||
}>
|
||||
|
||||
const DEFAULT_PER_PTY_HIGH_SOURCE_UNITS = 256 * 1024
|
||||
const DEFAULT_PER_PTY_HIGH_BYTES = 2 * 1024 * 1024
|
||||
const DEFAULT_GLOBAL_HIGH_SOURCE_UNITS = 50 * DEFAULT_PER_PTY_HIGH_SOURCE_UNITS
|
||||
const DEFAULT_GLOBAL_HIGH_BYTES = 64 * 1024 * 1024
|
||||
|
||||
export function resolveSshPtyModelAdmissionLimits(
|
||||
options: SshPtyModelAdmissionOptions
|
||||
): SshPtyModelAdmissionLimits {
|
||||
const perPtyHighSourceUnits = options.perPtyHighSourceUnits ?? DEFAULT_PER_PTY_HIGH_SOURCE_UNITS
|
||||
const perPtyHighBytes = options.perPtyHighBytes ?? DEFAULT_PER_PTY_HIGH_BYTES
|
||||
return {
|
||||
perPtyHighSourceUnits,
|
||||
perPtyHighBytes,
|
||||
perPtyLowSourceUnits: options.perPtyLowSourceUnits ?? Math.floor(perPtyHighSourceUnits / 2),
|
||||
perPtyLowBytes: options.perPtyLowBytes ?? Math.floor(perPtyHighBytes / 2),
|
||||
globalHighSourceUnits: options.globalHighSourceUnits ?? DEFAULT_GLOBAL_HIGH_SOURCE_UNITS,
|
||||
globalHighBytes: options.globalHighBytes ?? DEFAULT_GLOBAL_HIGH_BYTES,
|
||||
globalLowSourceUnits: options.globalLowSourceUnits ?? 8 * 1024 * 1024,
|
||||
globalLowBytes: options.globalLowBytes ?? 48 * 1024 * 1024,
|
||||
pressureMaxFrames: options.pressureMaxFrames ?? 64,
|
||||
pressureMaxBytes: options.pressureMaxBytes ?? 1024 * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SshPtyModelAdmission } from './ssh-pty-model-admission'
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((promiseResolve) => {
|
||||
resolve = promiseResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('SshPtyModelAdmission', () => {
|
||||
it('owns the sole running entry through disposal', async () => {
|
||||
const completion = deferred()
|
||||
const admission = new SshPtyModelAdmission()
|
||||
const receipt = admission.accept({ ptyId: 'pty-1', providerGeneration: 7 }, 'data', 4, () => ({
|
||||
sequence: 4,
|
||||
completion: completion.promise
|
||||
}))
|
||||
let idle = false
|
||||
const idleReceipt = admission.whenIdle({ ptyId: 'pty-1', providerGeneration: 7 }).then(() => {
|
||||
idle = true
|
||||
})
|
||||
|
||||
admission.dispose()
|
||||
await Promise.resolve()
|
||||
expect(idle).toBe(false)
|
||||
completion.resolve()
|
||||
|
||||
await expect(receipt).rejects.toThrow('ssh_model_admission_generation_closed')
|
||||
await idleReceipt
|
||||
expect(admission.getDebugSnapshot()).toMatchObject({ sourceUnits: 0, bytes: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import type {
|
||||
SshPtyModelAdmissionDebugSnapshot,
|
||||
SshPtyModelAdmissionKey,
|
||||
SshPtyModelAdmissionOptions,
|
||||
SshPtyModelAdmissionReceipt
|
||||
} from './ssh-pty-model-admission-contract'
|
||||
import {
|
||||
admissionError,
|
||||
admissionKeyId,
|
||||
canReserveAdmission,
|
||||
pressureHasAdmissionKey,
|
||||
retainedBytes,
|
||||
resolveAdmissionIdleWaiters,
|
||||
takePausedGeneration,
|
||||
takePressureEntriesForGeneration,
|
||||
type AdmissionCharge,
|
||||
type AdmissionEntry,
|
||||
type PtyUsage
|
||||
} from './ssh-pty-model-admission-entry'
|
||||
import {
|
||||
resolveSshPtyModelAdmissionLimits,
|
||||
type SshPtyModelAdmissionLimits
|
||||
} from './ssh-pty-model-admission-limits'
|
||||
|
||||
export type {
|
||||
SshPtyModelAdmissionKey,
|
||||
SshPtyModelAdmissionOptions,
|
||||
SshPtyModelAdmissionReceipt
|
||||
} from './ssh-pty-model-admission-contract'
|
||||
|
||||
export class SshPtyModelAdmission {
|
||||
private readonly limits: SshPtyModelAdmissionLimits
|
||||
private readonly pauseProvider: (key: SshPtyModelAdmissionKey) => boolean
|
||||
private readonly resumeProvider: (key: SshPtyModelAdmissionKey) => void
|
||||
private readonly closeProvider: (providerGeneration: number, reason: string) => void
|
||||
private readonly usageByPty = new Map<string, PtyUsage>()
|
||||
private readonly pressure: AdmissionEntry[] = []
|
||||
private readonly pausedKeys = new Map<string, SshPtyModelAdmissionKey>()
|
||||
private readonly idleWaiters = new Map<string, Set<() => void>>()
|
||||
private readonly closingGenerations = new Set<number>()
|
||||
private globalSourceUnits = 0
|
||||
private globalBytes = 0
|
||||
private pressureBytes = 0
|
||||
private disposed = false
|
||||
|
||||
constructor(options: SshPtyModelAdmissionOptions = {}) {
|
||||
this.limits = resolveSshPtyModelAdmissionLimits(options)
|
||||
this.pauseProvider = options.pauseProvider ?? (() => false)
|
||||
this.resumeProvider = options.resumeProvider ?? (() => {})
|
||||
this.closeProvider = options.closeProvider ?? (() => {})
|
||||
}
|
||||
|
||||
accept(
|
||||
key: SshPtyModelAdmissionKey,
|
||||
data: string,
|
||||
sourceUnits: number,
|
||||
run: () => { sequence: number; completion: Promise<void> }
|
||||
): Promise<SshPtyModelAdmissionReceipt> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(admissionError('ssh_model_admission_disposed'))
|
||||
}
|
||||
if (this.closingGenerations.has(key.providerGeneration)) {
|
||||
return Promise.reject(admissionError('ssh_model_admission_generation_closed'))
|
||||
}
|
||||
const charge = { sourceUnits, bytes: retainedBytes(data) }
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry: AdmissionEntry = {
|
||||
key: { ...key },
|
||||
charge,
|
||||
run,
|
||||
resolve,
|
||||
reject,
|
||||
state: 'queued'
|
||||
}
|
||||
if (this.canReserve(key, charge) && !pressureHasAdmissionKey(this.pressure, key)) {
|
||||
this.reserveAndQueue(entry)
|
||||
return
|
||||
}
|
||||
this.admitPressure(entry)
|
||||
})
|
||||
}
|
||||
|
||||
closeGeneration(providerGeneration: number, reason = 'provider-generation-closed'): void {
|
||||
this.closingGenerations.add(providerGeneration)
|
||||
const error = admissionError(reason)
|
||||
for (const entry of takePressureEntriesForGeneration(this.pressure, providerGeneration)) {
|
||||
this.pressureBytes -= entry.charge.bytes
|
||||
entry.reject(error)
|
||||
}
|
||||
for (const [id, usage] of this.usageByPty) {
|
||||
const queued = usage.queued.filter(
|
||||
(entry) => entry.key.providerGeneration === providerGeneration
|
||||
)
|
||||
usage.queued = usage.queued.filter(
|
||||
(entry) => entry.key.providerGeneration !== providerGeneration
|
||||
)
|
||||
for (const entry of queued) {
|
||||
this.release(entry.key, entry.charge)
|
||||
entry.reject(error)
|
||||
}
|
||||
if (!usage.running && usage.queued.length === 0 && usage.sourceUnits === 0) {
|
||||
this.usageByPty.delete(id)
|
||||
}
|
||||
resolveAdmissionIdleWaiters(this.usageByPty, this.pressure, this.idleWaiters, id)
|
||||
}
|
||||
for (const key of takePausedGeneration(this.pausedKeys, providerGeneration)) {
|
||||
try {
|
||||
this.resumeProvider(key)
|
||||
} catch {}
|
||||
}
|
||||
const generationPrefix = `${providerGeneration}\0`
|
||||
for (const id of this.idleWaiters.keys()) {
|
||||
if (id.startsWith(generationPrefix)) {
|
||||
resolveAdmissionIdleWaiters(this.usageByPty, this.pressure, this.idleWaiters, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
whenIdle(key: SshPtyModelAdmissionKey): Promise<void> {
|
||||
const id = admissionKeyId(key)
|
||||
const usage = this.usageByPty.get(id)
|
||||
const hasPressure = this.pressure.some(
|
||||
(entry) =>
|
||||
entry.key.providerGeneration === key.providerGeneration && entry.key.ptyId === key.ptyId
|
||||
)
|
||||
if ((!usage || (!usage.running && usage.queued.length === 0)) && !hasPressure) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const waiters = this.idleWaiters.get(id) ?? new Set<() => void>()
|
||||
waiters.add(resolve)
|
||||
this.idleWaiters.set(id, waiters)
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
const generations = new Set<number>()
|
||||
for (const usage of this.usageByPty.values()) {
|
||||
if (usage.running) {
|
||||
generations.add(usage.running.key.providerGeneration)
|
||||
}
|
||||
for (const entry of usage.queued) {
|
||||
generations.add(entry.key.providerGeneration)
|
||||
}
|
||||
}
|
||||
for (const entry of this.pressure) {
|
||||
generations.add(entry.key.providerGeneration)
|
||||
}
|
||||
for (const generation of generations) {
|
||||
this.closeGeneration(generation, 'ssh_model_admission_disposed')
|
||||
}
|
||||
}
|
||||
|
||||
getDebugSnapshot(): SshPtyModelAdmissionDebugSnapshot {
|
||||
return {
|
||||
sourceUnits: this.globalSourceUnits,
|
||||
bytes: this.globalBytes,
|
||||
pressureFrames: this.pressure.length,
|
||||
pressureBytes: this.pressureBytes,
|
||||
pausedPtys: this.pausedKeys.size
|
||||
}
|
||||
}
|
||||
|
||||
private canReserve(key: SshPtyModelAdmissionKey, charge: AdmissionCharge): boolean {
|
||||
return canReserveAdmission({
|
||||
key,
|
||||
charge,
|
||||
limits: this.limits,
|
||||
usageByPty: this.usageByPty,
|
||||
closingGenerations: this.closingGenerations,
|
||||
globalSourceUnits: this.globalSourceUnits,
|
||||
globalBytes: this.globalBytes
|
||||
})
|
||||
}
|
||||
|
||||
private reserveAndQueue(entry: AdmissionEntry): void {
|
||||
const id = admissionKeyId(entry.key)
|
||||
let usage = this.usageByPty.get(id)
|
||||
if (!usage) {
|
||||
usage = { sourceUnits: 0, bytes: 0, queued: [], running: null }
|
||||
this.usageByPty.set(id, usage)
|
||||
}
|
||||
usage.sourceUnits += entry.charge.sourceUnits
|
||||
usage.bytes += entry.charge.bytes
|
||||
this.globalSourceUnits += entry.charge.sourceUnits
|
||||
this.globalBytes += entry.charge.bytes
|
||||
entry.state = 'queued'
|
||||
usage.queued.push(entry)
|
||||
this.startNext(id, usage)
|
||||
}
|
||||
|
||||
private admitPressure(entry: AdmissionEntry): void {
|
||||
const id = admissionKeyId(entry.key)
|
||||
const paused = this.pausedKeys.has(id) || this.pauseProvider(entry.key)
|
||||
if (paused) {
|
||||
this.pausedKeys.set(id, entry.key)
|
||||
}
|
||||
if (
|
||||
!paused ||
|
||||
this.pressure.length >= this.limits.pressureMaxFrames ||
|
||||
this.pressureBytes + entry.charge.bytes > this.limits.pressureMaxBytes
|
||||
) {
|
||||
entry.reject(admissionError('ssh_model_admission_pressure_exhausted'))
|
||||
this.closeProvider(entry.key.providerGeneration, 'model-admission-pressure')
|
||||
return
|
||||
}
|
||||
entry.state = 'pressure'
|
||||
this.pressure.push(entry)
|
||||
this.pressureBytes += entry.charge.bytes
|
||||
}
|
||||
|
||||
private startNext(id: string, usage: PtyUsage): void {
|
||||
if (usage.running) {
|
||||
return
|
||||
}
|
||||
const entry = usage.queued.shift()
|
||||
if (!entry) {
|
||||
this.maybeResumeAndPromote()
|
||||
return
|
||||
}
|
||||
usage.running = entry
|
||||
entry.state = 'running'
|
||||
let execution: { sequence: number; completion: Promise<void> }
|
||||
try {
|
||||
execution = entry.run()
|
||||
} catch (error) {
|
||||
this.finishEntry(id, usage, entry)
|
||||
entry.reject(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
void execution.completion.then(
|
||||
() => {
|
||||
this.finishEntry(id, usage, entry)
|
||||
if (this.closingGenerations.has(entry.key.providerGeneration)) {
|
||||
entry.reject(admissionError('ssh_model_admission_generation_closed'))
|
||||
} else {
|
||||
entry.resolve({
|
||||
ptyId: entry.key.ptyId,
|
||||
providerGeneration: entry.key.providerGeneration,
|
||||
sequence: execution.sequence
|
||||
})
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
this.finishEntry(id, usage, entry)
|
||||
entry.reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private finishEntry(id: string, usage: PtyUsage, entry: AdmissionEntry): void {
|
||||
if (usage.running === entry) {
|
||||
usage.running = null
|
||||
}
|
||||
this.release(entry.key, entry.charge)
|
||||
this.startNext(id, usage)
|
||||
if (!usage.running && usage.queued.length === 0 && usage.sourceUnits === 0) {
|
||||
this.usageByPty.delete(id)
|
||||
}
|
||||
resolveAdmissionIdleWaiters(this.usageByPty, this.pressure, this.idleWaiters, id)
|
||||
}
|
||||
|
||||
private release(key: SshPtyModelAdmissionKey, charge: AdmissionCharge): void {
|
||||
const usage = this.usageByPty.get(admissionKeyId(key))
|
||||
if (usage) {
|
||||
usage.sourceUnits = Math.max(0, usage.sourceUnits - charge.sourceUnits)
|
||||
usage.bytes = Math.max(0, usage.bytes - charge.bytes)
|
||||
}
|
||||
this.globalSourceUnits = Math.max(0, this.globalSourceUnits - charge.sourceUnits)
|
||||
this.globalBytes = Math.max(0, this.globalBytes - charge.bytes)
|
||||
this.maybeResumeAndPromote()
|
||||
}
|
||||
|
||||
private maybeResumeAndPromote(): void {
|
||||
for (let index = 0; index < this.pressure.length; ) {
|
||||
const entry = this.pressure[index]!
|
||||
const hasEarlierEntryForPty = this.pressure
|
||||
.slice(0, index)
|
||||
.some((earlier) => admissionKeyId(earlier.key) === admissionKeyId(entry.key))
|
||||
if (hasEarlierEntryForPty || !this.canReserve(entry.key, entry.charge)) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
this.pressure.splice(index, 1)
|
||||
this.pressureBytes -= entry.charge.bytes
|
||||
this.reserveAndQueue(entry)
|
||||
}
|
||||
if (
|
||||
this.globalSourceUnits > this.limits.globalLowSourceUnits ||
|
||||
this.globalBytes > this.limits.globalLowBytes
|
||||
) {
|
||||
return
|
||||
}
|
||||
for (const [id, key] of this.pausedKeys) {
|
||||
const usage = this.usageByPty.get(id)
|
||||
const stillPressured = this.pressure.some(
|
||||
(entry) =>
|
||||
entry.key.providerGeneration === key.providerGeneration && entry.key.ptyId === key.ptyId
|
||||
)
|
||||
if (
|
||||
stillPressured ||
|
||||
(usage?.sourceUnits ?? 0) > this.limits.perPtyLowSourceUnits ||
|
||||
(usage?.bytes ?? 0) > this.limits.perPtyLowBytes
|
||||
) {
|
||||
continue
|
||||
}
|
||||
this.pausedKeys.delete(id)
|
||||
this.resumeProvider(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
|
||||
import type {
|
||||
SshPtyModelAdmissionOptions,
|
||||
SshPtyModelAdmissionReceipt
|
||||
} from './ssh-pty-model-admission-contract'
|
||||
|
||||
export type SshPtyOutputDataEvent = Readonly<{
|
||||
id: string
|
||||
data: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
rawLength: number
|
||||
transformed: boolean
|
||||
sequence?: number
|
||||
}>
|
||||
|
||||
export type SshPtyOutputExitEvent = Readonly<{
|
||||
id: string
|
||||
code: number
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
}>
|
||||
|
||||
export type SshPtyOutputReceipt = SshPtyModelAdmissionReceipt &
|
||||
Readonly<{ projection: LegacySshProjectionSemantics }>
|
||||
|
||||
export type SshPtyOutputIntakeDependencies = {
|
||||
getModelSequence: (id: string) => number
|
||||
acceptModel: (event: SshPtyOutputDataEvent) => { sequence: number; completion: Promise<void> }
|
||||
project: (event: SshPtyOutputDataEvent, projection: LegacySshProjectionSemantics) => void
|
||||
finalizeExit: (event: SshPtyOutputExitEvent) => void
|
||||
pauseProvider?: (providerGeneration: number, id: string) => boolean
|
||||
resumeProvider?: (providerGeneration: number, id: string) => void
|
||||
closeProvider?: (providerGeneration: number, reason: string) => void
|
||||
onGenerationClosed?: (providerGeneration: number, reason: string) => void
|
||||
}
|
||||
|
||||
export type SshPtyOutputIntakeOptions = SshPtyModelAdmissionOptions & {
|
||||
exitBarrierMs?: number
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SshPtyOutputIntake } from './ssh-pty-output-intake'
|
||||
import type {
|
||||
SshPtyOutputDataEvent,
|
||||
SshPtyOutputExitEvent,
|
||||
SshPtyOutputReceipt
|
||||
} from './ssh-pty-output-intake-contract'
|
||||
|
||||
let installedIntake: SshPtyOutputIntake | null = null
|
||||
let nextProviderGeneration = 1
|
||||
|
||||
export function allocateSshPtyProviderGeneration(): number {
|
||||
return nextProviderGeneration++
|
||||
}
|
||||
|
||||
export function installSshPtyOutputIntake(intake: SshPtyOutputIntake): () => void {
|
||||
const previous = installedIntake
|
||||
installedIntake = intake
|
||||
previous?.dispose()
|
||||
return () => {
|
||||
if (installedIntake === intake) {
|
||||
installedIntake = null
|
||||
intake.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function acceptSshPtyOutputData(event: SshPtyOutputDataEvent): Promise<SshPtyOutputReceipt> {
|
||||
return installedIntake
|
||||
? installedIntake.acceptData(event)
|
||||
: Promise.reject(outputIntakeUnavailableError())
|
||||
}
|
||||
|
||||
export function acceptSshPtyOutputExit(event: SshPtyOutputExitEvent): Promise<void> {
|
||||
return installedIntake
|
||||
? installedIntake.acceptExit(event)
|
||||
: Promise.reject(outputIntakeUnavailableError())
|
||||
}
|
||||
|
||||
export function closeSshPtyOutputGeneration(providerGeneration: number, reason: string): void {
|
||||
installedIntake?.closeGeneration(providerGeneration, reason)
|
||||
}
|
||||
|
||||
function outputIntakeUnavailableError(): Error {
|
||||
return Object.assign(new Error('ssh_output_intake_unavailable'), {
|
||||
code: 'ssh_output_intake_unavailable'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type SshPtyExitBarrier = {
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
export function outputIntakeError(code: string): Error {
|
||||
return Object.assign(new Error(code), { code })
|
||||
}
|
||||
|
||||
export function validOutputLength(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
export function sshPtyGenerationKey(ptyId: string, providerGeneration: number): string {
|
||||
return `${providerGeneration}\0${ptyId}`
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
|
||||
import {
|
||||
SshPtyOutputIntake,
|
||||
type SshPtyOutputDataEvent,
|
||||
type SshPtyOutputIntakeDependencies
|
||||
} from './ssh-pty-output-intake'
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<void>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function event(overrides: Partial<SshPtyOutputDataEvent> = {}): SshPtyOutputDataEvent {
|
||||
return {
|
||||
id: 'pty-1',
|
||||
data: 'aaaa',
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'incarnation-1',
|
||||
rawLength: 4,
|
||||
transformed: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
overrides: Partial<SshPtyOutputIntakeDependencies> = {},
|
||||
options: ConstructorParameters<typeof SshPtyOutputIntake>[1] = {}
|
||||
) {
|
||||
let sequence = 0
|
||||
const completions: ReturnType<typeof deferred>[] = []
|
||||
const order: string[] = []
|
||||
const dependencies: SshPtyOutputIntakeDependencies = {
|
||||
getModelSequence: () => sequence,
|
||||
acceptModel: (input) => {
|
||||
order.push(`model:${input.data}`)
|
||||
sequence += input.rawLength
|
||||
const completion = deferred()
|
||||
completions.push(completion)
|
||||
return { sequence, completion: completion.promise }
|
||||
},
|
||||
project: (input) => order.push(`project:${input.data}`),
|
||||
finalizeExit: () => order.push('exit'),
|
||||
pauseProvider: vi.fn(() => true),
|
||||
resumeProvider: vi.fn(),
|
||||
closeProvider: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
return {
|
||||
intake: new SshPtyOutputIntake(dependencies, options),
|
||||
dependencies,
|
||||
completions,
|
||||
order
|
||||
}
|
||||
}
|
||||
|
||||
describe('SshPtyOutputIntake', () => {
|
||||
it('plateaus at the model and pressure budgets, then resumes below low water', async () => {
|
||||
const harness = createHarness(
|
||||
{},
|
||||
{
|
||||
perPtyHighSourceUnits: 4,
|
||||
perPtyHighBytes: 1024,
|
||||
perPtyLowSourceUnits: 1,
|
||||
perPtyLowBytes: 256,
|
||||
globalHighSourceUnits: 4,
|
||||
globalHighBytes: 1024,
|
||||
globalLowSourceUnits: 1,
|
||||
globalLowBytes: 256,
|
||||
pressureMaxFrames: 2,
|
||||
pressureMaxBytes: 1024
|
||||
}
|
||||
)
|
||||
const first = harness.intake.acceptData(event())
|
||||
const second = harness.intake.acceptData(event({ data: 'bbbb' }))
|
||||
const third = harness.intake.acceptData(event({ data: 'cccc' }))
|
||||
const rejected = harness.intake.acceptData(event({ data: 'dddd' }))
|
||||
|
||||
expect(harness.intake.getDebugSnapshot().model).toMatchObject({
|
||||
sourceUnits: 4,
|
||||
pressureFrames: 2
|
||||
})
|
||||
await expect(rejected).rejects.toThrow('ssh_model_admission_pressure_exhausted')
|
||||
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(1, expect.any(String))
|
||||
|
||||
harness.completions[0]!.resolve()
|
||||
await first
|
||||
harness.completions[1]!.resolve()
|
||||
await second
|
||||
harness.completions[2]!.resolve()
|
||||
await third
|
||||
|
||||
expect(harness.intake.getDebugSnapshot().model).toMatchObject({
|
||||
sourceUnits: 0,
|
||||
bytes: 0,
|
||||
pressureFrames: 0
|
||||
})
|
||||
expect(harness.dependencies.resumeProvider).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves per-PTY FIFO while differently sized pressure entries wait', async () => {
|
||||
const harness = createHarness(
|
||||
{},
|
||||
{
|
||||
perPtyHighSourceUnits: 4,
|
||||
perPtyHighBytes: 4096,
|
||||
globalHighSourceUnits: 4,
|
||||
globalHighBytes: 4096,
|
||||
pressureMaxFrames: 4,
|
||||
pressureMaxBytes: 4096
|
||||
}
|
||||
)
|
||||
const receipts = [
|
||||
harness.intake.acceptData(event({ data: 'a', rawLength: 1 })),
|
||||
harness.intake.acceptData(event({ data: 'bbb', rawLength: 3 })),
|
||||
harness.intake.acceptData(event({ data: 'cc', rawLength: 2 })),
|
||||
harness.intake.acceptData(event({ data: 'd', rawLength: 1 }))
|
||||
]
|
||||
|
||||
harness.completions[0]!.resolve()
|
||||
await receipts[0]
|
||||
harness.completions[1]!.resolve()
|
||||
await receipts[1]
|
||||
harness.completions[2]!.resolve()
|
||||
await receipts[2]
|
||||
harness.completions[3]!.resolve()
|
||||
await receipts[3]
|
||||
|
||||
expect(harness.order).toEqual([
|
||||
'model:a',
|
||||
'project:a',
|
||||
'model:bbb',
|
||||
'project:bbb',
|
||||
'model:cc',
|
||||
'project:cc',
|
||||
'model:d',
|
||||
'project:d'
|
||||
])
|
||||
})
|
||||
|
||||
it('assigns projection sequence ends when queued model capture begins', async () => {
|
||||
const harness = createHarness(
|
||||
{},
|
||||
{
|
||||
perPtyHighSourceUnits: 12,
|
||||
perPtyHighBytes: 4096,
|
||||
globalHighSourceUnits: 12,
|
||||
globalHighBytes: 4096
|
||||
}
|
||||
)
|
||||
const first = harness.intake.acceptData(event({ data: 'aaaa' }))
|
||||
const second = harness.intake.acceptData(event({ data: 'bbbb' }))
|
||||
const third = harness.intake.acceptData(event({ data: 'cccc' }))
|
||||
|
||||
harness.completions[0]!.resolve()
|
||||
expect((await first).projection.identity.sequenceEnd).toBe(4)
|
||||
harness.completions[1]!.resolve()
|
||||
expect((await second).projection.identity.sequenceEnd).toBe(8)
|
||||
harness.completions[2]!.resolve()
|
||||
expect((await third).projection.identity.sequenceEnd).toBe(12)
|
||||
})
|
||||
|
||||
it('transfers projection state and closes the provider on model failure', async () => {
|
||||
const harness = createHarness()
|
||||
const receipt = harness.intake.acceptData(event())
|
||||
harness.completions[0]!.reject(new Error('emulator failed'))
|
||||
|
||||
await expect(receipt).rejects.toThrow('emulator failed')
|
||||
expect(harness.intake.getDebugSnapshot().projection.transferred).toBe(1)
|
||||
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(1, 'model-admission-failed')
|
||||
})
|
||||
|
||||
it('transfers a committed projection when desktop admission throws', async () => {
|
||||
const projections: LegacySshProjectionSemantics[] = []
|
||||
const harness = createHarness({
|
||||
project: (_event, projection) => {
|
||||
projections.push(projection)
|
||||
if (projections.length === 1) {
|
||||
throw new Error('send failed')
|
||||
}
|
||||
}
|
||||
})
|
||||
const receipt = harness.intake.acceptData(event({ data: '\x1b[?20', rawLength: 5 }))
|
||||
harness.completions[0]!.resolve()
|
||||
|
||||
await expect(receipt).resolves.toMatchObject({ sequence: 5 })
|
||||
expect(harness.intake.getDebugSnapshot().projection.transferred).toBe(1)
|
||||
|
||||
const next = harness.intake.acceptData(event({ data: '31h', rawLength: 3 }))
|
||||
harness.completions[1]!.resolve()
|
||||
await next
|
||||
expect(projections[1]).toMatchObject({
|
||||
identity: { displayStart: 0 },
|
||||
beforeScanner: { tail: '', pendingSubscribe: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back projection staging when model capture throws synchronously', async () => {
|
||||
const project = vi.fn()
|
||||
const harness = createHarness({
|
||||
acceptModel: () => {
|
||||
throw new Error('model reservation failed')
|
||||
},
|
||||
project
|
||||
})
|
||||
|
||||
await expect(harness.intake.acceptData(event())).rejects.toThrow('model reservation failed')
|
||||
expect(project).not.toHaveBeenCalled()
|
||||
expect(harness.intake.getDebugSnapshot().projection).toMatchObject({
|
||||
rolledBack: 1,
|
||||
records: 0
|
||||
})
|
||||
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(1, 'model-admission-failed')
|
||||
})
|
||||
|
||||
it('rejects stale provider generations without model capture', async () => {
|
||||
const harness = createHarness()
|
||||
const current = harness.intake.acceptData(event({ providerGeneration: 2 }))
|
||||
harness.completions[0]!.resolve()
|
||||
await current
|
||||
|
||||
await expect(harness.intake.acceptData(event({ providerGeneration: 1 }))).rejects.toThrow(
|
||||
'ssh_output_stale_generation'
|
||||
)
|
||||
expect(harness.completions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps exit behind accepted model and projection work', async () => {
|
||||
const harness = createHarness({}, { exitBarrierMs: 1000 })
|
||||
const dataReceipt = harness.intake.acceptData(event())
|
||||
const exitReceipt = harness.intake.acceptExit({
|
||||
id: 'pty-1',
|
||||
code: 0,
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'incarnation-1'
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(harness.order).toEqual(['model:aaaa', 'project:aaaa'])
|
||||
|
||||
harness.completions[0]!.resolve()
|
||||
await Promise.all([dataReceipt, exitReceipt])
|
||||
expect(harness.order).toEqual(['model:aaaa', 'project:aaaa', 'exit'])
|
||||
})
|
||||
|
||||
it('rejects late same-generation data after ordered exit cleanup', async () => {
|
||||
const harness = createHarness({}, { exitBarrierMs: 1000 })
|
||||
const first = harness.intake.acceptData(event())
|
||||
harness.completions[0]!.resolve()
|
||||
await first
|
||||
await harness.intake.acceptExit({
|
||||
id: 'pty-1',
|
||||
code: 0,
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'incarnation-1'
|
||||
})
|
||||
|
||||
await expect(harness.intake.acceptData(event())).rejects.toThrow('ssh_output_after_exit')
|
||||
await expect(
|
||||
harness.intake.acceptExit({
|
||||
id: 'pty-1',
|
||||
code: 0,
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'incarnation-1'
|
||||
})
|
||||
).rejects.toThrow('ssh_output_duplicate_exit')
|
||||
|
||||
const next = harness.intake.acceptData(
|
||||
event({ providerGeneration: 2, ptyIncarnation: 'incarnation-2' })
|
||||
)
|
||||
harness.completions[1]!.resolve()
|
||||
await expect(next).resolves.toMatchObject({
|
||||
projection: { identity: { ptyIncarnation: 'incarnation-2', displayStart: 0 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the provider when exit finalization fails', async () => {
|
||||
const harness = createHarness({
|
||||
finalizeExit: () => {
|
||||
throw new Error('renderer exit send failed')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.intake.acceptExit({
|
||||
id: 'pty-1',
|
||||
code: 0,
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'incarnation-1'
|
||||
})
|
||||
).rejects.toThrow('renderer exit send failed')
|
||||
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(1, 'pty-exit-finalize-failed')
|
||||
})
|
||||
|
||||
it('cancels queued work and exit waiters on generation cleanup', async () => {
|
||||
const harness = createHarness(
|
||||
{},
|
||||
{
|
||||
perPtyHighSourceUnits: 8,
|
||||
perPtyHighBytes: 2048,
|
||||
globalHighSourceUnits: 8,
|
||||
globalHighBytes: 2048
|
||||
}
|
||||
)
|
||||
const running = harness.intake.acceptData(event())
|
||||
const queued = harness.intake.acceptData(event({ data: 'bbbb' }))
|
||||
const exit = harness.intake.acceptExit({
|
||||
id: 'pty-1',
|
||||
code: 0,
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'incarnation-1'
|
||||
})
|
||||
harness.intake.closeGeneration(1, 'provider-closed')
|
||||
|
||||
await expect(queued).rejects.toThrow('provider-closed')
|
||||
await expect(exit).rejects.toThrow('provider-closed')
|
||||
await expect(harness.intake.acceptData(event())).rejects.toThrow('ssh_output_stale_generation')
|
||||
harness.completions[0]!.resolve()
|
||||
await expect(running).rejects.toThrow('ssh_model_admission_generation_closed')
|
||||
expect(harness.intake.getDebugSnapshot().exitBarriers).toBe(0)
|
||||
})
|
||||
|
||||
it('resumes a paused provider during generation cleanup', async () => {
|
||||
const harness = createHarness(
|
||||
{},
|
||||
{
|
||||
perPtyHighSourceUnits: 4,
|
||||
perPtyHighBytes: 1024,
|
||||
globalHighSourceUnits: 4,
|
||||
globalHighBytes: 1024,
|
||||
pressureMaxFrames: 2,
|
||||
pressureMaxBytes: 1024
|
||||
}
|
||||
)
|
||||
const running = harness.intake.acceptData(event())
|
||||
const pressured = harness.intake.acceptData(event({ data: 'bbbb' }))
|
||||
|
||||
harness.intake.closeGeneration(1, 'provider-closed')
|
||||
|
||||
await expect(pressured).rejects.toThrow('provider-closed')
|
||||
expect(harness.dependencies.resumeProvider).toHaveBeenCalledWith(1, 'pty-1')
|
||||
harness.completions[0]!.resolve()
|
||||
await expect(running).rejects.toThrow('ssh_model_admission_generation_closed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
SshPtyLegacyProjectionLedger,
|
||||
type LegacySshProjectionSemantics
|
||||
} from './ssh-pty-legacy-projection'
|
||||
import { SshPtyModelAdmission } from './ssh-pty-model-admission'
|
||||
import type {
|
||||
SshPtyOutputDataEvent,
|
||||
SshPtyOutputExitEvent,
|
||||
SshPtyOutputIntakeDependencies,
|
||||
SshPtyOutputIntakeOptions,
|
||||
SshPtyOutputReceipt
|
||||
} from './ssh-pty-output-intake-contract'
|
||||
import {
|
||||
outputIntakeError,
|
||||
sshPtyGenerationKey,
|
||||
validOutputLength,
|
||||
type SshPtyExitBarrier
|
||||
} from './ssh-pty-output-intake-validation'
|
||||
|
||||
export type {
|
||||
SshPtyOutputDataEvent,
|
||||
SshPtyOutputExitEvent,
|
||||
SshPtyOutputIntakeDependencies,
|
||||
SshPtyOutputIntakeOptions,
|
||||
SshPtyOutputReceipt
|
||||
} from './ssh-pty-output-intake-contract'
|
||||
|
||||
export class SshPtyOutputIntake {
|
||||
private readonly projections = new SshPtyLegacyProjectionLedger()
|
||||
private readonly admission: SshPtyModelAdmission
|
||||
private readonly exitBarrierMs: number
|
||||
private readonly latestGenerationByPty = new Map<string, number>()
|
||||
private readonly incarnationByPty = new Map<string, string>()
|
||||
private readonly sealedPtys = new Set<string>()
|
||||
private readonly closedGenerations = new Set<number>()
|
||||
private readonly exitBarriersByGeneration = new Map<number, Set<SshPtyExitBarrier>>()
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: SshPtyOutputIntakeDependencies,
|
||||
options: SshPtyOutputIntakeOptions = {}
|
||||
) {
|
||||
this.exitBarrierMs = options.exitBarrierMs ?? 30_000
|
||||
this.admission = new SshPtyModelAdmission({
|
||||
...options,
|
||||
pauseProvider: (key) =>
|
||||
this.dependencies.pauseProvider?.(key.providerGeneration, key.ptyId) ?? false,
|
||||
resumeProvider: (key) =>
|
||||
this.dependencies.resumeProvider?.(key.providerGeneration, key.ptyId),
|
||||
closeProvider: (providerGeneration, reason) =>
|
||||
this.dependencies.closeProvider?.(providerGeneration, reason)
|
||||
})
|
||||
}
|
||||
|
||||
acceptData(event: SshPtyOutputDataEvent): Promise<SshPtyOutputReceipt> {
|
||||
try {
|
||||
this.validateDataEvent(event)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
let projection: LegacySshProjectionSemantics | undefined
|
||||
const receipt = this.admission.accept(
|
||||
{ ptyId: event.id, providerGeneration: event.providerGeneration },
|
||||
event.data,
|
||||
event.rawLength,
|
||||
() => {
|
||||
const expectedSequence = this.dependencies.getModelSequence(event.id) + event.rawLength
|
||||
const reservation = this.projections.reserve({
|
||||
ptyId: event.id,
|
||||
providerGeneration: event.providerGeneration,
|
||||
ptyIncarnation: event.ptyIncarnation,
|
||||
data: event.data,
|
||||
sequenceEnd: expectedSequence,
|
||||
rawLength: event.rawLength,
|
||||
transformed: event.transformed
|
||||
})
|
||||
let model: { sequence: number; completion: Promise<void> }
|
||||
try {
|
||||
model = this.dependencies.acceptModel(event)
|
||||
} catch (error) {
|
||||
this.projections.rollback(reservation)
|
||||
throw error
|
||||
}
|
||||
projection = this.projections.commit(reservation)
|
||||
try {
|
||||
this.dependencies.project(event, projection)
|
||||
} catch {
|
||||
const id = projection.identity.projectionSemanticsId
|
||||
if (!this.projections.transferUnpublished(id, 'projection-admission-failed')) {
|
||||
this.projections.transfer([id], 'projection-admission-failed')
|
||||
}
|
||||
}
|
||||
return model
|
||||
}
|
||||
)
|
||||
return receipt.then(
|
||||
(modelReceipt) => {
|
||||
if (!projection) {
|
||||
throw outputIntakeError('ssh_projection_receipt_missing')
|
||||
}
|
||||
return Object.freeze({ ...modelReceipt, projection })
|
||||
},
|
||||
(error) => {
|
||||
if (projection) {
|
||||
this.projections.transfer(
|
||||
[projection.identity.projectionSemanticsId],
|
||||
'model-admission-failed'
|
||||
)
|
||||
}
|
||||
this.dependencies.closeProvider?.(event.providerGeneration, 'model-admission-failed')
|
||||
throw error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async acceptExit(event: SshPtyOutputExitEvent): Promise<void> {
|
||||
this.validateGeneration(event)
|
||||
const sealKey = sshPtyGenerationKey(event.id, event.providerGeneration)
|
||||
if (this.sealedPtys.has(sealKey)) {
|
||||
throw outputIntakeError('ssh_output_duplicate_exit')
|
||||
}
|
||||
this.sealedPtys.add(sealKey)
|
||||
await this.withExitDeadline(
|
||||
event.providerGeneration,
|
||||
this.admission.whenIdle({
|
||||
ptyId: event.id,
|
||||
providerGeneration: event.providerGeneration
|
||||
})
|
||||
)
|
||||
this.validateGeneration(event)
|
||||
try {
|
||||
this.dependencies.finalizeExit(event)
|
||||
this.projections.closePty(
|
||||
event.id,
|
||||
event.providerGeneration,
|
||||
event.ptyIncarnation,
|
||||
'pty-exit'
|
||||
)
|
||||
} catch (error) {
|
||||
this.projections.closePty(
|
||||
event.id,
|
||||
event.providerGeneration,
|
||||
event.ptyIncarnation,
|
||||
'pty-exit-finalize-failed'
|
||||
)
|
||||
this.dependencies.closeProvider?.(event.providerGeneration, 'pty-exit-finalize-failed')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
publishProjectionPrefix(
|
||||
ids: readonly string[],
|
||||
displayChars: number,
|
||||
accountingChars: number
|
||||
): void {
|
||||
this.projections.publishPrefix(ids, displayChars, accountingChars)
|
||||
}
|
||||
|
||||
settleProjectionPrefix(ptyId: string, accountingChars: number): number {
|
||||
return this.projections.settlePublishedPrefix(ptyId, accountingChars)
|
||||
}
|
||||
|
||||
transferProjections(ids: readonly string[], reason: string): number {
|
||||
return this.projections.transfer(ids, reason)
|
||||
}
|
||||
|
||||
transferPtyProjections(ptyId: string, reason: string): number {
|
||||
return this.projections.transferPty(ptyId, reason)
|
||||
}
|
||||
|
||||
hasProjectionFromGeneration(ids: readonly string[], providerGeneration: number): boolean {
|
||||
return ids.some(
|
||||
(id) => this.projections.get(id)?.identity.providerGeneration === providerGeneration
|
||||
)
|
||||
}
|
||||
|
||||
closeGeneration(providerGeneration: number, reason: string): void {
|
||||
this.closedGenerations.add(providerGeneration)
|
||||
this.admission.closeGeneration(providerGeneration, reason)
|
||||
this.dependencies.onGenerationClosed?.(providerGeneration, reason)
|
||||
this.projections.closeGeneration(providerGeneration, reason)
|
||||
const barriers = this.exitBarriersByGeneration.get(providerGeneration)
|
||||
if (barriers) {
|
||||
for (const barrier of barriers) {
|
||||
clearTimeout(barrier.timer)
|
||||
barrier.reject(outputIntakeError(reason))
|
||||
}
|
||||
this.exitBarriersByGeneration.delete(providerGeneration)
|
||||
}
|
||||
for (const [ptyId, generation] of this.latestGenerationByPty) {
|
||||
if (generation === providerGeneration) {
|
||||
this.latestGenerationByPty.delete(ptyId)
|
||||
this.incarnationByPty.delete(ptyId)
|
||||
this.sealedPtys.delete(sshPtyGenerationKey(ptyId, generation))
|
||||
}
|
||||
}
|
||||
const generationPrefix = `${providerGeneration}\0`
|
||||
for (const key of this.sealedPtys) {
|
||||
if (key.startsWith(generationPrefix)) {
|
||||
this.sealedPtys.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
for (const generation of new Set(this.latestGenerationByPty.values())) {
|
||||
this.closeGeneration(generation, 'ssh_output_intake_disposed')
|
||||
}
|
||||
this.admission.dispose()
|
||||
}
|
||||
|
||||
getDebugSnapshot() {
|
||||
return {
|
||||
model: this.admission.getDebugSnapshot(),
|
||||
projection: this.projections.getDebugSnapshot(),
|
||||
exitBarriers: Array.from(this.exitBarriersByGeneration.values()).reduce(
|
||||
(total, barriers) => total + barriers.size,
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validateDataEvent(event: SshPtyOutputDataEvent): void {
|
||||
if (this.disposed) {
|
||||
throw outputIntakeError('ssh_output_intake_disposed')
|
||||
}
|
||||
if (
|
||||
!event.id ||
|
||||
!event.ptyIncarnation ||
|
||||
!Number.isSafeInteger(event.providerGeneration) ||
|
||||
event.providerGeneration <= 0 ||
|
||||
!validOutputLength(event.rawLength) ||
|
||||
(event.transformed && event.rawLength < 0)
|
||||
) {
|
||||
throw outputIntakeError('ssh_output_invalid_event')
|
||||
}
|
||||
this.validateGeneration(event)
|
||||
if (this.sealedPtys.has(sshPtyGenerationKey(event.id, event.providerGeneration))) {
|
||||
throw outputIntakeError('ssh_output_after_exit')
|
||||
}
|
||||
}
|
||||
|
||||
private validateGeneration(event: {
|
||||
id: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
}): void {
|
||||
if (this.disposed) {
|
||||
throw outputIntakeError('ssh_output_intake_disposed')
|
||||
}
|
||||
if (this.closedGenerations.has(event.providerGeneration)) {
|
||||
throw outputIntakeError('ssh_output_stale_generation')
|
||||
}
|
||||
const generation = this.latestGenerationByPty.get(event.id)
|
||||
if (generation !== undefined && event.providerGeneration < generation) {
|
||||
throw outputIntakeError('ssh_output_stale_generation')
|
||||
}
|
||||
const incarnation = this.incarnationByPty.get(event.id)
|
||||
if (
|
||||
generation === event.providerGeneration &&
|
||||
incarnation !== undefined &&
|
||||
incarnation !== event.ptyIncarnation
|
||||
) {
|
||||
throw outputIntakeError('ssh_output_stale_incarnation')
|
||||
}
|
||||
if (generation === undefined || event.providerGeneration > generation) {
|
||||
this.latestGenerationByPty.set(event.id, event.providerGeneration)
|
||||
this.incarnationByPty.set(event.id, event.ptyIncarnation)
|
||||
}
|
||||
}
|
||||
|
||||
private withExitDeadline(providerGeneration: number, promise: Promise<void>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const barrier: SshPtyExitBarrier = {
|
||||
timer: setTimeout(() => {
|
||||
this.removeExitBarrier(providerGeneration, barrier)
|
||||
this.dependencies.closeProvider?.(providerGeneration, 'ssh-exit-barrier-timeout')
|
||||
reject(outputIntakeError('ssh_exit_barrier_timeout'))
|
||||
}, this.exitBarrierMs),
|
||||
reject
|
||||
}
|
||||
barrier.timer.unref?.()
|
||||
let barriers = this.exitBarriersByGeneration.get(providerGeneration)
|
||||
if (!barriers) {
|
||||
barriers = new Set()
|
||||
this.exitBarriersByGeneration.set(providerGeneration, barriers)
|
||||
}
|
||||
barriers.add(barrier)
|
||||
void promise.then(
|
||||
() => {
|
||||
clearTimeout(barrier.timer)
|
||||
this.removeExitBarrier(providerGeneration, barrier)
|
||||
resolve()
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(barrier.timer)
|
||||
this.removeExitBarrier(providerGeneration, barrier)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private removeExitBarrier(providerGeneration: number, barrier: SshPtyExitBarrier): void {
|
||||
const barriers = this.exitBarriersByGeneration.get(providerGeneration)
|
||||
barriers?.delete(barrier)
|
||||
if (barriers?.size === 0) {
|
||||
this.exitBarriersByGeneration.delete(providerGeneration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ function createSubscription() {
|
||||
replayListeners: replayListeners as never,
|
||||
exitListeners: exitListeners as never,
|
||||
livePtyIds,
|
||||
recordExit
|
||||
recordExit,
|
||||
providerGeneration: 7,
|
||||
resolvePtyIncarnation: (id) => `incarnation:${id}`
|
||||
})
|
||||
|
||||
const handler = mux.onNotification.mock.calls[0]?.[0] as (
|
||||
@@ -70,6 +72,8 @@ describe('subscribeSshPtyNotifications', () => {
|
||||
expect(onData).toHaveBeenCalledWith({
|
||||
id: 'ssh:conn@@pty-1',
|
||||
data: 'hello',
|
||||
providerGeneration: 7,
|
||||
ptyIncarnation: 'incarnation:pty-1',
|
||||
sequenceChars: 5,
|
||||
seq: 9
|
||||
})
|
||||
@@ -92,6 +96,8 @@ describe('subscribeSshPtyNotifications', () => {
|
||||
expect(onExit).toHaveBeenCalledWith({
|
||||
id: 'ssh:conn@@pty-1',
|
||||
code: 0,
|
||||
providerGeneration: 7,
|
||||
ptyIncarnation: 'incarnation:pty-1',
|
||||
incarnationId: 'incarnation-1'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,8 @@ export function subscribeSshPtyNotifications(args: {
|
||||
exitListeners: Set<SshPtyExitCallback>
|
||||
livePtyIds: Set<string>
|
||||
recordExit: (relayPtyId: string, incarnationId: unknown) => void
|
||||
providerGeneration: number
|
||||
resolvePtyIncarnation: (relayPtyId: string, incarnationId?: unknown) => string
|
||||
}): () => void {
|
||||
return args.mux.onNotification((method, params) => {
|
||||
// Why: mux delivers every method to generic handlers; non-PTY payloads
|
||||
@@ -30,12 +32,15 @@ export function subscribeSshPtyNotifications(args: {
|
||||
const relayPtyId = params.id
|
||||
const id = args.toAppPtyId(relayPtyId)
|
||||
if (method === 'pty.exit') {
|
||||
const ptyIncarnation = args.resolvePtyIncarnation(relayPtyId, params.incarnationId)
|
||||
args.recordExit(relayPtyId, params.incarnationId)
|
||||
args.livePtyIds.delete(id)
|
||||
for (const listener of args.exitListeners) {
|
||||
listener({
|
||||
id,
|
||||
code: params.code as number,
|
||||
providerGeneration: args.providerGeneration,
|
||||
ptyIncarnation,
|
||||
...(isPtyIncarnationId(params.incarnationId)
|
||||
? { incarnationId: params.incarnationId }
|
||||
: {})
|
||||
@@ -54,6 +59,8 @@ export function subscribeSshPtyNotifications(args: {
|
||||
listener({
|
||||
id,
|
||||
data: params.data as string,
|
||||
providerGeneration: args.providerGeneration,
|
||||
ptyIncarnation: args.resolvePtyIncarnation(relayPtyId, params.incarnationId),
|
||||
...(typeof params.rawLength === 'number' ? { sequenceChars: params.rawLength } : {}),
|
||||
...(params.transformed === true ? { transformed: true } : {}),
|
||||
...(typeof params.seq === 'number' ? { seq: params.seq } : {})
|
||||
|
||||
@@ -11,6 +11,8 @@ export type RemoteCliBridgeEnv = {
|
||||
export type SshPtyDataCallback = (payload: {
|
||||
id: string
|
||||
data: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
@@ -19,5 +21,13 @@ export type SshPtyReplayCallback = (payload: { id: string; data: string }) => vo
|
||||
export type SshPtyExitCallback = (payload: {
|
||||
id: string
|
||||
code: number
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
incarnationId?: PtyIncarnationId
|
||||
}) => void
|
||||
|
||||
export type SshPtyDeliveryPauseAdapter = (args: {
|
||||
id: string
|
||||
providerGeneration: number
|
||||
paused: boolean
|
||||
}) => void
|
||||
|
||||
@@ -32,7 +32,9 @@ it('rejects a fresh SSH PTY whose exit shares the spawn response batch', async (
|
||||
expect(exitListener).toHaveBeenCalledWith({
|
||||
id: 'ssh:conn-1@@pty-raced',
|
||||
code: 0,
|
||||
incarnationId: 'incarnation-raced'
|
||||
incarnationId: 'incarnation-raced',
|
||||
providerGeneration: expect.any(Number),
|
||||
ptyIncarnation: 'incarnation-raced'
|
||||
})
|
||||
mux.request.mockResolvedValue({ id: 'pty-next', incarnationId: 'incarnation-next' })
|
||||
await expect(provider.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import type {
|
||||
SshPtyDataCallback,
|
||||
SshPtyDeliveryPauseAdapter,
|
||||
SshPtyExitCallback,
|
||||
SshPtyReplayCallback
|
||||
} from './ssh-pty-provider-contract'
|
||||
import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing'
|
||||
|
||||
export class SshPtyProviderOutputState {
|
||||
private readonly dataListeners = new Set<SshPtyDataCallback>()
|
||||
private readonly replayListeners = new Set<SshPtyReplayCallback>()
|
||||
private readonly exitListeners = new Set<SshPtyExitCallback>()
|
||||
private readonly incarnationByRelayPtyId = new Map<string, string>()
|
||||
private readonly pausedRelayPtyIds = new Set<string>()
|
||||
private deliveryPauseAdapter: SshPtyDeliveryPauseAdapter | null = null
|
||||
private legacyIncarnationSerial = 1
|
||||
private unsubscribeNotifications: (() => void) | null
|
||||
|
||||
constructor(
|
||||
private readonly providerGeneration: number,
|
||||
args: {
|
||||
mux: SshChannelMultiplexer
|
||||
toAppPtyId: (id: string) => string
|
||||
livePtyIds: Set<string>
|
||||
recordExit: (relayPtyId: string, incarnationId: unknown) => void
|
||||
}
|
||||
) {
|
||||
this.unsubscribeNotifications = subscribeSshPtyNotifications({
|
||||
...args,
|
||||
dataListeners: this.dataListeners,
|
||||
replayListeners: this.replayListeners,
|
||||
exitListeners: this.exitListeners,
|
||||
providerGeneration,
|
||||
resolvePtyIncarnation: (relayPtyId, incarnationId) =>
|
||||
this.resolvePtyIncarnation(relayPtyId, incarnationId),
|
||||
recordExit: (relayPtyId, incarnationId) => {
|
||||
args.recordExit(relayPtyId, incarnationId)
|
||||
this.incarnationByRelayPtyId.delete(relayPtyId)
|
||||
this.pausedRelayPtyIds.delete(relayPtyId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.resumePausedDeliveries()
|
||||
this.unsubscribeNotifications?.()
|
||||
this.unsubscribeNotifications = null
|
||||
this.dataListeners.clear()
|
||||
this.replayListeners.clear()
|
||||
this.exitListeners.clear()
|
||||
this.incarnationByRelayPtyId.clear()
|
||||
this.deliveryPauseAdapter = null
|
||||
}
|
||||
|
||||
onData(callback: SshPtyDataCallback): () => void {
|
||||
this.dataListeners.add(callback)
|
||||
return () => this.dataListeners.delete(callback)
|
||||
}
|
||||
|
||||
onReplay(callback: SshPtyReplayCallback): () => void {
|
||||
this.replayListeners.add(callback)
|
||||
return () => this.replayListeners.delete(callback)
|
||||
}
|
||||
|
||||
onExit(callback: SshPtyExitCallback): () => void {
|
||||
this.exitListeners.add(callback)
|
||||
return () => this.exitListeners.delete(callback)
|
||||
}
|
||||
|
||||
setDeliveryPauseAdapter(adapter: SshPtyDeliveryPauseAdapter | null): void {
|
||||
if (adapter !== this.deliveryPauseAdapter) {
|
||||
this.resumePausedDeliveries()
|
||||
}
|
||||
this.deliveryPauseAdapter = adapter
|
||||
}
|
||||
|
||||
hasDeliveryPauseAdapter(): boolean {
|
||||
return this.deliveryPauseAdapter !== null
|
||||
}
|
||||
|
||||
pause(id: string): void {
|
||||
if (!this.deliveryPauseAdapter || this.pausedRelayPtyIds.has(id)) {
|
||||
return
|
||||
}
|
||||
this.pausedRelayPtyIds.add(id)
|
||||
this.deliveryPauseAdapter({ id, providerGeneration: this.providerGeneration, paused: true })
|
||||
}
|
||||
|
||||
resume(id: string): void {
|
||||
if (!this.deliveryPauseAdapter || !this.pausedRelayPtyIds.delete(id)) {
|
||||
return
|
||||
}
|
||||
this.deliveryPauseAdapter({ id, providerGeneration: this.providerGeneration, paused: false })
|
||||
}
|
||||
|
||||
rememberPtyIncarnation(relayPtyId: string, incarnationId: unknown): void {
|
||||
if (
|
||||
!this.incarnationByRelayPtyId.has(relayPtyId) &&
|
||||
typeof incarnationId === 'string' &&
|
||||
incarnationId.length > 0
|
||||
) {
|
||||
this.incarnationByRelayPtyId.set(relayPtyId, incarnationId)
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePtyIncarnation(relayPtyId: string, incarnationId: unknown): string {
|
||||
this.rememberPtyIncarnation(relayPtyId, incarnationId)
|
||||
let resolved = this.incarnationByRelayPtyId.get(relayPtyId)
|
||||
if (!resolved) {
|
||||
resolved = `legacy:${this.providerGeneration}:${this.legacyIncarnationSerial++}:${relayPtyId}`
|
||||
this.incarnationByRelayPtyId.set(relayPtyId, resolved)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private resumePausedDeliveries(): void {
|
||||
const adapter = this.deliveryPauseAdapter
|
||||
if (!adapter) {
|
||||
this.pausedRelayPtyIds.clear()
|
||||
return
|
||||
}
|
||||
for (const id of this.pausedRelayPtyIds) {
|
||||
try {
|
||||
adapter({ id, providerGeneration: this.providerGeneration, paused: false })
|
||||
} catch {
|
||||
/* Generation close is the fallback cleanup proof. */
|
||||
}
|
||||
}
|
||||
this.pausedRelayPtyIds.clear()
|
||||
}
|
||||
}
|
||||
@@ -135,10 +135,17 @@ describe('SshPtyProvider process listings and events', () => {
|
||||
notify('pty.replay', { id: 'pty-1', data: 'buffered output' })
|
||||
notify('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' })
|
||||
|
||||
expect(dataHandler).toHaveBeenNthCalledWith(1, { id: scopedPty1, data: 'output' })
|
||||
expect(dataHandler).toHaveBeenNthCalledWith(1, {
|
||||
id: scopedPty1,
|
||||
data: 'output',
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'legacy:1:1:pty-1'
|
||||
})
|
||||
expect(dataHandler).toHaveBeenNthCalledWith(2, {
|
||||
id: scopedPty1,
|
||||
data: '',
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'legacy:1:1:pty-1',
|
||||
sequenceChars: 9,
|
||||
seq: 9,
|
||||
transformed: true
|
||||
@@ -147,10 +154,28 @@ describe('SshPtyProvider process listings and events', () => {
|
||||
expect(exitHandler).toHaveBeenCalledWith({
|
||||
id: scopedPty1,
|
||||
code: 0,
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'legacy:1:1:pty-1',
|
||||
incarnationId: 'incarnation-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps fallback admission identity stable when spawn metadata arrives later', async () => {
|
||||
const dataHandler = vi.fn()
|
||||
provider.onData(dataHandler)
|
||||
const notify = mux.onNotification.mock.calls[0][0]
|
||||
|
||||
notify('pty.data', { id: 'pty-1', data: 'before-response' })
|
||||
mux.request.mockResolvedValue({ id: 'pty-1', incarnationId: 'incarnation-1' })
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
notify('pty.data', { id: 'pty-1', data: 'after-response' })
|
||||
|
||||
expect(dataHandler.mock.calls.map(([payload]) => payload.ptyIncarnation)).toEqual([
|
||||
'legacy:1:1:pty-1',
|
||||
'legacy:1:1:pty-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('supports listener removal, fanout, and connection namespaces', () => {
|
||||
const removed = vi.fn()
|
||||
const first = vi.fn()
|
||||
@@ -170,6 +195,29 @@ describe('SshPtyProvider process listings and events', () => {
|
||||
const other = vi.fn()
|
||||
otherProvider.onData(other)
|
||||
otherMux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'second' })
|
||||
expect(other).toHaveBeenCalledWith({ id: 'ssh:conn-2@@pty-1', data: 'second' })
|
||||
expect(other).toHaveBeenCalledWith({
|
||||
id: 'ssh:conn-2@@pty-1',
|
||||
data: 'second',
|
||||
providerGeneration: 1,
|
||||
ptyIncarnation: 'legacy:1:1:pty-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes pause delivery adapters and resumes them during cleanup', () => {
|
||||
const adapter = vi.fn()
|
||||
provider.setPtyDeliveryPauseAdapter(adapter)
|
||||
|
||||
provider.pauseProducer(scopedPty1)
|
||||
provider.pauseProducer(scopedPty1)
|
||||
provider.resumeProducer(scopedPty1)
|
||||
provider.pauseProducer(scopedPty1)
|
||||
provider.dispose()
|
||||
|
||||
expect(adapter.mock.calls).toEqual([
|
||||
[{ id: 'pty-1', providerGeneration: 1, paused: true }],
|
||||
[{ id: 'pty-1', providerGeneration: 1, paused: false }],
|
||||
[{ id: 'pty-1', providerGeneration: 1, paused: true }],
|
||||
[{ id: 'pty-1', providerGeneration: 1, paused: false }]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,11 @@ import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size'
|
||||
import type {
|
||||
RemoteCliBridgeEnv,
|
||||
SshPtyDataCallback,
|
||||
SshPtyDeliveryPauseAdapter,
|
||||
SshPtyExitCallback,
|
||||
SshPtyReplayCallback
|
||||
} from './ssh-pty-provider-contract'
|
||||
import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing'
|
||||
import { SshPtyProviderOutputState } from './ssh-pty-provider-output-state'
|
||||
import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation'
|
||||
import {
|
||||
assertSshAgentSessionCreateResult,
|
||||
@@ -34,46 +35,35 @@ function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: numbe
|
||||
export class SshPtyProvider implements IPtyProvider {
|
||||
private mux: SshChannelMultiplexer
|
||||
private connectionId: string
|
||||
private dataListeners = new Set<SshPtyDataCallback>()
|
||||
private replayListeners = new Set<SshPtyReplayCallback>()
|
||||
private exitListeners = new Set<SshPtyExitCallback>()
|
||||
private livePtyIds = new Set<string>()
|
||||
// Why: stale notification callbacks must not outlive a disconnected provider.
|
||||
private unsubscribeNotifications: (() => void) | null = null
|
||||
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
|
||||
private readonly agentSessionCapabilities: SshAgentSessionCapabilities
|
||||
private spawnExitRaces = new SshPtySpawnExitRaceTracker()
|
||||
private readonly outputState: SshPtyProviderOutputState
|
||||
|
||||
constructor(
|
||||
connectionId: string,
|
||||
mux: SshChannelMultiplexer,
|
||||
private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv
|
||||
private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv,
|
||||
readonly providerGeneration = 1
|
||||
) {
|
||||
this.connectionId = connectionId
|
||||
this.mux = mux
|
||||
this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux)
|
||||
this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId)
|
||||
|
||||
this.unsubscribeNotifications = subscribeSshPtyNotifications({
|
||||
this.outputState = new SshPtyProviderOutputState(providerGeneration, {
|
||||
mux,
|
||||
toAppPtyId: (id) => this.toAppPtyId(id),
|
||||
dataListeners: this.dataListeners,
|
||||
replayListeners: this.replayListeners,
|
||||
exitListeners: this.exitListeners,
|
||||
livePtyIds: this.livePtyIds,
|
||||
recordExit: (relayPtyId, incarnationId) =>
|
||||
recordExit: (relayPtyId, incarnationId) => {
|
||||
this.spawnExitRaces.recordExit(relayPtyId, incarnationId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.unsubscribeNotifications) {
|
||||
this.unsubscribeNotifications()
|
||||
this.unsubscribeNotifications = null
|
||||
}
|
||||
this.dataListeners.clear()
|
||||
this.replayListeners.clear()
|
||||
this.exitListeners.clear()
|
||||
this.outputState.dispose()
|
||||
this.livePtyIds.clear()
|
||||
}
|
||||
|
||||
@@ -161,6 +151,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
const id = this.toAppPtyId(spawnResult.id)
|
||||
this.outputState.rememberPtyIncarnation(spawnResult.id, spawnResult.incarnationId)
|
||||
this.livePtyIds.add(id)
|
||||
return {
|
||||
...spawnResult,
|
||||
@@ -209,7 +200,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
// be filtered before they reach the renderer. The expected identity lets the
|
||||
// relay reject a cross-generation id collision instead of reattaching this
|
||||
// lease to a different pane's freshly spawned PTY.
|
||||
return parseSshPtyAttachResult(
|
||||
const result = parseSshPtyAttachResult(
|
||||
await this.mux.request('pty.attach', {
|
||||
id: this.toRelayPtyId(id),
|
||||
suppressReplayNotification: true,
|
||||
@@ -217,6 +208,8 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
...(expected?.tabId ? { expectedTabId: expected.tabId } : {})
|
||||
})
|
||||
)
|
||||
this.outputState.rememberPtyIncarnation(this.toRelayPtyId(id), result.incarnationId)
|
||||
return result
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
@@ -308,6 +301,8 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id))
|
||||
for (const process of processes) {
|
||||
this.livePtyIds.add(process.id)
|
||||
const relayPtyId = this.toRelayPtyId(process.id)
|
||||
this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId)
|
||||
}
|
||||
return processes
|
||||
}
|
||||
@@ -326,18 +321,28 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
return result as { name: string; path: string }[]
|
||||
}
|
||||
|
||||
onData(callback: SshPtyDataCallback): () => void {
|
||||
this.dataListeners.add(callback)
|
||||
return () => this.dataListeners.delete(callback)
|
||||
onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback)
|
||||
onReplay = (callback: SshPtyReplayCallback): (() => void) => this.outputState.onReplay(callback)
|
||||
onExit = (callback: SshPtyExitCallback): (() => void) => this.outputState.onExit(callback)
|
||||
|
||||
setPtyDeliveryPauseAdapter(adapter: SshPtyDeliveryPauseAdapter | null): void {
|
||||
this.outputState.setDeliveryPauseAdapter(adapter)
|
||||
}
|
||||
|
||||
onReplay(callback: SshPtyReplayCallback): () => void {
|
||||
this.replayListeners.add(callback)
|
||||
return () => this.replayListeners.delete(callback)
|
||||
hasPtyDeliveryPauseAdapter(): boolean {
|
||||
return this.outputState.hasDeliveryPauseAdapter()
|
||||
}
|
||||
|
||||
onExit(callback: SshPtyExitCallback): () => void {
|
||||
this.exitListeners.add(callback)
|
||||
return () => this.exitListeners.delete(callback)
|
||||
pauseProducer(id: string): void {
|
||||
this.outputState.pause(this.toRelayPtyId(id))
|
||||
}
|
||||
|
||||
resumeProducer(id: string): void {
|
||||
this.outputState.resume(this.toRelayPtyId(id))
|
||||
}
|
||||
|
||||
closeOutputIntake(reason: string): void {
|
||||
this.mux.dispose('connection_lost')
|
||||
console.error('[ssh-pty-provider] closed after bounded output intake failure', { reason })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,6 +1417,11 @@ type RuntimeHeadlessTerminal = {
|
||||
writeChain: Promise<void>
|
||||
}
|
||||
|
||||
export type RuntimePtyDataAdmission = Readonly<{
|
||||
sequence: number
|
||||
completion: Promise<void>
|
||||
}>
|
||||
|
||||
type RuntimeVisibleTerminalState = {
|
||||
lines: string[]
|
||||
isAlternateScreen: boolean
|
||||
@@ -7714,12 +7719,30 @@ export class OrcaRuntimeService {
|
||||
* Handles incoming data from a PTY process, running agent detection,
|
||||
* updating terminal tail buffers, and triggering foreground agent refreshes.
|
||||
*/
|
||||
onPtyData(
|
||||
acceptPtyDataBounded(
|
||||
ptyId: string,
|
||||
data: string,
|
||||
at: number,
|
||||
sequenceChars = data.length,
|
||||
transformed = false
|
||||
): RuntimePtyDataAdmission {
|
||||
let completion: Promise<void> | null = null
|
||||
const sequence = this.onPtyData(ptyId, data, at, sequenceChars, transformed, (receipt) => {
|
||||
completion = receipt
|
||||
})
|
||||
if (!completion) {
|
||||
throw new Error('PTY model admission receipt was not captured')
|
||||
}
|
||||
return Object.freeze({ sequence, completion })
|
||||
}
|
||||
|
||||
onPtyData(
|
||||
ptyId: string,
|
||||
data: string,
|
||||
at: number,
|
||||
sequenceChars = data.length,
|
||||
transformed = false,
|
||||
captureModelReceipt?: (completion: Promise<void>) => void
|
||||
): number {
|
||||
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars
|
||||
this.ptyOutputSequenceById.set(ptyId, outputSequence)
|
||||
@@ -7758,7 +7781,13 @@ export class OrcaRuntimeService {
|
||||
// applyTrackedPtyTitle) in byte order, superseding main's inline
|
||||
// extractLastOscTitleForPty block (#7880/#7852 title/status semantics are
|
||||
// preserved via the tracker + detectAgentStatusFromTitle path).
|
||||
this.trackHeadlessTerminalData(ptyId, data, outputSequence, forwardQueryReplies)
|
||||
const modelCompletion = this.trackHeadlessTerminalData(
|
||||
ptyId,
|
||||
data,
|
||||
outputSequence,
|
||||
forwardQueryReplies
|
||||
)
|
||||
captureModelReceipt?.(modelCompletion)
|
||||
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
|
||||
const ptyTailBefore = pty
|
||||
@@ -9278,19 +9307,17 @@ export class OrcaRuntimeService {
|
||||
data: string,
|
||||
outputSequence: number,
|
||||
forwardQueryReplies = false
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const state = this.getOrCreateHeadlessTerminal(ptyId)
|
||||
state.writeChain = state.writeChain
|
||||
.then(async () => {
|
||||
// Why: the ingestion-time ownership decision is closed over this
|
||||
// chain link; async scheduling cannot retroactively change it.
|
||||
await state.emulator.write(data, { forwardQueryReplies })
|
||||
state.outputSequence = outputSequence
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort state tracking; live streaming must continue even if
|
||||
// xterm rejects a malformed or raced write during shutdown.
|
||||
})
|
||||
const completion = state.writeChain.then(async () => {
|
||||
// Why: the ingestion-time ownership decision is closed over this
|
||||
// chain link; async scheduling cannot retroactively change it.
|
||||
await state.emulator.write(data, { forwardQueryReplies })
|
||||
state.outputSequence = outputSequence
|
||||
})
|
||||
// Legacy callers remain best-effort; bounded SSH admission observes the raw receipt.
|
||||
state.writeChain = completion.catch(() => {})
|
||||
return completion
|
||||
}
|
||||
|
||||
/** Shared factory for the per-PTY runtime emulators (seed, hydration, and
|
||||
|
||||
@@ -2,9 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SshRelaySession } from './ssh-relay-session'
|
||||
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
|
||||
|
||||
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
|
||||
const { acceptOutputDataMock, muxRequestMock } = vi.hoisted(() => ({
|
||||
acceptOutputDataMock: vi.fn().mockResolvedValue(undefined),
|
||||
muxRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
|
||||
vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({
|
||||
acceptSshPtyOutputData: acceptOutputDataMock,
|
||||
acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined),
|
||||
allocateSshPtyProviderGeneration: vi.fn(() => 23),
|
||||
closeSshPtyOutputGeneration: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh-channel-multiplexer', () => ({
|
||||
SshChannelMultiplexer: class MockSshChannelMultiplexer {
|
||||
@@ -95,18 +104,28 @@ describe('SshRelaySession data delivery', () => {
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
}) => void
|
||||
|
||||
onData({ id: 'ssh-pty-1', data: '', sequenceChars: 9, transformed: true })
|
||||
|
||||
expect(runtime.onPtyData).toHaveBeenCalledWith('ssh-pty-1', '', expect.any(Number), 9, true)
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
|
||||
onData({
|
||||
id: 'ssh-pty-1',
|
||||
data: '',
|
||||
sequenceChars: 9,
|
||||
transformed: true,
|
||||
seq: 17,
|
||||
rawLength: 9
|
||||
providerGeneration: 23,
|
||||
ptyIncarnation: 'incarnation-1'
|
||||
})
|
||||
|
||||
expect(acceptOutputDataMock).toHaveBeenCalledWith({
|
||||
id: 'ssh-pty-1',
|
||||
data: '',
|
||||
providerGeneration: 23,
|
||||
ptyIncarnation: 'incarnation-1',
|
||||
rawLength: 9,
|
||||
transformed: true
|
||||
})
|
||||
expect(runtime.onPtyData).not.toHaveBeenCalled()
|
||||
expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SshRelaySession } from './ssh-relay-session'
|
||||
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
|
||||
|
||||
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
|
||||
const { acceptOutputExitMock, muxRequestMock } = vi.hoisted(() => ({
|
||||
acceptOutputExitMock: vi.fn().mockResolvedValue(undefined),
|
||||
muxRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
|
||||
vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({
|
||||
acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined),
|
||||
acceptSshPtyOutputExit: acceptOutputExitMock,
|
||||
allocateSshPtyProviderGeneration: vi.fn(() => 31),
|
||||
closeSshPtyOutputGeneration: vi.fn()
|
||||
}))
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') }))
|
||||
vi.mock('./ssh-channel-multiplexer', () => ({
|
||||
SshChannelMultiplexer: class MockSshChannelMultiplexer {
|
||||
@@ -90,22 +99,41 @@ describe('SSH relay PTY incarnation exits', () => {
|
||||
id: string
|
||||
code: number
|
||||
incarnationId: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
}) => void
|
||||
vi.mocked(isCurrentPtyExit).mockReturnValueOnce(false)
|
||||
|
||||
onExit({ id: 'ssh:target-1@@pty-reused', code: 0, incarnationId: 'old-incarnation' })
|
||||
onExit({
|
||||
id: 'ssh:target-1@@pty-reused',
|
||||
code: 0,
|
||||
incarnationId: 'old-incarnation',
|
||||
providerGeneration: 31,
|
||||
ptyIncarnation: 'old-incarnation'
|
||||
})
|
||||
|
||||
expect(clearProviderPtyState).not.toHaveBeenCalled()
|
||||
expect(deletePtyOwnership).not.toHaveBeenCalled()
|
||||
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled()
|
||||
expect(acceptOutputExitMock).not.toHaveBeenCalled()
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything())
|
||||
|
||||
onExit({ id: 'ssh:target-1@@pty-reused', code: 7, incarnationId: 'current-incarnation' })
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(
|
||||
'ssh:target-1@@pty-reused',
|
||||
7,
|
||||
'current-incarnation'
|
||||
onExit({
|
||||
id: 'ssh:target-1@@pty-reused',
|
||||
code: 7,
|
||||
incarnationId: 'current-incarnation',
|
||||
providerGeneration: 31,
|
||||
ptyIncarnation: 'current-incarnation'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(acceptOutputExitMock).toHaveBeenCalledWith({
|
||||
id: 'ssh:target-1@@pty-reused',
|
||||
code: 7,
|
||||
providerGeneration: 31,
|
||||
ptyIncarnation: 'current-incarnation'
|
||||
})
|
||||
)
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SshRelaySession } from './ssh-relay-session'
|
||||
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
|
||||
|
||||
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
|
||||
const { acceptOutputExitMock, muxRequestMock } = vi.hoisted(() => ({
|
||||
acceptOutputExitMock: vi.fn().mockResolvedValue(undefined),
|
||||
muxRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
|
||||
vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({
|
||||
acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined),
|
||||
acceptSshPtyOutputExit: acceptOutputExitMock,
|
||||
allocateSshPtyProviderGeneration: vi.fn(() => 17),
|
||||
closeSshPtyOutputGeneration: vi.fn()
|
||||
}))
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') }))
|
||||
vi.mock('./ssh-channel-multiplexer', () => ({
|
||||
SshChannelMultiplexer: class MockSshChannelMultiplexer {
|
||||
@@ -85,14 +94,26 @@ function detachedLease() {
|
||||
}
|
||||
}
|
||||
|
||||
function emitExitDuringAttach(payload: { id: string; code: number; incarnationId?: string }): void {
|
||||
function emitExitDuringAttach(payload: {
|
||||
id: string
|
||||
code: number
|
||||
incarnationId?: string
|
||||
providerGeneration?: number
|
||||
ptyIncarnation?: string
|
||||
}): void {
|
||||
const registeredProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as {
|
||||
onExit: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const exitHandler = registeredProvider.onExit.mock.calls[0]?.[0] as
|
||||
| ((exit: typeof payload) => void)
|
||||
| undefined
|
||||
queueMicrotask(() => exitHandler?.(payload))
|
||||
queueMicrotask(() =>
|
||||
exitHandler?.({
|
||||
providerGeneration: 17,
|
||||
ptyIncarnation: payload.incarnationId ?? `legacy:${payload.id}`,
|
||||
...payload
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('SshRelaySession reconnect incarnation ordering', () => {
|
||||
@@ -175,7 +196,13 @@ describe('SshRelaySession reconnect incarnation ordering', () => {
|
||||
|
||||
await session.establish(mockConn)
|
||||
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(APP_PTY_ID, 0, incarnationId)
|
||||
expect(acceptOutputExitMock).toHaveBeenCalledWith({
|
||||
id: APP_PTY_ID,
|
||||
code: 0,
|
||||
providerGeneration: 17,
|
||||
ptyIncarnation: incarnationId
|
||||
})
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
expect(runtime.acceptPtyIncarnationForExit).toHaveBeenCalledWith(APP_PTY_ID, incarnationId)
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId)
|
||||
|
||||
@@ -8,12 +8,22 @@ import {
|
||||
import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types'
|
||||
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
|
||||
|
||||
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
|
||||
const { acceptOutputDataMock, muxRequestMock } = vi.hoisted(() => ({
|
||||
acceptOutputDataMock: vi.fn().mockResolvedValue(undefined),
|
||||
muxRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh-relay-deploy', () => ({
|
||||
deployAndLaunchRelay: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({
|
||||
acceptSshPtyOutputData: acceptOutputDataMock,
|
||||
acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined),
|
||||
allocateSshPtyProviderGeneration: vi.fn(() => 41),
|
||||
closeSshPtyOutputGeneration: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({
|
||||
execCommand: vi.fn().mockResolvedValue('')
|
||||
}))
|
||||
@@ -86,12 +96,6 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
}))
|
||||
|
||||
const { deployAndLaunchRelay } = await import('./ssh-relay-deploy')
|
||||
// Why: the hidden-delivery gate module is intentionally real (pure state, no
|
||||
// electron deps) so the SSH parity tests exercise the same gate main uses.
|
||||
const { markHiddenRendererPty, setRendererPtyDeliveryInterest } =
|
||||
await import('../ipc/pty-hidden-delivery-gate')
|
||||
const { _resetHiddenRendererPtyDeliveryGateForTest } =
|
||||
await import('../ipc/pty-hidden-delivery-gate')
|
||||
const { execCommand } = await import('./ssh-relay-deploy-helpers')
|
||||
const { getRemoteHostPlatform } = await import('./ssh-remote-platform')
|
||||
const {
|
||||
@@ -115,15 +119,11 @@ describe('SshRelaySession', () => {
|
||||
muxRequestMock.mockResolvedValue([])
|
||||
mockDeploySuccess()
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
})
|
||||
|
||||
it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => {
|
||||
it('hands each PTY data event exactly once to the bounded main intake', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
|
||||
const runtime = {
|
||||
onPtyData: vi.fn(() => 99),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() }
|
||||
const session = new SshRelaySession(
|
||||
'target-1',
|
||||
getMainWindow,
|
||||
@@ -138,53 +138,31 @@ describe('SshRelaySession', () => {
|
||||
const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: {
|
||||
id: string
|
||||
data: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
}) => void
|
||||
|
||||
markHiddenRendererPty('ssh-pty-1')
|
||||
onData({ id: 'ssh-pty-1', data: 'hidden ssh output' })
|
||||
|
||||
// Runtime ingestion still ran; renderer delivery shrank to one marker.
|
||||
expect(runtime.onPtyData).toHaveBeenCalledWith(
|
||||
'ssh-pty-1',
|
||||
'hidden ssh output',
|
||||
expect.any(Number),
|
||||
'hidden ssh output'.length,
|
||||
undefined
|
||||
)
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1)
|
||||
// Why out-of-band: an in-band empty pty:data sentinel is ambiguous with
|
||||
// chunks fully consumed by renderer OSC-9999 stripping.
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
|
||||
onData({
|
||||
id: 'ssh-pty-1',
|
||||
reason: 'hidden-drop',
|
||||
markerSeq: 99
|
||||
data: 'ssh output',
|
||||
providerGeneration: 41,
|
||||
ptyIncarnation: 'incarnation-1'
|
||||
})
|
||||
|
||||
onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' })
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Delivery interest (renderer sidecars) suppresses the gate — parity with
|
||||
// the local path in ipc/pty.ts.
|
||||
setRendererPtyDeliveryInterest('ssh-pty-1', true)
|
||||
onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' })
|
||||
expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
|
||||
expect(acceptOutputDataMock).toHaveBeenCalledTimes(1)
|
||||
expect(acceptOutputDataMock).toHaveBeenCalledWith({
|
||||
id: 'ssh-pty-1',
|
||||
data: 'sidecar ssh bytes',
|
||||
seq: 99,
|
||||
rawLength: 'sidecar ssh bytes'.length
|
||||
})
|
||||
|
||||
// Non-hidden PTYs are unaffected.
|
||||
onData({ id: 'ssh-pty-2', data: 'visible ssh output' })
|
||||
expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
|
||||
id: 'ssh-pty-2',
|
||||
data: 'visible ssh output',
|
||||
seq: 99,
|
||||
rawLength: 'visible ssh output'.length
|
||||
data: 'ssh output',
|
||||
providerGeneration: 41,
|
||||
ptyIncarnation: 'incarnation-1',
|
||||
rawLength: 'ssh output'.length,
|
||||
transformed: false
|
||||
})
|
||||
expect(runtime.onPtyData).not.toHaveBeenCalled()
|
||||
expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything())
|
||||
})
|
||||
|
||||
it('keeps hidden SSH delivery when the gate kill switch is off', async () => {
|
||||
it('keeps the bounded intake path when hidden delivery gating is disabled', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
|
||||
;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({
|
||||
terminalHiddenDeliveryGate: false
|
||||
@@ -197,16 +175,26 @@ describe('SshRelaySession', () => {
|
||||
const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: {
|
||||
id: string
|
||||
data: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
}) => void
|
||||
|
||||
markHiddenRendererPty('ssh-pty-1')
|
||||
onData({ id: 'ssh-pty-1', data: 'still delivered' })
|
||||
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
|
||||
onData({
|
||||
id: 'ssh-pty-1',
|
||||
data: 'still delivered',
|
||||
rawLength: 'still delivered'.length
|
||||
providerGeneration: 41,
|
||||
ptyIncarnation: 'incarnation-1'
|
||||
})
|
||||
|
||||
expect(acceptOutputDataMock).toHaveBeenCalledWith({
|
||||
id: 'ssh-pty-1',
|
||||
data: 'still delivered',
|
||||
providerGeneration: 41,
|
||||
ptyIncarnation: 'incarnation-1',
|
||||
rawLength: 'still delivered'.length,
|
||||
transformed: false
|
||||
})
|
||||
expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything())
|
||||
})
|
||||
|
||||
it('starts in idle state', () => {
|
||||
|
||||
@@ -37,10 +37,11 @@ import {
|
||||
isCurrentPtyExit
|
||||
} from '../ipc/pty'
|
||||
import {
|
||||
recordHiddenRendererPtyDataDrop,
|
||||
shouldDropHiddenRendererPtyData
|
||||
} from '../ipc/pty-hidden-delivery-gate'
|
||||
import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker'
|
||||
acceptSshPtyOutputData,
|
||||
acceptSshPtyOutputExit,
|
||||
allocateSshPtyProviderGeneration,
|
||||
closeSshPtyOutputGeneration
|
||||
} from '../ipc/ssh-pty-output-intake-registry'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider,
|
||||
@@ -151,6 +152,7 @@ export class SshRelaySession {
|
||||
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
|
||||
private forwardedReattachReplayByPty = new Map<string, ForwardedReplayFingerprint>()
|
||||
private pendingPtyReattaches = new Map<string, PendingPtyReattach>()
|
||||
private activePtyProviderGeneration: number | null = null
|
||||
|
||||
constructor(
|
||||
readonly targetId: string,
|
||||
@@ -532,7 +534,14 @@ export class SshRelaySession {
|
||||
|
||||
this.wireUpRemoteOrcaCli(mux)
|
||||
|
||||
const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined)
|
||||
const providerGeneration = allocateSshPtyProviderGeneration()
|
||||
const ptyProvider = new SshPtyProvider(
|
||||
this.targetId,
|
||||
mux,
|
||||
this.remoteCliBridgeEnv ?? undefined,
|
||||
providerGeneration
|
||||
)
|
||||
this.activePtyProviderGeneration = providerGeneration
|
||||
registerSshPtyProvider(this.targetId, ptyProvider)
|
||||
|
||||
const connection = this.requireReadyConnection()
|
||||
@@ -826,6 +835,10 @@ export class SshRelaySession {
|
||||
this.mux.dispose(reason)
|
||||
}
|
||||
this.mux = null
|
||||
if (this.activePtyProviderGeneration !== null) {
|
||||
closeSshPtyOutputGeneration(this.activePtyProviderGeneration, reason)
|
||||
this.activePtyProviderGeneration = null
|
||||
}
|
||||
|
||||
if (reason === 'shutdown') {
|
||||
clearPtyOwnershipForConnection(this.targetId)
|
||||
@@ -920,38 +933,15 @@ export class SshRelaySession {
|
||||
private wireUpPtyEvents(ptyProvider: SshPtyProvider): void {
|
||||
ptyProvider.onData((payload) => {
|
||||
const rawLength = payload.sequenceChars ?? payload.data.length
|
||||
const seq = this.runtime?.onPtyData(
|
||||
payload.id,
|
||||
payload.data,
|
||||
Date.now(),
|
||||
void acceptSshPtyOutputData({
|
||||
id: payload.id,
|
||||
data: payload.data,
|
||||
providerGeneration: payload.providerGeneration,
|
||||
ptyIncarnation: payload.ptyIncarnation,
|
||||
rawLength,
|
||||
payload.transformed
|
||||
)
|
||||
const win = this.getMainWindow()
|
||||
if (!win || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
// Why: hidden-delivery gate parity with ipc/pty.ts — latch model-restore out-of-band, never an in-band pty:data sentinel (OSC-9999-only chunks strip to empty).
|
||||
const store = this.store as { getSettings?: Store['getSettings'] }
|
||||
if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) {
|
||||
const drop = recordHiddenRendererPtyDataDrop(payload.id, rawLength)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
win.webContents.send('pty:modelRestoreNeeded', {
|
||||
id: payload.id,
|
||||
reason: 'hidden-drop',
|
||||
...(typeof seq === 'number' ? { markerSeq: seq } : {})
|
||||
} satisfies PtyModelRestoreNeededEvent)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (payload.data.length > 0 || payload.transformed) {
|
||||
win.webContents.send('pty:data', {
|
||||
...payload,
|
||||
...(typeof seq === 'number' ? { seq } : {}),
|
||||
rawLength,
|
||||
...(payload.transformed ? { transformed: true } : {})
|
||||
})
|
||||
}
|
||||
transformed: payload.transformed === true,
|
||||
...(typeof payload.seq === 'number' ? { sequence: payload.seq } : {})
|
||||
}).catch(() => {})
|
||||
})
|
||||
ptyProvider.onReplay((payload) => {
|
||||
const win = this.getMainWindow()
|
||||
@@ -969,16 +959,31 @@ export class SshRelaySession {
|
||||
if (!isCurrentPtyExit(payload)) {
|
||||
return
|
||||
}
|
||||
this.retireExitedPty(payload)
|
||||
void this.acceptPtyExit(payload).catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
private retireExitedPty(payload: SshPtyExitPayload): void {
|
||||
private async acceptPtyExit(payload: SshPtyExitPayload): Promise<void> {
|
||||
await acceptSshPtyOutputExit({
|
||||
id: payload.id,
|
||||
code: payload.code,
|
||||
providerGeneration: payload.providerGeneration,
|
||||
ptyIncarnation: payload.ptyIncarnation
|
||||
})
|
||||
if (isCurrentPtyExit(payload)) {
|
||||
this.retireExitedPty(payload, true)
|
||||
}
|
||||
}
|
||||
|
||||
private retireExitedPty(payload: SshPtyExitPayload, deliveryHandled = false): void {
|
||||
const relayPtyId = toRelaySshPtyId(this.targetId, payload.id)
|
||||
clearProviderPtyState(payload.id)
|
||||
deletePtyOwnership(payload.id)
|
||||
this.forwardedReattachReplayByPty.delete(payload.id)
|
||||
this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated')
|
||||
if (deliveryHandled) {
|
||||
return
|
||||
}
|
||||
this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
|
||||
const win = this.getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
@@ -1069,7 +1074,7 @@ export class SshRelaySession {
|
||||
restorePtyIncarnation(appPtyId, attachResult.incarnationId)
|
||||
this.runtime?.acceptPtyIncarnationForExit(appPtyId, attachResult.incarnationId)
|
||||
}
|
||||
this.retireExitedPty(exitDuringAttach)
|
||||
await this.acceptPtyExit(exitDuringAttach)
|
||||
continue
|
||||
}
|
||||
setPtyOwnership(appPtyId, this.targetId)
|
||||
|
||||
Reference in New Issue
Block a user