| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132 |
- /** Window holds and conservative idle reclamation for one terminal owner. */
- import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
- import type { SubprocessTerminalActivity } from '@deepseek-ai/dsh-subprocess'
- import type { TerminalRetentionFrame } from './types.ts'
- /** Validated Host timing policy for unattended terminal cleanup. */
- export interface TerminalRetentionPolicy {
- readonly unattendedTimeoutMs: number
- readonly activityPollIntervalMs: number
- readonly cleanupRetryMs: number
- }
- /** Exactly one owner orders holds, observation, and retryable process cleanup. */
- export class TerminalRetention {
- private readonly lifetime = new AbortController()
- private readonly holders = new Set<object>()
- private epoch = 0
- private timer: ReturnType<typeof setTimeout> | undefined
- private observation: Promise<void> | undefined
- private idle: { since: number; revision: number; observedAt: number } | undefined
- private closing = false
- private disposed = false
- private cleanup: Promise<void> | undefined
- /**
- * @param policy - deployment timing choices.
- * @param inspect - fresh shell and owned-job observation.
- * @param terminate - mark the identity closed, await process quiescence, and remove its owner record.
- * @param failed - diagnostic sink for failed automatic cleanup.
- */
- constructor(
- private readonly policy: TerminalRetentionPolicy,
- private readonly inspect: () => Promise<SubprocessTerminalActivity>,
- private readonly terminate: () => Promise<void>,
- private readonly failed: (error: unknown) => void,
- ) { this.schedule(0) }
- /**
- * Hold one terminal for one physical Remote stream, independently of screen subscriptions.
- * @param signal - transport generation lifetime.
- * @returns acknowledgement followed by an open stream until cancellation or terminal closure.
- */
- async *retain(signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> {
- signal.throwIfAborted()
- if (this.closing || this.disposed) throw new RemoteError('terminal/unavailable', 'Terminal is closing or unavailable', {})
- const holder = {}
- const ended = Promise.withResolvers<void>()
- const combined = AbortSignal.any([signal, this.lifetime.signal])
- const release = (): void => {
- if (!this.holders.delete(holder)) return
- combined.removeEventListener('abort', release)
- this.invalidate()
- ended.resolve()
- this.schedule(0)
- }
- this.holders.add(holder)
- this.invalidate()
- this.cancelTimer()
- combined.addEventListener('abort', release, { once: true })
- try { yield { type: 'retained' }; await ended.promise }
- finally { release() }
- }
- /** Invalidate outstanding idle observations before accepting input. */
- invalidate(): void { this.epoch++; this.idle = undefined }
- /**
- * Start or join cleanup; failure keeps the identity closed and schedules one retry.
- * @returns after owned process cleanup succeeds, or rejects with its failure.
- */
- close(): Promise<void> {
- if (this.cleanup !== undefined) return this.cleanup
- this.closing = true
- this.invalidate()
- this.lifetime.abort(new Error('Terminal closed'))
- this.cancelTimer()
- this.cleanup = this.terminate().catch((error: unknown) => {
- this.cleanup = undefined
- this.schedule(this.policy.cleanupRetryMs)
- throw error
- })
- return this.cleanup
- }
- /**
- * Stop timers and streams and await both observation and final cleanup.
- * @returns after process quiescence; cleanup failure is reported to the disposing owner.
- */
- async dispose(): Promise<void> {
- this.disposed = true
- this.cancelTimer()
- const observation = this.observation
- try { await this.close() }
- finally { await observation }
- }
- private cancelTimer(): void { clearTimeout(this.timer); this.timer = undefined }
- private schedule(delay: number): void {
- if (this.disposed || this.timer !== undefined) return
- if (!this.closing && (this.holders.size > 0 || this.policy.unattendedTimeoutMs === 0)) return
- const due = performance.now() + delay
- this.timer = setTimeout(() => {
- this.timer = undefined
- const remaining = due - performance.now()
- if (remaining > 0) { this.schedule(remaining); return }
- if (this.closing) { void this.close().catch(this.failed); return }
- this.observe()
- }, Math.min(delay, 2_147_483_647))
- this.timer.unref()
- }
- private observe(): void {
- if (this.observation !== undefined) return
- const epoch = this.epoch
- this.observation = (async () => {
- let activity: SubprocessTerminalActivity
- try { activity = await this.inspect() }
- catch (_activityUnavailable) { activity = { state: 'unknown', revision: 0 } }
- if (this.disposed || this.closing || this.holders.size > 0 || epoch !== this.epoch) return
- const now = performance.now()
- if (activity.state !== 'idle') { this.idle = undefined; return }
- if (this.idle?.revision !== activity.revision || now - this.idle.observedAt > this.policy.activityPollIntervalMs * 2) {
- this.idle = { since: now, observedAt: now, revision: activity.revision }
- } else this.idle.observedAt = now
- if (now - this.idle.since >= this.policy.unattendedTimeoutMs) await this.close()
- })().catch(this.failed).finally(() => {
- this.observation = undefined
- if (!this.closing) this.schedule(this.policy.activityPollIntervalMs)
- })
- }
- }
|