From 13c0a4d21b39d490e81dd8928748104e59f55d01 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 15 Oct 2026 01:16:31 +0800 Subject: [PATCH] fix: handle mobile INI editing and silence timeout cleanup Map INI to the CodeMirror properties language and fall back to plain text for unsupported modes. Reap the timeout watchdog immediately under stderr redirection to prevent Bash job notifications during cleanup. Refs #3095, #3096 --- shell/task-timeout.sh | 8 +++- src/pages/script/index.tsx | 6 +-- src/utils/codemirror/language.ts | 11 +++++ test/back/task-timeout-cleanup.test.cjs | 34 +++++++++++++++ test/front/codemirror-language.test.cjs | 57 +++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 src/utils/codemirror/language.ts create mode 100644 test/back/task-timeout-cleanup.test.cjs create mode 100644 test/front/codemirror-language.test.cjs 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', + ); + } +});