computerPool.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. // @vitest-environment node
  2. import {afterEach,expect,it,vi} from 'vitest'
  3. import {DatabaseSync} from 'node:sqlite'
  4. import {randomUUID} from 'node:crypto'
  5. import {ComputerPool} from '../../src/runner/computers/pool'
  6. import {ContainerComputerProvider,type ComputerProvider,type ComputerSpecification} from '../../src/runner/computers/container'
  7. const databases:DatabaseSync[]=[]
  8. afterEach(()=>{for(const db of databases.splice(0))db.close()})
  9. const spec=():ComputerSpecification=>({id:randomUUID(),ownerKey:'owner',imageId:`sha256:${'a'.repeat(64)}`})
  10. it('uses the configured idle deadline, resets it after reuse, and never exempts active leases from expiry',async()=>{
  11. const f=fixture();await f.pool.recover();const resource=spec()
  12. f.pool.idleStopMinutes=15
  13. await f.pool.desktop(resource,'start',()=>{})
  14. f.advance(5*60000);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('idle')
  15. const lease=await f.pool.acquire(resource,'next',()=>{});await f.pool.release(lease,true)
  16. f.advance(15*60000-1);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('idle')
  17. f.advance(1);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('free')
  18. f.pool.idleStopMinutes=0;await f.pool.desktop(resource,'start',()=>{})
  19. f.advance(30*24*60*60000);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('idle')
  20. await f.pool.acquire(resource,'expired-holder',()=>{});f.advance(30001);await f.pool.expire()
  21. expect(f.pool.status('owner')[0]?.status).toBe('free')
  22. await f.pool.desktop(resource,'start',()=>{});await f.pool.close();expect(f.pool.status('owner')[0]?.status).toBe('free')
  23. })
  24. function fixture(limits={concurrent:2,environments:32,ttlMs:30000}){
  25. let clock=100000
  26. const db=new DatabaseSync(':memory:');databases.push(db)
  27. const validate=new ContainerComputerProvider('docker',randomUUID(),'/unused').validateSpecification
  28. const provider={validateSpecification:validate,ensure:vi.fn(async(_spec,authorize)=>{authorize();return {running:true}}),stop:vi.fn(async()=>{}),remove:vi.fn(async()=>{})} as unknown as ComputerProvider
  29. const pool=new ComputerPool(db,provider,limits,()=>clock)
  30. return {db,provider,pool,advance:(value:number)=>{clock+=value}}
  31. }
  32. it('reserves quota before starting, rejects another holder and rejects malformed specs before storage',async()=>{
  33. const f=fixture({concurrent:1,environments:2,ttlMs:30000});await f.pool.recover()
  34. const first=spec();const lease=await f.pool.acquire(first,'task-one',()=>{})
  35. expect(await f.pool.acquire(first,'task-one',()=>{})).toEqual(lease)
  36. await expect(f.pool.acquire(first,'task-two',()=>{})).rejects.toMatchObject({code:'computer_busy'})
  37. await expect(f.pool.acquire(spec(),'task-three',()=>{})).rejects.toMatchObject({code:'computer_quota'})
  38. await expect(f.pool.acquire({...spec(),imageId:'untrusted:latest'},'invalid',()=>{})).rejects.toMatchObject({code:'computer_spec_invalid'})
  39. expect(f.db.prepare('SELECT count(*) AS n FROM computer_environments').get()?.n).toBe(1)
  40. expect(f.pool.status('different-owner')).toEqual([])
  41. await f.pool.close()
  42. })
  43. it('fences cancellation before an old operation returns and waits for its stop before admitting a successor',async()=>{
  44. const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task-one',()=>{})
  45. let entered=false
  46. const operation=f.pool.use(lease,({signal})=>new Promise<void>((resolve)=>{entered=true;signal.addEventListener('abort',()=>resolve(),{once:true})})).catch(error=>error)
  47. await vi.waitFor(()=>expect(entered).toBe(true))
  48. const released=f.pool.release(lease)
  49. expect(await operation).toMatchObject({code:'computer_lease_stale'})
  50. await released
  51. const next=await f.pool.acquire(resource,'task-two',()=>{})
  52. expect(next.generation).toBeGreaterThan(lease.generation)
  53. expect(()=>f.pool.release(lease)).toThrow('电脑控制权已改变')
  54. expect(f.provider.stop).toHaveBeenCalledTimes(1)
  55. await f.pool.close()
  56. })
  57. it('keeps an unconfirmed stop reserved and recovers it before a new acquisition',async()=>{
  58. const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task',()=>{})
  59. vi.mocked(f.provider.stop).mockRejectedValueOnce(new Error('daemon unavailable'))
  60. await expect(f.pool.release(lease)).rejects.toThrow('daemon unavailable')
  61. expect(f.pool.status('owner')[0]?.status).toBe('uncertain')
  62. await expect(f.pool.acquire(resource,'new-task',()=>{})).rejects.toMatchObject({code:'computer_busy'})
  63. const restarted=new ComputerPool(f.db,f.provider)
  64. await expect(restarted.acquire(resource,'new-task',()=>{})).rejects.toMatchObject({code:'computer_pool_unavailable'})
  65. await restarted.recover()
  66. const next=await restarted.acquire(resource,'new-task',()=>{})
  67. expect(next.generation).toBeGreaterThan(lease.generation)
  68. await restarted.close()
  69. })
  70. it('does not expire a new holder acquired while an earlier environment stop was in flight',async()=>{
  71. const f=fixture();await f.pool.recover();const a=spec(),b=spec()
  72. const first=await f.pool.acquire(a,'first',()=>{}),second=await f.pool.acquire(b,'second',()=>{})
  73. let release!:()=>void,entered=false
  74. const gate=new Promise<void>(resolve=>{release=resolve})
  75. vi.mocked(f.provider.stop).mockImplementation(async resource=>{if(resource.id===a.id){entered=true;await gate}})
  76. f.advance(30001)
  77. const sweep=f.pool.expire()
  78. await vi.waitFor(()=>expect(entered).toBe(true))
  79. await f.pool.release(second)
  80. const replacement=await f.pool.acquire(b,'replacement',()=>{})
  81. release();await sweep
  82. expect(f.pool.status('owner').find(row=>row.environmentId===a.id)?.status).toBe('free')
  83. expect(f.pool.status('owner').find(row=>row.environmentId===b.id)).toMatchObject({status:'active',holderId:'replacement',generation:replacement.generation})
  84. expect(()=>f.pool.renew(first)).toThrow()
  85. f.pool.renew(replacement);await f.pool.close()
  86. })
  87. it('rechecks authority and cleans up a creation cancelled while the provider is starting',async()=>{
  88. const f=fixture();await f.pool.recover();const abort=new AbortController()
  89. let release!:()=>void,entered=false
  90. const gate=new Promise<void>(resolve=>{release=resolve})
  91. vi.mocked(f.provider.ensure).mockImplementationOnce(async(_spec,authorize)=>{entered=true;await gate;authorize();return {} as any})
  92. const acquiring=f.pool.acquire(spec(),'starting',()=>{},abort.signal).catch(error=>error)
  93. await vi.waitFor(()=>expect(entered).toBe(true));abort.abort();release()
  94. expect(await acquiring).toBeInstanceOf(Error)
  95. expect(f.pool.status('owner')[0]?.status).toBe('free')
  96. expect(f.provider.stop).toHaveBeenCalledTimes(1)
  97. await f.pool.close()
  98. })
  99. it('rejects changes of account identity and resource configuration for a persistent environment',async()=>{
  100. const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task',()=>{})
  101. await f.pool.release(lease)
  102. for(const change of [{ownerKey:'other'},{memoryMiB:8192}])await expect(f.pool.acquire({...resource,...change},'changed',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
  103. expect(f.provider.ensure).toHaveBeenCalledTimes(1)
  104. })
  105. it('applies a changed authoritative cwd only after an environment is released',async()=>{
  106. const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task',()=>{})
  107. const updated={...resource,cwd:'/projects/new-location'}
  108. await expect(f.pool.configure(updated,()=>{})).rejects.toMatchObject({code:'computer_busy'})
  109. await f.pool.release(lease)
  110. await f.pool.configure(updated,()=>{})
  111. const next=await f.pool.acquire(updated,'next',()=>{})
  112. expect(next.generation).toBeGreaterThan(lease.generation)
  113. await f.pool.close()
  114. })
  115. it('transfers an idle live computer with a new generation and rejects its previous holder',async()=>{
  116. const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'agent',()=>{})
  117. f.provider.inspect=vi.fn(async()=>({id:resource.id,containerId:'same-computer',running:true,workspace:'/fixture',isolation:'container'}))
  118. const human=await f.pool.transfer(lease,'human',()=>{})
  119. expect(human.generation).toBeGreaterThan(lease.generation)
  120. expect(()=>f.pool.authorize(lease)).toThrow()
  121. expect(f.provider.stop).not.toHaveBeenCalled()
  122. f.pool.authorize(human)
  123. const resumed=await f.pool.transfer(human,'agent',()=>{})
  124. expect(resumed.generation).toBeGreaterThan(human.generation)
  125. await f.pool.close()
  126. })