test(smoke): fix flaky login-shell temp dir cleanup (ENOTEMPTY on CI)
disposeAll() only requests the pty kill; the bash child exits asynchronously via onExit, so deleting the temp HOME right after races the still-running shell (it may be writing history files) and fails with ENOTEMPTY on CI (seen on both #114 and #120). Wait for the spawned handle to report exited (bounded 2s) before removing the directory, with a short retry loop as a fallback.
This commit is contained in:
+30
-3
@@ -12,7 +12,7 @@ import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-setti
|
||||
import { apply, mediaTypeForPath } from '../src/index.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
import { listDirectory } from '../src/fs-tree.ts'
|
||||
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
|
||||
import { defaultShell, PtyManager, type SidebarPty } from '../src/pty-manager.ts'
|
||||
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
|
||||
|
||||
interface FakeContext {
|
||||
@@ -31,6 +31,32 @@ interface FakeContext {
|
||||
get: (key: string) => undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The login-shell test spawns a real pty whose bash may still be writing to
|
||||
* the temp HOME (history files, etc.) when `disposeAll()` returns — `close()`
|
||||
* only requests the kill and the process exit lands asynchronously in
|
||||
* `onExit`. Deleting the directory immediately then races the shell and
|
||||
* fails with ENOTEMPTY on CI. Wait for the spawned handle to report `exited`
|
||||
* (bounded), then remove with a short retry loop as a belt-and-braces
|
||||
* fallback for any straggler fd.
|
||||
*/
|
||||
async function rmTempDirAfterPtyExit(handle: { exited: boolean }, dir: string): Promise<void> {
|
||||
const deadline = Date.now() + 2000
|
||||
while (Date.now() < deadline && !handle.exited) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
const busy = (error as NodeJS.ErrnoException).code === 'ENOTEMPTY' || (error as NodeJS.ErrnoException).code === 'EBUSY'
|
||||
if (!busy || attempt >= 4) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('host plugin smoke', () => {
|
||||
it('serves PDF with the browser-native content type', () => {
|
||||
expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf')
|
||||
@@ -191,6 +217,7 @@ describe('host plugin smoke', () => {
|
||||
it.skipIf(process.platform === 'win32')('spawns the shell as a login shell (loads ~/.profile)', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-sidebar-login-'))
|
||||
const previousHome = process.env.HOME
|
||||
let handle: SidebarPty | undefined
|
||||
try {
|
||||
// A login bash reads ~/.profile (a non-login interactive bash reads
|
||||
// ~/.bashrc instead), so this marker proves the spawn used a login
|
||||
@@ -199,7 +226,7 @@ describe('host plugin smoke', () => {
|
||||
process.env.HOME = home
|
||||
const manager = new PtyManager('/bin/bash', 3)
|
||||
try {
|
||||
const handle = manager.open('s5', 't1', process.cwd(), 80, 24)
|
||||
handle = manager.open('s5', 't1', process.cwd(), 80, 24)
|
||||
handle.pty.write('echo $DSH_LOGIN_MARKER\r')
|
||||
const deadline = Date.now() + 5000
|
||||
while (!handle.transcript.includes('loaded-from-profile') && Date.now() < deadline) {
|
||||
@@ -212,7 +239,7 @@ describe('host plugin smoke', () => {
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME
|
||||
else process.env.HOME = previousHome
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
await rmTempDirAfterPtyExit(handle ?? { exited: true }, home)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user