vmToolLifecycle.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. // @vitest-environment node
  2. import {expect,it,vi} from 'vitest'
  3. import {DatabaseSync} from 'node:sqlite'
  4. import {mkdtemp,rm} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
  9. import {VmToolSession} from '../../src/server/vmComputer'
  10. import type {GatewayTarget} from '../../src/server/workspaceGateway'
  11. async function fixture(docker=false){
  12. const home=await mkdtemp(join(tmpdir(),'yaoyao-vm-lifecycle-')),db=new DatabaseSync(':memory:')
  13. const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1:1',hermesURL:'http://127.0.0.1:1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:docker?process.env.YAOYAO_COMPUTER_IMAGE!:'sha256:'+'a'.repeat(64),python:'/unused',hermesSource:home,hermesHome:home,network:'none'}},home)
  14. await runtime.ready;runtime.retainDesktops=true;runtime.pool.idleStopMinutes=0
  15. const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'vm-lifecycle-fixture',hermesRuntime:true}
  16. const gateways:ComputerGateway[]=[],closed:Promise<void>[]=[],calls:string[]=[]
  17. let allowed=true
  18. const authorize=()=>{if(!allowed)throw new Error('revoked')}
  19. if(!docker){
  20. vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id,containerId:id,running:true,workspace:home,isolation:'container'})
  21. vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
  22. vi.spyOn(runtime.provider,'execute').mockResolvedValue({stdout:'vm proof',stderr:''})
  23. }
  24. const target={url:new URL('http://127.0.0.1:1'),client:{},session:{},runner:{id:randomUUID(),computer:true,
  25. async open(onEvent,onDisconnect,scope){
  26. const gateway=new ComputerGateway(runtime,meta,scope!.workId,async()=>{authorize();scope!.authorize()},async()=>({}),target)
  27. gateways.push(gateway);gateway.onEvent=onEvent;gateway.onDisconnect=onDisconnect
  28. await gateway.connect()
  29. return {rpc:async(method,params)=>{calls.push(method);return gateway.rpc(method,params)},close:()=>{closed.push(gateway.close())}}
  30. },lease:async()=>{throw new Error('unused')},
  31. }} as GatewayTarget
  32. const session=()=>new VmToolSession(target,'default',randomUUID(),authorize)
  33. return {runtime,meta,gateways,calls,session,revoke:()=>{allowed=false},drain:()=>Promise.all(closed),state:()=>runtime.pool.status(meta.ownerKey)[0],
  34. async close(){await Promise.all(gateways.map(g=>g.close().catch(()=>{})));await Promise.all(closed);await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();await rm(home,{recursive:true,force:true})},
  35. }
  36. }
  37. it('keeps a completed Bot VM idle across tool-channel disconnection and the next turn',async()=>{
  38. const f=await fixture()
  39. try{
  40. const first=f.session();await first.call('computer_shell',{command:'pwd'});await first.complete();await f.drain()
  41. expect(f.state()).toMatchObject({status:'idle',holderIds:[]})
  42. await f.runtime.pool.expire();expect(f.state()?.status).toBe('idle')
  43. expect(f.runtime.provider.stop).not.toHaveBeenCalled()
  44. const ended=f.gateways[0]!
  45. await expect(ended.rpc('computer.invoke',{session_id:'old',name:'computer_shell'})).rejects.toMatchObject({code:'computer_cancelled'})
  46. const second=f.session();await second.call('computer_shell',{command:'pwd'});await second.complete();await f.drain()
  47. expect(f.state()?.status).toBe('idle');expect(f.runtime.provider.stop).not.toHaveBeenCalled()
  48. f.runtime.pool.idleStopMinutes=1
  49. const clock=vi.spyOn(f.runtime.pool,'now').mockReturnValue(Date.now()+61000)
  50. try{await f.runtime.pool.expire();expect(f.state()?.status).toBe('free')}finally{clock.mockRestore()}
  51. }finally{await f.close()}
  52. })
  53. it.each(['disconnect','revoked','interrupt'] as const)('still stops the last VM holder on %s with idle stopping disabled',async reason=>{
  54. const f=await fixture()
  55. try{
  56. const session=f.session();await session.call('computer_shell',{command:'pwd'})
  57. if(reason==='revoked'){f.revoke();await expect(session.complete()).rejects.toThrow('revoked')}
  58. else if(reason==='interrupt')await f.gateways[0]!.interrupt()
  59. else session.close()
  60. await f.drain();expect(f.state()?.status).toBe('free')
  61. expect(f.runtime.provider.stop).toHaveBeenCalled()
  62. }finally{await f.close()}
  63. })
  64. it('waits for an in-flight command and rejects new work while completing',async()=>{
  65. const f=await fixture()
  66. let release!:()=>void
  67. try{
  68. const gate=new Promise<void>(done=>{release=done}),session=f.session()
  69. vi.mocked(f.runtime.provider.execute).mockImplementation(async()=>{await gate;return {stdout:'finished',stderr:''}})
  70. const command=session.call('computer_shell',{command:'slow'})
  71. await vi.waitFor(()=>expect(f.runtime.provider.execute).toHaveBeenCalled())
  72. const completion=session.complete()
  73. await vi.waitFor(()=>expect(f.calls).toContain('computer.complete'))
  74. await expect(session.call('computer_shell',{command:'late'})).rejects.toMatchObject({code:'computer_cancelled'})
  75. expect(f.state()?.status).toBe('active');expect(f.runtime.provider.stop).not.toHaveBeenCalled()
  76. release();await command;await completion;await f.drain()
  77. expect(f.state()?.status).toBe('idle')
  78. }finally{release?.();await f.close()}
  79. })
  80. it('releases only its own holder while another Bot still uses the shared VM',async()=>{
  81. const f=await fixture()
  82. try{
  83. const first=f.session(),second=f.session()
  84. await first.call('computer_shell',{command:'pwd'});await second.call('computer_shell',{command:'pwd'})
  85. await first.complete();await f.drain()
  86. expect(f.state()).toMatchObject({status:'active',holderIds:[f.gateways[1]!.workId]})
  87. await second.complete();await f.drain()
  88. expect(f.state()?.status).toBe('idle');expect(f.runtime.provider.stop).not.toHaveBeenCalled()
  89. }finally{await f.close()}
  90. })
  91. it('does not start an unused VM merely to complete a turn',async()=>{
  92. const f=await fixture()
  93. try{await f.session().complete();expect(f.calls).toEqual([]);expect(f.state()).toBeUndefined()}finally{await f.close()}
  94. })
  95. it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('retains the real Docker container and running processes after Bot completion',async()=>{
  96. const f=await fixture(true)
  97. try{
  98. const first=f.session()
  99. await first.call('computer_shell',{command:'nohup sleep 600 >/tmp/yaoyao-retain.log 2>&1 </dev/null & echo $! > retain.pid'})
  100. const spec=f.gateways[0]!.specification!,before=await f.runtime.provider.inspect(spec)
  101. expect(before?.running).toBe(true)
  102. await first.complete();await f.drain();await f.runtime.pool.expire()
  103. expect(f.state()?.status).toBe('idle')
  104. expect(await f.runtime.provider.inspect(spec)).toMatchObject({containerId:before!.containerId,running:true})
  105. const second=f.session(),result=await second.call('computer_shell',{command:'kill -0 "$(cat retain.pid)" && printf process-survived'})
  106. expect(JSON.stringify(result)).toContain('process-survived')
  107. await second.complete();await f.drain()
  108. expect(await f.runtime.provider.inspect(spec)).toMatchObject({containerId:before!.containerId,running:true})
  109. f.runtime.pool.idleStopMinutes=1
  110. const clock=vi.spyOn(f.runtime.pool,'now').mockReturnValue(Date.now()+61000)
  111. try{await f.runtime.pool.expire()}finally{clock.mockRestore()}
  112. expect(await f.runtime.provider.inspect(spec)).toBeUndefined()
  113. }finally{await f.close()}
  114. },120000)