fix(runtime): contain and clean temporary workspaces

This commit is contained in:
digua committed 2026-07-16 20:09:18 +08:00
1 parent c34f71ed2d
commit 7d156040f8
75 files changed
+801 -276

No files matched your search

+3 -3
View File
@@ -9,10 +9,10 @@
* Requires the native module to be built first: pnpm build:native
*/
import { createWriteStream, mkdtempSync, rmSync, statSync } from 'node:fs'
import { createWriteStream, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { once } from 'node:events'
import { createChatLabTempDir } from './chatlab-temp.mjs'
import { parseFileSync } from '../packages/parser/src/index'
import { loadNativeParser } from '../packages/parser/src/native/loader'
@@ -115,7 +115,7 @@ async function main() {
process.exit(1)
}
const dir = mkdtempSync(join(tmpdir(), 'chatlab-weflow-bench-'))
const dir = createChatLabTempDir('bench', 'weflow-')
const filePath = join(dir, 'bench.json')
try {
console.log(`Generating fixture with ${messageCount.toLocaleString()} messages...`)
+39
View File
@@ -0,0 +1,39 @@
import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
export const CHATLAB_TEMP_ROOT_ENV = 'CHATLAB_TEMP_ROOT'
const CHATLAB_TEMP_SCOPES = new Set(['tests', 'runtime', 'imports', 'merge', 'sync', 'parser', 'build', 'bench'])
export function resolveChatLabTempRoot(env = process.env) {
const configured = env[CHATLAB_TEMP_ROOT_ENV]?.trim()
if (configured) return resolve(configured)
return resolve(process.platform === 'darwin' ? '/private/tmp/chatlab' : join(tmpdir(), 'chatlab'))
}
function ensurePrivateDirectory(dirPath) {
mkdirSync(dirPath, { recursive: true, mode: 0o700 })
const stats = lstatSync(dirPath)
if (stats.isSymbolicLink()) throw new Error(`ChatLab temp directory must not be a symbolic link: ${dirPath}`)
if (!stats.isDirectory()) throw new Error(`ChatLab temp path must be a directory: ${dirPath}`)
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) {
throw new Error(`ChatLab temp directory is owned by another user: ${dirPath}`)
}
if (process.platform !== 'win32') chmodSync(dirPath, 0o700)
}
export function getChatLabTempScopeDir(scope, env = process.env) {
if (!CHATLAB_TEMP_SCOPES.has(scope)) throw new Error(`Invalid ChatLab temp scope: ${scope}`)
const root = resolveChatLabTempRoot(env)
ensurePrivateDirectory(root)
env[CHATLAB_TEMP_ROOT_ENV] = root
const scopeDir = join(root, scope)
ensurePrivateDirectory(scopeDir)
return scopeDir
}
export function createChatLabTempDir(scope, prefix, env = process.env) {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(prefix)) throw new Error(`Invalid ChatLab temp prefix: ${prefix}`)
return mkdtempSync(join(getChatLabTempScopeDir(scope, env), prefix))
}
+29 -5
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node
import { readdirSync, statSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs'
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { CHATLAB_TEMP_ROOT_ENV, getChatLabTempScopeDir } from './chatlab-temp.mjs'
const SKIP_DIRS = new Set([
'.git',
@@ -19,6 +20,8 @@ const SKIP_DIRS = new Set([
const TEST_FILE_RE = /\.(?:test|spec)\.(?:ts|tsx|js|jsx|mjs|mts|cjs|cts)$/
const SUPPORTED_NODE_MAJOR = 24
const TEST_RUN_TEMP_PREFIX = 'run-'
export const TEST_RUN_TEMP_ENV = 'CHATLAB_TEST_TMPDIR'
function normalizePath(filePath) {
return filePath.split(sep).join('/')
@@ -74,6 +77,24 @@ export function checkSupportedNodeVersion(version = process.versions.node) {
}
}
export function buildTestEnvironment(tempRoot, env = process.env) {
return { ...env, TMPDIR: tempRoot, [TEST_RUN_TEMP_ENV]: tempRoot, [CHATLAB_TEMP_ROOT_ENV]: tempRoot }
}
export function withTestRunTempRoot(callback, parentDir = getChatLabTempScopeDir('tests')) {
const resolvedParent = resolve(parentDir)
const tempRoot = mkdtempSync(join(resolvedParent, TEST_RUN_TEMP_PREFIX))
const resolvedRoot = resolve(tempRoot)
if (dirname(resolvedRoot) !== resolvedParent || !basename(resolvedRoot).startsWith(TEST_RUN_TEMP_PREFIX)) {
throw new Error(`Refusing to use non-owned test run temp root: ${resolvedRoot}`)
}
try {
return callback(tempRoot)
} finally {
rmSync(resolvedRoot, { recursive: true, force: true })
}
}
function run() {
const nodeVersion = checkSupportedNodeVersion()
if (!nodeVersion.ok) {
@@ -93,9 +114,12 @@ function run() {
console.log(`Running ${testArgs.length} default test files.`)
}
const result = spawnSync(process.execPath, buildNodeTestArgs(testArgs), {
stdio: 'inherit',
})
const result = withTestRunTempRoot((tempRoot) =>
spawnSync(process.execPath, buildNodeTestArgs(testArgs), {
stdio: 'inherit',
env: buildTestEnvironment(tempRoot),
})
)
if (result.error) {
throw result.error
+72 -1
View File
@@ -1,7 +1,16 @@
import assert from 'node:assert/strict'
import { existsSync, writeFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
import { buildNodeTestArgs, checkSupportedNodeVersion, filterDefaultTestFiles } from './run-tests.mjs'
import {
buildNodeTestArgs,
buildTestEnvironment,
checkSupportedNodeVersion,
filterDefaultTestFiles,
withTestRunTempRoot,
} from './run-tests.mjs'
import { CHATLAB_TEMP_ROOT_ENV } from './chatlab-temp.mjs'
test('default test collection excludes e2e, smoke, and real external tests', () => {
const files = [
@@ -39,3 +48,65 @@ test('node version check rejects unsupported test runtimes before native modules
'ChatLab tests require Node.js >=24 <25. Current Node.js is 22.20.0. Switch to Node 24 before running tests.',
})
})
test('test run temp root is removed after success and exceptions', () => {
let successfulRoot = ''
withTestRunTempRoot((tempRoot) => {
successfulRoot = tempRoot
writeFileSync(`${tempRoot}/success.txt`, 'ok')
assert.equal(existsSync(tempRoot), true)
})
assert.equal(existsSync(successfulRoot), false)
let failedRoot = ''
assert.throws(
() =>
withTestRunTempRoot((tempRoot) => {
failedRoot = tempRoot
throw new Error('simulated test runner failure')
}),
/simulated test runner failure/
)
assert.equal(existsSync(failedRoot), false)
})
test('test subprocesses receive the owned run root as every temp environment entry', () => {
const env = buildTestEnvironment('/tmp/chatlab/tests/run-example', { KEEP_ME: 'yes' })
assert.equal(env.TMPDIR, '/tmp/chatlab/tests/run-example')
assert.equal(env.CHATLAB_TEST_TMPDIR, '/tmp/chatlab/tests/run-example')
assert.equal(env[CHATLAB_TEMP_ROOT_ENV], '/tmp/chatlab/tests/run-example')
assert.equal(env.KEEP_ME, 'yes')
})
test('interrupted child exits before its owned test run temp root is removed', () => {
let tempRoot = ''
const result = withTestRunTempRoot((root) => {
tempRoot = root
return spawnSync(process.execPath, ['-e', "process.kill(process.pid, 'SIGTERM')"], {
env: buildTestEnvironment(root),
})
})
assert.equal(result.signal, 'SIGTERM')
assert.equal(existsSync(tempRoot), false)
})
test('parallel test runs receive isolated temp roots and never remove each other', () => {
let firstRoot = ''
let secondRoot = ''
withTestRunTempRoot((rootA) => {
firstRoot = rootA
withTestRunTempRoot((rootB) => {
secondRoot = rootB
assert.notEqual(rootA, rootB)
assert.equal(existsSync(rootA), true)
assert.equal(existsSync(rootB), true)
})
assert.equal(existsSync(rootA), true)
assert.equal(existsSync(secondRoot), false)
})
assert.equal(existsSync(firstRoot), false)
assert.equal(existsSync(secondRoot), false)
})