pool.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import {randomUUID} from 'node:crypto'
  2. import {DatabaseSync} from 'node:sqlite'
  3. import type {ComputerProvider,ComputerSpecification} from './container.js'
  4. import {ComputerError} from './container.js'
  5. export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
  6. type Status='free'|'idle'|'preparing'|'active'|'stopping'|'uncertain'
  7. interface Entry {spec:ComputerSpecification;generation:number;status:Status;lease?:ComputerLease;expiresAt:number;updatedAt:number}
  8. interface Active {lease:ComputerLease;authorize():void;controller:AbortController;operations:Set<Promise<unknown>>;detach():void}
  9. /** Durable exclusive use. A generation is invalidated before cancellation; no
  10. * successor is admitted until the old private environment is confirmed stopped. */
  11. export class ComputerPool {
  12. private active=new Map<string,Active>()
  13. private changing=new Map<string,Promise<unknown>>()
  14. private ready=false
  15. private closing=false
  16. private maintenance=false
  17. holdMaintenance(){this.maintenance=true;let released=false;return ()=>{if(!released){released=true;this.maintenance=false}}}
  18. beginMaintenance({allowIdle=false}={}){
  19. if(this.maintenance||this.changing.size||this.rows().some(row=>row.status!=='free'&&!(allowIdle&&row.status==='idle')))throw new ComputerError('computer_busy',allowIdle?'请先停止正在使用虚拟机的任务并交还控制权,再修改虚拟机设置':'请先停止电脑任务并交还控制权,再管理镜像')
  20. return this.holdMaintenance()
  21. }
  22. constructor(readonly db:DatabaseSync,readonly provider:ComputerProvider,readonly limits={concurrent:2,environments:32,ttlMs:30000},readonly now=Date.now) {
  23. if(![limits.concurrent,limits.environments,limits.ttlMs].every(Number.isSafeInteger)||limits.concurrent<1||limits.concurrent>8||limits.environments<limits.concurrent||limits.environments>128||limits.ttlMs<1000||limits.ttlMs>120000)throw new Error('电脑资源配额无效')
  24. db.exec('CREATE TABLE IF NOT EXISTS computer_environments(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
  25. }
  26. private rows():Entry[]{return (this.db.prepare('SELECT value FROM computer_environments').all() as {value:string}[]).map(row=>JSON.parse(row.value))}
  27. private get(id:string):Entry|undefined {const row=this.db.prepare('SELECT value FROM computer_environments WHERE id=?').get(id) as {value:string}|undefined;return row?JSON.parse(row.value):undefined}
  28. private save(entry:Entry){entry.updatedAt=this.now();this.db.prepare('INSERT INTO computer_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(entry.spec.id,JSON.stringify(entry))}
  29. private serial<T>(id:string,action:()=>Promise<T>):Promise<T>{
  30. const pending=(this.changing.get(id)??Promise.resolve()).catch(()=>{}).then(action)
  31. this.changing.set(id,pending);void pending.finally(()=>{if(this.changing.get(id)===pending)this.changing.delete(id)}).catch(()=>{})
  32. return pending
  33. }
  34. /** Must run under the Runner's OS instance lock before accepting commands. */
  35. async recover():Promise<void>{
  36. if(this.ready)throw new ComputerError('computer_pool_active','运行中的资源池不能重新执行启动恢复')
  37. for(const entry of this.rows())if(entry.status!=='free')await this.serial(entry.spec.id,()=>this.stopEntry(entry)).catch(()=>{})
  38. this.ready=true
  39. }
  40. status(ownerKey:string){return this.rows().filter(row=>row.spec.ownerKey===ownerKey).map(row=>({environmentId:row.spec.id,status:row.status,generation:row.generation,holderId:row.lease?.holderId,expiresAt:row.expiresAt}))}
  41. private require(lease:ComputerLease,activeOnly=true):Entry {
  42. const entry=this.get(lease.environmentId)
  43. if(!entry||entry.spec.ownerKey!==lease.ownerKey||entry.lease?.id!==lease.id||entry.lease.holderId!==lease.holderId||entry.generation!==lease.generation||(activeOnly&&entry.status!=='active'))throw new ComputerError('computer_lease_stale','电脑控制权已改变,旧操作不能继续')
  44. return entry
  45. }
  46. private assert(lease:ComputerLease){
  47. const entry=this.require(lease),active=this.active.get(lease.id)
  48. if(this.closing||!active||active.controller.signal.aborted||entry.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑控制授权已到期')
  49. active.authorize()
  50. }
  51. async acquire(spec:ComputerSpecification,holderId:string,authorize:()=>void,signal?:AbortSignal):Promise<ComputerLease>{
  52. spec=this.provider.validateSpecification(spec)
  53. if(!holderId||holderId.length>256)throw new ComputerError('computer_holder_invalid','电脑任务身份无效')
  54. return this.serial(spec.id,async()=>{
  55. authorize()
  56. if(this.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
  57. if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
  58. let entry=this.get(spec.id)
  59. if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
  60. if(entry?.status==='active'&&entry.lease?.holderId===holderId&&this.active.has(entry.lease.id)){this.assert(entry.lease);return entry.lease}
  61. if(entry&&entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
  62. const rows=this.rows()
  63. if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
  64. const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:(entry?.generation??0)+1}
  65. entry={spec:structuredClone(spec),lease,generation:lease.generation,status:'preparing',expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()}
  66. // This reservation is synchronous, before any provider await or next acquire.
  67. this.save(entry)
  68. const abort=()=>{const current=this.get(spec.id);if(current?.lease?.id===lease.id)this.invalidate(current)}
  69. const active:Active={lease,authorize,controller:new AbortController(),operations:new Set(),detach:()=>signal?.removeEventListener('abort',abort)};this.active.set(lease.id,active)
  70. signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
  71. const guard=()=>{const current=this.require(lease,false);if(this.closing||active.controller.signal.aborted||current.status!=='preparing'||current.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑启动授权已失效');authorize()}
  72. try{
  73. await this.provider.ensure(spec,guard);guard()
  74. entry.status='active';this.save(entry);return lease
  75. }catch(error){await this.stopEntry(entry).catch(()=>{});throw error}
  76. })
  77. }
  78. async configure(spec:ComputerSpecification,authorize:()=>void):Promise<void>{
  79. spec=this.provider.validateSpecification(spec)
  80. return this.serial(spec.id,async()=>{
  81. authorize();if(this.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
  82. if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
  83. const entry=this.get(spec.id);if(!entry)return
  84. if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
  85. if(JSON.stringify(entry.spec)===JSON.stringify(spec))return
  86. if(entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','请先释放当前电脑,再应用新的配置')
  87. entry.status='stopping';entry.generation++;this.save(entry)
  88. try{await this.provider.remove(entry.spec);authorize();entry.spec=spec;entry.status='free';this.save(entry)}
  89. catch(error){entry.status='uncertain';this.save(entry);throw error}
  90. })
  91. }
  92. async transfer(lease:ComputerLease,holderId:string,authorize:()=>void):Promise<ComputerLease>{
  93. return this.serial(lease.environmentId,async()=>{
  94. this.assert(lease);authorize()
  95. const active=this.active.get(lease.id)!
  96. if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有在途操作,不能移交控制')
  97. const entry=this.require(lease),state=await this.provider.inspect(entry.spec)
  98. this.assert(lease);authorize()
  99. if(active.operations.size||!state?.running)throw new ComputerError('computer_busy','电脑尚未就绪,不能移交控制')
  100. const next:ComputerLease={...lease,id:randomUUID(),holderId,generation:entry.generation+1}
  101. active.detach();active.controller.abort();this.active.delete(lease.id)
  102. this.active.set(next.id,{lease:next,authorize,controller:new AbortController(),operations:new Set(),detach:()=>{}})
  103. entry.lease=next;entry.generation=next.generation;entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)
  104. return next
  105. })
  106. }
  107. authorize(lease:ComputerLease){this.assert(lease)}
  108. renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)}
  109. use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
  110. this.assert(lease)
  111. const active=this.active.get(lease.id)!
  112. const pending=Promise.resolve().then(()=>{this.assert(lease);return action({signal:active.controller.signal,authorize:()=>this.assert(lease)})}).then(value=>{this.assert(lease);return value})
  113. active.operations.add(pending);void pending.finally(()=>active.operations.delete(pending)).catch(()=>{})
  114. return pending
  115. }
  116. release(lease:ComputerLease,keepRunning=false):Promise<void>{
  117. if(keepRunning)return this.serial(lease.environmentId,async()=>{
  118. this.assert(lease)
  119. const entry=this.require(lease),active=this.active.get(lease.id)!
  120. if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有未结束操作,暂不能进入空闲状态')
  121. active.detach();active.controller.abort();this.active.delete(lease.id)
  122. entry.status='idle';entry.generation++;entry.lease=undefined;entry.expiresAt=0;this.save(entry)
  123. })
  124. const existing=this.get(lease.environmentId)
  125. const entry=existing&&existing.lease?.id===lease.id&&existing.spec.ownerKey===lease.ownerKey&&existing.lease.holderId===lease.holderId&&['stopping','uncertain'].includes(existing.status)?existing:this.require(lease,false)
  126. this.invalidate(entry)
  127. return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
  128. }
  129. definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
  130. async desktop(spec:ComputerSpecification,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
  131. spec=this.provider.validateSpecification(spec)
  132. await this.serial(spec.id,async()=>{
  133. authorize()
  134. if(!this.ready||this.closing||this.maintenance)throw new ComputerError('computer_busy','本地虚拟机正在维护')
  135. let entry=this.get(spec.id)
  136. if(entry?.spec.ownerKey!==undefined&&entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','虚拟机归属不匹配')
  137. if(entry&&!['free','idle'].includes(entry.status))throw new ComputerError('computer_busy','请先停止 Agent 任务并交还控制权')
  138. const starts=['create','start','recreate'].includes(action)
  139. if(starts&&this.rows().filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
  140. if(!entry&&!starts)return
  141. entry??={spec,generation:0,status:'free',expiresAt:0,updatedAt:this.now()}
  142. entry.status='preparing';entry.generation++;this.save(entry)
  143. try {
  144. if(['stop','recreate','remove'].includes(action)) {await this.provider.stop(entry.spec);await this.provider.remove(entry.spec)}
  145. authorize()
  146. if(starts){entry.spec=spec;await this.provider.ensure(spec,authorize);entry.status='idle'}else entry.status='free'
  147. if(action==='remove')this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(spec.id)
  148. else this.save(entry)
  149. }catch(error){entry.status='uncertain';this.save(entry);throw error}
  150. })
  151. }
  152. async retire(ownerKey:string,id:string):Promise<ComputerSpecification|undefined>{
  153. return this.serial(id,async()=>{
  154. const entry=this.get(id);if(!entry)return
  155. if(entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
  156. if(entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
  157. if(entry.status==='idle')await this.provider.stop(entry.spec)
  158. await this.provider.remove(entry.spec)
  159. this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id)
  160. return entry.spec
  161. })
  162. }
  163. async stopHolder(ownerKey:string,id:string,holderId:string):Promise<void>{
  164. const entry=this.get(id)
  165. if(!entry||entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
  166. if(entry.lease?.holderId!==holderId)return
  167. this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry))
  168. }
  169. private invalidate(expected:Entry){
  170. const entry=this.get(expected.spec.id)
  171. if(!entry||entry.lease?.id!==expected.lease?.id||entry.status==='free')return
  172. if(entry.status!=='stopping'){entry.generation++;entry.status='stopping';entry.expiresAt=0;this.save(entry)}
  173. const active=entry.lease?this.active.get(entry.lease.id):undefined
  174. active?.controller.abort()
  175. return entry
  176. }
  177. private async stopEntry(expected:Entry):Promise<void>{
  178. const entry=this.invalidate(expected)
  179. if(!entry)return
  180. const active=entry.lease?this.active.get(entry.lease.id):undefined
  181. try{
  182. await this.provider.stop(entry.spec)
  183. // Retire the runtime identity as well as the lease generation. A delayed
  184. // exec targeting the old container ID can never enter its successor.
  185. await this.provider.remove(entry.spec)
  186. active?.detach();if(entry.lease)this.active.delete(entry.lease.id)
  187. entry.status='free';entry.lease=undefined;entry.expiresAt=0;this.save(entry)
  188. }catch(error){entry.status='uncertain';this.save(entry);throw error}
  189. }
  190. async expire():Promise<void>{
  191. for(const id of this.rows().map(entry=>entry.spec.id)){
  192. const entry=this.get(id)!
  193. if(entry.status==='idle'&&this.now()-entry.updatedAt>=300000){this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{});continue}
  194. if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
  195. this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
  196. }
  197. }
  198. }
  199. async close():Promise<void>{
  200. this.closing=true
  201. for(const entry of this.rows())if(entry.status!=='free')this.invalidate(entry)
  202. await Promise.allSettled(this.rows().filter(entry=>entry.status!=='free').map(entry=>this.serial(entry.spec.id,()=>this.stopEntry(this.get(entry.spec.id)!))))
  203. if(this.rows().some(entry=>entry.status!=='free'))throw new ComputerError('computer_stop_uncertain','尚未确认所有电脑环境已停止')
  204. }
  205. }