background-start.spec.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  2. import type { ShellProcess } from '@deepseek-ai/dsh-shell'
  3. import { processJob } from '../src/background.ts'
  4. function processHandle() {
  5. const exited = Promise.withResolvers<undefined>()
  6. const killed = Promise.withResolvers<undefined>()
  7. const kill = vi.fn(() => {
  8. if (process.status !== 'running') return false
  9. process.status = 'killed'
  10. process.signal = 'SIGTERM'
  11. killed.resolve(undefined)
  12. return true
  13. })
  14. const process: ShellProcess = {
  15. status: 'running', exitCode: null, signal: null, done: exited.promise,
  16. readOutput: () => ({ delta: 'output', lossy: false }), kill,
  17. }
  18. onTestFinished(() => { exited.resolve(undefined) })
  19. return { process, exited, killed, kill }
  20. }
  21. describe('background job ownership during asynchronous shell startup', () => {
  22. it('keeps output empty until a process is published and then consumes its output', async () => {
  23. const child = processHandle()
  24. const prepared = Promise.withResolvers<ShellProcess>()
  25. const render = vi.fn((process: ShellProcess) => process.readOutput().delta)
  26. const hooks = processJob(() => prepared.promise, render)
  27. expect(hooks.readOutput!()).toBe('')
  28. expect(render).not.toHaveBeenCalled()
  29. prepared.resolve(child.process)
  30. await Promise.resolve()
  31. expect(hooks.readOutput!()).toBe('output')
  32. child.process.status = 'completed'
  33. child.process.exitCode = 5
  34. child.exited.resolve(undefined)
  35. expect(await hooks.done).toEqual({ status: 'completed', detail: 'exit code: 5' })
  36. })
  37. it('aborts pending preparation through the job-owned signal', async () => {
  38. let received: AbortSignal | undefined
  39. const hooks = processJob((signal) => {
  40. received = signal
  41. return new Promise<ShellProcess>((_resolve, reject) => {
  42. signal.addEventListener('abort', () => { reject(new Error(String(signal.reason))) }, { once: true })
  43. })
  44. }, () => 'unreachable')
  45. expect(received?.aborted).toBe(false)
  46. hooks.cancel('cancel pending confinement')
  47. hooks.cancel('later cancellation')
  48. expect(received?.reason).toBe('cancel pending confinement')
  49. expect(await hooks.done).toEqual({ status: 'killed', detail: 'cancel pending confinement' })
  50. expect(hooks.readOutput!()).toBe('')
  51. })
  52. it('kills a process that materializes after cancellation and awaits its exit', async () => {
  53. const child = processHandle()
  54. const prepared = Promise.withResolvers<ShellProcess>()
  55. const hooks = processJob(() => prepared.promise, () => 'output')
  56. let settled = false
  57. void hooks.done.then(() => { settled = true })
  58. hooks.cancel('cancel before process publication')
  59. prepared.resolve(child.process)
  60. await child.killed.promise
  61. expect(child.kill).toHaveBeenCalledOnce()
  62. expect(settled).toBe(false)
  63. child.exited.resolve(undefined)
  64. expect(await hooks.done).toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
  65. })
  66. it('cancels a published process once and keeps done pending until exit', async () => {
  67. const child = processHandle()
  68. const hooks = processJob(async () => child.process, () => 'output')
  69. await Promise.resolve()
  70. let settled = false
  71. void hooks.done.then(() => { settled = true })
  72. hooks.cancel('stop')
  73. hooks.cancel('stop again')
  74. await child.killed.promise
  75. expect(child.kill).toHaveBeenCalledOnce()
  76. expect(settled).toBe(false)
  77. child.exited.resolve(undefined)
  78. expect((await hooks.done).status).toBe('killed')
  79. })
  80. it('joins a late process even when its termination request throws', async () => {
  81. const child = processHandle()
  82. const attempted = Promise.withResolvers<undefined>()
  83. vi.mocked(child.kill).mockImplementation(() => { attempted.resolve(undefined); throw new Error('termination failed') })
  84. const prepared = Promise.withResolvers<ShellProcess>()
  85. const hooks = processJob(() => prepared.promise, () => '')
  86. let settled = false
  87. void hooks.done.then(() => { settled = true })
  88. hooks.cancel('stop before publication')
  89. prepared.resolve(child.process)
  90. await attempted.promise
  91. expect(settled).toBe(false)
  92. child.exited.resolve(undefined)
  93. expect(await hooks.done).toEqual({ status: 'failed', detail: 'termination failed' })
  94. })
  95. it('reports a startup failure through job completion without publishing output', async () => {
  96. const render = vi.fn(() => 'unreachable')
  97. const hooks = processJob(async () => { throw new Error('remote sandbox unavailable') }, render)
  98. expect(await hooks.done).toEqual({ status: 'failed', detail: 'remote sandbox unavailable' })
  99. expect(hooks.readOutput!()).toBe('')
  100. expect(render).not.toHaveBeenCalled()
  101. })
  102. it('reports a primitive upstream abort reason as a failed startup', async () => {
  103. const upstream = new AbortController()
  104. upstream.abort('upstream preparation stopped')
  105. const hooks = processJob(async () => { upstream.signal.throwIfAborted(); throw new Error('unreachable') }, () => '')
  106. expect(await hooks.done).toEqual({ status: 'failed', detail: 'upstream preparation stopped' })
  107. })
  108. })