process-main.spec.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. import { Duplex, PassThrough } from 'node:stream'
  2. import { setImmediate as nextTurn } from 'node:timers/promises'
  3. import { expect, it, onTestFinished } from 'vitest'
  4. import { JsonChannel } from '../src/channel.ts'
  5. import { runNodeMain } from '../src/process.ts'
  6. import type { ProgramProcess } from '../src/process.ts'
  7. import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts'
  8. function endpoints() {
  9. const first = new PassThrough()
  10. const second = new PassThrough()
  11. const child = Duplex.from({ readable: first, writable: second })
  12. const host = Duplex.from({ readable: second, writable: first })
  13. child.on('error', () => {})
  14. host.on('error', () => {})
  15. onTestFinished(() => { child.destroy(); host.destroy() })
  16. return { child, host }
  17. }
  18. function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' }, stdout: { write: () => true }, stderr: { write: () => true }, exitCode: undefined } }
  19. it('clears process environment, dispatches a binding reply and flushes the terminal frame', async () => {
  20. const { child, host } = endpoints()
  21. const state = processState()
  22. const nativeEnvironment = state.env
  23. nativeEnvironment.SystemRoot = 'C:\\Windows'
  24. nativeEnvironment.PATH = '/native/bin'
  25. nativeEnvironment.TMP = 'C:\\sandbox-temp'
  26. nativeEnvironment.TEMP = 'C:\\sandbox-temp'
  27. const messages: Record<string, unknown>[] = []
  28. const peer = new JsonChannel(host, 4096, (raw) => {
  29. const message = raw as Record<string, unknown>
  30. messages.push(message)
  31. if (message.type === 'ready') void peer.send({ type: 'boot', data: { code: 'console.log("ready"); return await tools.echo({});', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 1024 } })
  32. if (message.type === 'call') void peer.send({ type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(42) })
  33. if (message.type === 'done') host.end()
  34. }, () => {})
  35. onTestFinished(() => { peer.close() })
  36. await runNodeMain(child, 4096, state)
  37. expect(state.env).toEqual({})
  38. expect(state.env).not.toBe(nativeEnvironment)
  39. expect(Object.getPrototypeOf(state.env)).toBeNull()
  40. expect(nativeEnvironment).toEqual({ SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' })
  41. expect(state.exitCode).toBeUndefined()
  42. expect(decodePtcJsonWire(messages.find(message => message.type === 'done')?.value)).toBe(42)
  43. })
  44. it('keeps the control pipe open for a late binding reply until the host closes it', async () => {
  45. const { child, host } = endpoints()
  46. const state = processState()
  47. const terminal = Promise.withResolvers<unknown>()
  48. const messages: string[] = []
  49. let callId: unknown
  50. const peer = new JsonChannel(host, 4096, (raw) => {
  51. const message = raw as Record<string, unknown>
  52. messages.push(String(message.type))
  53. if (message.type === 'ready') void peer.send({ type: 'boot', data: {
  54. code: 'void tools.echo({}).then(() => { throw new Error("late reply resumed the program") }); setImmediate(() => console.log("late timer")); return 42;',
  55. namespaces: [{ global: 'tools', names: ['echo'] }],
  56. maxOutputBytes: 1024,
  57. } })
  58. if (message.type === 'call') callId = message.id
  59. if (message.type === 'done') terminal.resolve(decodePtcJsonWire(message.value))
  60. }, (error) => { terminal.reject(error) })
  61. const main = runNodeMain(child, 4096, state)
  62. try {
  63. expect(await terminal.promise).toBe(42)
  64. // Settle the terminal write callbacks while the host still owns the open pipe.
  65. await nextTurn()
  66. expect(child.destroyed).toBe(false)
  67. await peer.send({ type: 'reply', id: callId, ok: true, value: encodePtcJsonWire(42) })
  68. await nextTurn()
  69. expect(messages).toEqual(['ready', 'call', 'done'])
  70. } finally {
  71. peer.close()
  72. await main
  73. }
  74. expect(state.exitCode).toBeUndefined()
  75. })
  76. it.each([0, -1, 1.5, 4294967296])('rejects an invalid bootstrap frame limit %i', async (limit) => {
  77. const { child } = endpoints()
  78. await expect(runNodeMain(child, limit, processState())).rejects.toThrow('invalid control message limit')
  79. })
  80. it('rejects an unexpected first control frame', async () => {
  81. const { child, host } = endpoints()
  82. const peer = new JsonChannel(host, 4096, () => { void peer.send({ type: 'reply' }) }, () => {})
  83. onTestFinished(() => { peer.close() })
  84. await expect(runNodeMain(child, 4096, processState())).rejects.toThrow('expected program boot')
  85. })
  86. it('records an I/O failure before the boot frame', async () => {
  87. const { child, host } = endpoints()
  88. const state = processState()
  89. const peer = new JsonChannel(host, 4096, () => { peer.close() }, () => {})
  90. await expect(runNodeMain(child, 4096, state)).rejects.toBeInstanceOf(Error)
  91. expect(state.exitCode).toBe(1)
  92. })
  93. it('contains program writes that exceed queued control output', async () => {
  94. const { child, host } = endpoints()
  95. const state = processState()
  96. const peer = new JsonChannel(host, 1024, (raw) => {
  97. if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: { code: 'for(let i=0;i<30;i++) console.log("x".repeat(100));', namespaces: [], maxOutputBytes: 8000 } })
  98. }, () => {})
  99. onTestFinished(() => { peer.close() })
  100. await runNodeMain(child, 1024, state)
  101. expect(state.exitCode).toBe(1)
  102. })
  103. it('records a terminal frame that cannot fit the control write budget as failure', async () => {
  104. const { child, host } = endpoints()
  105. const state = processState()
  106. const peer = new JsonChannel(host, 1024, (raw) => {
  107. if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: {
  108. code: 'return "x".repeat(2000)', namespaces: [], maxOutputBytes: 8000,
  109. } })
  110. }, () => {})
  111. onTestFinished(() => { peer.close() })
  112. await runNodeMain(child, 1024, state)
  113. expect(state.exitCode).toBe(1)
  114. })
  115. it('retains a malformed host frame failure after the terminal frame', async () => {
  116. const { child, host } = endpoints()
  117. const state = processState()
  118. const terminal = Promise.withResolvers<undefined>()
  119. const peer = new JsonChannel(host, 1024, (raw) => {
  120. const message = raw as { type: string }
  121. if (message.type === 'ready') void peer.send({ type: 'boot', data: {
  122. code: 'return 42', namespaces: [], maxOutputBytes: 1000,
  123. } })
  124. if (message.type === 'done') terminal.resolve(undefined)
  125. }, () => {})
  126. onTestFinished(() => { peer.close() })
  127. const main = runNodeMain(child, 1024, state)
  128. try {
  129. await terminal.promise
  130. host.write(Buffer.from([0, 0, 0, 1, 0xff]))
  131. await main
  132. expect(state.exitCode).toBe(1)
  133. } finally { peer.close(); await main }
  134. })