retention.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. /** Window holds and conservative idle reclamation for one terminal owner. */
  2. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  3. import type { SubprocessTerminalActivity } from '@deepseek-ai/dsh-subprocess'
  4. import type { TerminalRetentionFrame } from './types.ts'
  5. /** Validated Host timing policy for unattended terminal cleanup. */
  6. export interface TerminalRetentionPolicy {
  7. readonly unattendedTimeoutMs: number
  8. readonly activityPollIntervalMs: number
  9. readonly cleanupRetryMs: number
  10. }
  11. /** Exactly one owner orders holds, observation, and retryable process cleanup. */
  12. export class TerminalRetention {
  13. private readonly lifetime = new AbortController()
  14. private readonly holders = new Set<object>()
  15. private epoch = 0
  16. private timer: ReturnType<typeof setTimeout> | undefined
  17. private observation: Promise<void> | undefined
  18. private idle: { since: number; revision: number; observedAt: number } | undefined
  19. private closing = false
  20. private disposed = false
  21. private cleanup: Promise<void> | undefined
  22. /**
  23. * @param policy - deployment timing choices.
  24. * @param inspect - fresh shell and owned-job observation.
  25. * @param terminate - mark the identity closed, await process quiescence, and remove its owner record.
  26. * @param failed - diagnostic sink for failed automatic cleanup.
  27. */
  28. constructor(
  29. private readonly policy: TerminalRetentionPolicy,
  30. private readonly inspect: () => Promise<SubprocessTerminalActivity>,
  31. private readonly terminate: () => Promise<void>,
  32. private readonly failed: (error: unknown) => void,
  33. ) { this.schedule(0) }
  34. /**
  35. * Hold one terminal for one physical Remote stream, independently of screen subscriptions.
  36. * @param signal - transport generation lifetime.
  37. * @returns acknowledgement followed by an open stream until cancellation or terminal closure.
  38. */
  39. async *retain(signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> {
  40. signal.throwIfAborted()
  41. if (this.closing || this.disposed) throw new RemoteError('terminal/unavailable', 'Terminal is closing or unavailable', {})
  42. const holder = {}
  43. const ended = Promise.withResolvers<void>()
  44. const combined = AbortSignal.any([signal, this.lifetime.signal])
  45. const release = (): void => {
  46. if (!this.holders.delete(holder)) return
  47. combined.removeEventListener('abort', release)
  48. this.invalidate()
  49. ended.resolve()
  50. this.schedule(0)
  51. }
  52. this.holders.add(holder)
  53. this.invalidate()
  54. this.cancelTimer()
  55. combined.addEventListener('abort', release, { once: true })
  56. try { yield { type: 'retained' }; await ended.promise }
  57. finally { release() }
  58. }
  59. /** Invalidate outstanding idle observations before accepting input. */
  60. invalidate(): void { this.epoch++; this.idle = undefined }
  61. /**
  62. * Start or join cleanup; failure keeps the identity closed and schedules one retry.
  63. * @returns after owned process cleanup succeeds, or rejects with its failure.
  64. */
  65. close(): Promise<void> {
  66. if (this.cleanup !== undefined) return this.cleanup
  67. this.closing = true
  68. this.invalidate()
  69. this.lifetime.abort(new Error('Terminal closed'))
  70. this.cancelTimer()
  71. this.cleanup = this.terminate().catch((error: unknown) => {
  72. this.cleanup = undefined
  73. this.schedule(this.policy.cleanupRetryMs)
  74. throw error
  75. })
  76. return this.cleanup
  77. }
  78. /**
  79. * Stop timers and streams and await both observation and final cleanup.
  80. * @returns after process quiescence; cleanup failure is reported to the disposing owner.
  81. */
  82. async dispose(): Promise<void> {
  83. this.disposed = true
  84. this.cancelTimer()
  85. const observation = this.observation
  86. try { await this.close() }
  87. finally { await observation }
  88. }
  89. private cancelTimer(): void { clearTimeout(this.timer); this.timer = undefined }
  90. private schedule(delay: number): void {
  91. if (this.disposed || this.timer !== undefined) return
  92. if (!this.closing && (this.holders.size > 0 || this.policy.unattendedTimeoutMs === 0)) return
  93. const due = performance.now() + delay
  94. this.timer = setTimeout(() => {
  95. this.timer = undefined
  96. const remaining = due - performance.now()
  97. if (remaining > 0) { this.schedule(remaining); return }
  98. if (this.closing) { void this.close().catch(this.failed); return }
  99. this.observe()
  100. }, Math.min(delay, 2_147_483_647))
  101. this.timer.unref()
  102. }
  103. private observe(): void {
  104. if (this.observation !== undefined) return
  105. const epoch = this.epoch
  106. this.observation = (async () => {
  107. let activity: SubprocessTerminalActivity
  108. try { activity = await this.inspect() }
  109. catch (_activityUnavailable) { activity = { state: 'unknown', revision: 0 } }
  110. if (this.disposed || this.closing || this.holders.size > 0 || epoch !== this.epoch) return
  111. const now = performance.now()
  112. if (activity.state !== 'idle') { this.idle = undefined; return }
  113. if (this.idle?.revision !== activity.revision || now - this.idle.observedAt > this.policy.activityPollIntervalMs * 2) {
  114. this.idle = { since: now, observedAt: now, revision: activity.revision }
  115. } else this.idle.observedAt = now
  116. if (now - this.idle.since >= this.policy.unattendedTimeoutMs) await this.close()
  117. })().catch(this.failed).finally(() => {
  118. this.observation = undefined
  119. if (!this.closing) this.schedule(this.policy.activityPollIntervalMs)
  120. })
  121. }
  122. }