|
1 | 1 | import type { DevframeTerminalSession } from '../../types/terminals' |
2 | 2 | import type { DevframeHubContext } from '../context' |
| 3 | +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' |
| 4 | +import { tmpdir } from 'node:os' |
| 5 | +import { join } from 'node:path' |
3 | 6 | import process from 'node:process' |
4 | 7 | import { describe, expect, it, vi } from 'vitest' |
5 | 8 | import { hasNative } from 'zigpty' |
@@ -71,8 +74,8 @@ function createTerminalHost() { |
71 | 74 | } |
72 | 75 | } |
73 | 76 |
|
74 | | -async function waitUntil(assertion: () => void): Promise<void> { |
75 | | - const deadline = Date.now() + 1000 |
| 77 | +async function waitUntil(assertion: () => void, timeout = 1000): Promise<void> { |
| 78 | + const deadline = Date.now() + timeout |
76 | 79 | let lastError: unknown |
77 | 80 | while (Date.now() < deadline) { |
78 | 81 | try { |
@@ -397,6 +400,104 @@ describe('devframeTerminalHost child-process status lifecycle', () => { |
397 | 400 | }) |
398 | 401 | }) |
399 | 402 |
|
| 403 | +// On Windows, tinyexec runs anything that isn't a `.exe`/`.com` (e.g. the |
| 404 | +// `node_modules/.bin/*.cmd` shims package managers generate) through |
| 405 | +// `cmd.exe /d /s /c`, so the pid the host holds belongs to `cmd.exe`, not to |
| 406 | +// the program the shim starts. Killing only that pid leaves the real process |
| 407 | +// orphaned (still holding its ports). |
| 408 | +describe.runIf(process.platform === 'win32')('devframeTerminalHost child-process tree on Windows', { timeout: 20_000 }, () => { |
| 409 | + function isAlive(pid: number): boolean { |
| 410 | + try { |
| 411 | + process.kill(pid, 0) |
| 412 | + return true |
| 413 | + } |
| 414 | + catch { |
| 415 | + return false |
| 416 | + } |
| 417 | + } |
| 418 | + |
| 419 | + async function startShimSession(host: DevframeTerminalsHost, id: string) { |
| 420 | + const dir = mkdtempSync(join(tmpdir(), 'devframe-terminals-shim-')) |
| 421 | + writeFileSync(join(dir, 'child.cjs'), 'console.log("pid:" + process.pid); setInterval(() => {}, 1000)\n') |
| 422 | + const shim = join(dir, 'child.cmd') |
| 423 | + writeFileSync(shim, `@"${NODE}" "%~dp0\\child.cjs" %*\r\n`) |
| 424 | + const session = await host.startChildProcess({ command: shim, args: [], cwd: dir }, { id, title: id }) |
| 425 | + let pid = 0 |
| 426 | + await waitUntil(() => { |
| 427 | + const match = session.buffer?.join('').match(/pid:(\d+)/) |
| 428 | + expect(match).toBeTruthy() |
| 429 | + pid = Number(match![1]) |
| 430 | + }, 10_000) |
| 431 | + // The host holds the `cmd.exe` wrapper, not the node child. |
| 432 | + expect(session.getChildProcess()?.pid).not.toBe(pid) |
| 433 | + return { session, pid, cleanup: () => rmSync(dir, { recursive: true, force: true }) } |
| 434 | + } |
| 435 | + |
| 436 | + it('terminate() kills the process started by a .cmd shim', async () => { |
| 437 | + const { host } = createTerminalHost() |
| 438 | + const { session, pid, cleanup } = await startShimSession(host, 'shim-terminate') |
| 439 | + try { |
| 440 | + await session.terminate() |
| 441 | + await waitUntil(() => expect(isAlive(pid)).toBe(false), 5000) |
| 442 | + expect(session.status).toBe('stopped') |
| 443 | + } |
| 444 | + finally { |
| 445 | + if (isAlive(pid)) |
| 446 | + process.kill(pid) |
| 447 | + cleanup() |
| 448 | + } |
| 449 | + }) |
| 450 | + |
| 451 | + it('terminate() reports a stopped, killed run rather than a crash', async () => { |
| 452 | + const { host, sinks } = createTerminalHost() |
| 453 | + const { session, pid, cleanup } = await startShimSession(host, 'shim-result') |
| 454 | + const result = session.getResult() |
| 455 | + try { |
| 456 | + await session.terminate() |
| 457 | + await waitUntil(() => expect(sinks.get('shim-result')?.closed).toBe(true)) |
| 458 | + const output = await result |
| 459 | + expect(output.exitCode).toBeUndefined() |
| 460 | + expect(result.killed).toBe(true) |
| 461 | + expect(session.status).toBe('stopped') |
| 462 | + } |
| 463 | + finally { |
| 464 | + if (isAlive(pid)) |
| 465 | + process.kill(pid) |
| 466 | + cleanup() |
| 467 | + } |
| 468 | + }) |
| 469 | + |
| 470 | + it('restart() kills the previous run started by a .cmd shim', async () => { |
| 471 | + const { host } = createTerminalHost() |
| 472 | + const { session, pid, cleanup } = await startShimSession(host, 'shim-restart') |
| 473 | + try { |
| 474 | + await session.restart() |
| 475 | + await waitUntil(() => expect(isAlive(pid)).toBe(false), 5000) |
| 476 | + expect(session.status).toBe('running') |
| 477 | + await session.terminate() |
| 478 | + } |
| 479 | + finally { |
| 480 | + if (isAlive(pid)) |
| 481 | + process.kill(pid) |
| 482 | + cleanup() |
| 483 | + } |
| 484 | + }) |
| 485 | + |
| 486 | + it('cancelling the stream kills the process started by a .cmd shim', async () => { |
| 487 | + const { host } = createTerminalHost() |
| 488 | + const { session, pid, cleanup } = await startShimSession(host, 'shim-cancel') |
| 489 | + try { |
| 490 | + host.remove(session) |
| 491 | + await waitUntil(() => expect(isAlive(pid)).toBe(false), 5000) |
| 492 | + } |
| 493 | + finally { |
| 494 | + if (isAlive(pid)) |
| 495 | + process.kill(pid) |
| 496 | + cleanup() |
| 497 | + } |
| 498 | + }) |
| 499 | +}) |
| 500 | + |
400 | 501 | describe('devframeTerminalHost interactive PTY sessions', () => { |
401 | 502 | itPty('inherits the parent process environment', async () => { |
402 | 503 | expect.assertions(1) |
|
0 commit comments