diff --git a/shell/task-timeout.sh b/shell/task-timeout.sh index a4448d97..4710a9a4 100644 --- a/shell/task-timeout.sh +++ b/shell/task-timeout.sh @@ -36,11 +36,15 @@ run_task_command() { ) & timeout_watcher=$! set +m - trap 'kill -KILL -- "-$timeout_watcher" 2>/dev/null || : + trap '{ + # Bash can report a killed job between any two cleanup commands. + # Keep redirection active until the watchdog is fully reaped. + kill -KILL -- "-$timeout_watcher" || : + wait "$timeout_watcher" || : + } 2>/dev/null if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi kill -KILL -- "-$timeout_worker" 2>/dev/null || : wait "$timeout_worker" 2>/dev/null || : - wait "$timeout_watcher" 2>/dev/null || : rm -rf -- "$timeout_dir"' EXIT trap 'exit 130' INT trap 'exit 143' TERM diff --git a/src/pages/script/index.tsx b/src/pages/script/index.tsx index 9cbf45d6..f29a06b6 100644 --- a/src/pages/script/index.tsx +++ b/src/pages/script/index.tsx @@ -4,6 +4,7 @@ import { SharedContext } from '@/layouts'; import { depthFirstSearch, findNode, getEditorMode } from '@/utils'; import config from '@/utils/config'; import { request } from '@/utils/http'; +import { getCodeMirrorExtensions } from '@/utils/codemirror/language'; import { canPreviewInMonaco } from '@/utils/monaco'; import { CloudDownloadOutlined, @@ -15,7 +16,6 @@ import { } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-layout'; import Editor from '@monaco-editor/react'; -import { langs } from '@uiw/codemirror-extensions-langs'; import CodeMirror from '@uiw/react-codemirror'; import { history, useOutletContext } from '@umijs/max'; import { @@ -747,9 +747,7 @@ const Script = () => { {isPhone && ( { diff --git a/src/utils/codemirror/language.ts b/src/utils/codemirror/language.ts new file mode 100644 index 00000000..3f8baf46 --- /dev/null +++ b/src/utils/codemirror/language.ts @@ -0,0 +1,11 @@ +import { langs } from '@uiw/codemirror-extensions-langs'; + +export function getCodeMirrorExtensions(mode: string) { + // Monaco calls this language "ini"; CodeMirror exposes it as "properties". + const language = mode === 'ini' ? 'properties' : mode; + if (!Object.prototype.hasOwnProperty.call(langs, language)) { + return []; + } + const factory = langs[language as keyof typeof langs]; + return typeof factory === 'function' ? [factory()] : []; +} diff --git a/test/back/task-timeout-cleanup.test.cjs b/test/back/task-timeout-cleanup.test.cjs new file mode 100644 index 00000000..bd7f5be0 --- /dev/null +++ b/test/back/task-timeout-cleanup.test.cjs @@ -0,0 +1,34 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const source = fs.readFileSync( + path.resolve(__dirname, '../../shell/task-timeout.sh'), + 'utf8', +); + +test('timed task cleanup preserves output and exit codes without Bash job notifications', () => { + const result = spawnSync( + process.env.QL_TEST_BASH || '/bin/bash', + [ + '-c', + source + + ` + command_timeout_time=1h + for code in 0 7 0 7 0 7 0 7 0 7; do + # Keep a descendant alive after the task leader exits so cleanup must + # terminate the worker group as well as the sleeping watchdog. + run_task_command bash -c 'sleep 10 & echo TASK_OUTPUT; echo TASK_ERROR >&2; exit "$1"' task "$code" + actual=$? + [[ $actual == "$code" ]] || exit 99 + done + `, + ], + { encoding: 'utf8', timeout: 15000 }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'TASK_OUTPUT\n'.repeat(10)); + assert.equal(result.stderr, 'TASK_ERROR\n'.repeat(10)); +}); diff --git a/test/front/codemirror-language.test.cjs b/test/front/codemirror-language.test.cjs new file mode 100644 index 00000000..fb87a25e --- /dev/null +++ b/test/front/codemirror-language.test.cjs @@ -0,0 +1,57 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const test = require('node:test'); +const { EditorState } = require('@codemirror/state'); +const load = require('../helpers/load-security-module.cjs'); +const { getCodeMirrorExtensions } = load( + path.resolve(__dirname, '../../src/utils/codemirror/language.ts'), +); +const { LANG_MAP } = load(path.resolve(__dirname, '../../src/utils/const.ts')); +const languageRequire = createRequire( + require.resolve('@uiw/codemirror-extensions-langs'), +); +const { syntaxTree } = languageRequire('@codemirror/language'); + +test('INI documents load and remain editable with CodeMirror syntax support', () => { + const doc = '; comment\n[section]\nkey=value\n'; + const state = EditorState.create({ + doc, + extensions: getCodeMirrorExtensions(LANG_MAP['.ini']), + }); + assert.ok(syntaxTree(state).length > 0); + assert.ok(syntaxTree(state).topNode.firstChild, 'INI tokens were parsed'); + const updated = state.update({ + changes: { + from: doc.indexOf('value'), + to: doc.indexOf('value') + 5, + insert: 'edited', + }, + }).state; + assert.equal(updated.doc.toString(), '; comment\n[section]\nkey=edited\n'); +}); + +test('every configured script language supplies a valid CodeMirror extension', () => { + for (const mode of new Set(Object.values(LANG_MAP))) { + const extensions = getCodeMirrorExtensions(mode); + assert.equal(extensions.length, 1, mode); + assert.doesNotThrow( + () => EditorState.create({ doc: 'sample', extensions }), + mode, + ); + } +}); + +test('unknown or empty languages fall back to editable plain text', () => { + for (const mode of ['', undefined, 'unknown', 'constructor', 'toString']) { + const extensions = getCodeMirrorExtensions(mode); + assert.deepEqual(extensions, []); + const state = EditorState.create({ doc: 'text', extensions }); + assert.equal( + state + .update({ changes: { from: 4, insert: ' edited' } }) + .state.doc.toString(), + 'text edited', + ); + } +});