From 1e652d001deff7972283068008f29cee6c6e6f79 Mon Sep 17 00:00:00 2001 From: Davin Shearer <2205472+scholarsmate@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:34:50 -0400 Subject: [PATCH] =?UTF-8?q?-=20upgraded=20the=20=CE=A9edit=E2=84=A2=20inte?= =?UTF-8?q?gration=20to=20`v2.0.0=20-=20updated=20the=20data=20editor=20to?= =?UTF-8?q?=20the=20=CE=A9edit=E2=84=A2=202.x=20client=20APIs,=20including?= =?UTF-8?q?=20heartbeat=20and=20viewport=20subscription=20helpers=20-=20ad?= =?UTF-8?q?justed=20server=20metrics=20and=20packaging=20to=20the=20new=20?= =?UTF-8?q?native=20runtime=20fields=20and=20`@omega-edit/server/out`=20la?= =?UTF-8?q?yout=20-=20refreshed=20the=20=CE=A9edit=E2=84=A2=20test=20start?= =?UTF-8?q?up=20call=20sites=20for=20the=202.x=20`startServer(...,=20{=20l?= =?UTF-8?q?ogConfigFile=20})`=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ωedit™ `v2.0.0` changes the client/server package layout and several APIs. This PR aligns the VS Code extension with the migration guide and the new helper APIs so we can validate the upgrade in CI. - keeps the data editor building and packaging against the RC release - updates the runtime metrics surfaced in the UI to match the new server fields - ensures the packaged VSIX includes the server binaries from the new `out/` directory structure - `yarn install` - `yarn compile` - `yarn test:svelte` - `yarn vite:pkg` - `yarn package` - Manual run through of the data editor features in the extension, including: - opening a data editor and confirming it loads successfully - confirming the server info shows the new heartbeat messages - confirming the file type information in the profile view is correct - confirming the server shuts down after the idle timeout when no editor is open - confirming search and replace features work as expected with the new client APIs - confirming the viewport subscription helper correctly updates the visible range in the UI as we scroll - others _as needed_ to validate the upgrade and new features - [x] I have determined that no documentation updates are needed for these changes Closes: #1667 --- build/package/LICENSE | 62 ++ build/package/NONOTICE | 6 + build/yarn-scripts.ts | 234 ++++++- package.json | 24 +- src/dataEditor/config/Config.ts | 56 +- src/dataEditor/config/Extract.ts | 13 +- src/dataEditor/dataEditorClient.ts | 571 +++++++++++++----- .../include/server/LogbackConfig.ts | 53 ++ src/dataEditor/include/server/ServerInfo.ts | 43 +- src/dataEditor/include/server/Sessions.ts | 49 +- .../include/server/heartbeat/HeartBeatInfo.ts | 9 +- .../include/server/heartbeat/index.ts | 36 +- src/dataEditor/svelteWebviewInitializer.ts | 22 +- src/svelte/src/App.svelte | 20 +- .../CustomByteDisplay/DataLineFeed.svelte | 2 +- .../ServerMetrics/ServerMetrics.svelte | 54 +- src/svelte/src/components/dataEditor.svelte | 19 +- src/svelte/src/utilities/message.ts | 1 + src/tests/omegaEditServerLifecycle.ts | 147 +++++ src/tests/suite/dataEditor.test.ts | 44 +- src/tests/suite/omegaEditClientLogger.test.ts | 47 ++ src/tests/suite/utils.test.ts | 2 +- src/utils.ts | 2 +- vite.config.mjs | 10 +- yarn.lock | 182 ++---- 25 files changed, 1243 insertions(+), 465 deletions(-) create mode 100644 src/dataEditor/include/server/LogbackConfig.ts create mode 100644 src/tests/omegaEditServerLifecycle.ts create mode 100644 src/tests/suite/omegaEditClientLogger.test.ts diff --git a/build/package/LICENSE b/build/package/LICENSE index bbff97db7..06041f3d7 100644 --- a/build/package/LICENSE +++ b/build/package/LICENSE @@ -1103,6 +1103,41 @@ conditions of the following licenses. TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- '@protobuf-ts/runtime' in extension/dist/ext/extension.js +- '@protobuf-ts/runtime' in node_modules/@omega-edit/client + This product bundles '@protobuf-ts/runtime' from the above files. + This package is available under the BSD-3-Clause License and Apache License 2.0: + + BSD 3-Clause License + + Copyright (c) Timo Stamm + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + - 'protobufjs' in extension/dist/ext/extension.js - 'protobufjs' in node_modules/@omega-edit/client This product bundles 'protobufjs' from the above files. @@ -5214,6 +5249,33 @@ conditions of the following licenses. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +- '@pinojs/redact' in extension/dist/ext/extension.js +- '@pinojs/redact' in node_modules/@omega-edit/client + This product bundles '@pinojs/redact' from the above files. + These files are available under the MIT License: + + MIT License + + Copyright (c) 2025 pinojs contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. - 'unicorn-magic' in extension/dist/ext/extension.js This produces bundles 'unicorn-magic' from the above files diff --git a/build/package/NONOTICE b/build/package/NONOTICE index ba89c9070..40d0ef4a6 100644 --- a/build/package/NONOTICE +++ b/build/package/NONOTICE @@ -33,6 +33,12 @@ The following binary components distributed with this project are licensed under This package is available under the Apache License v2 without a NOTICE: Repository at: https://github.com/dcodeIO/long.js +- '@protobuf-ts/runtime-rpc' in extension/dist/ext/extension.js +- '@protobuf-ts/runtime-rpc' in node_modules/@omega-edit/client + This product bundles '@protobuf-ts/runtime-rpc' from the above files. + This package is available under the Apache License v2 without a NOTICE: + Repository at: https://github.com/timostamm/protobuf-ts + - com.fasterxml.woodstox.woodstox-core-.jar in daffodil-debugger-.zip This product bundles 'woodstox-core' from the above files. These packages are available under the Apache License v2 without a NOTICE: diff --git a/build/yarn-scripts.ts b/build/yarn-scripts.ts index ab798a107..1acaf6644 100644 --- a/build/yarn-scripts.ts +++ b/build/yarn-scripts.ts @@ -95,13 +95,236 @@ function package() { # limitations under the License. **/node_modules/**/* -!node_modules/@omega-edit/server/bin -!node_modules/@omega-edit/server/lib -!node_modules/@vscode/webview-ui-toolkit/**/* +!node_modules/ +!node_modules/**/* ` ) } +function packageNamePath(packageName) { + return path.join(...packageName.split('/')) +} + +function readPackageVersion(packageRoot) { + const packageJsonPath = path.join(packageRoot, 'package.json') + if (!fs.existsSync(packageJsonPath)) return undefined + + try { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')).version + } catch (err) { + console.warn( + `[omega-edit] Unable to read package version for ${packageRoot}: ${String( + err + )}` + ) + return undefined + } +} + +function shouldPatchOmegaEditPackage(packageRoot, expectedVersion, label) { + const version = readPackageVersion(packageRoot) + if (version === expectedVersion) { + return true + } + + const versionLabel = version ?? 'unknown' + console.warn( + `[omega-edit] Skipping ${label} patch for ${packageRoot}; expected ${expectedVersion}, found ${versionLabel}.` + ) + return false +} + +function patchOmegaEditClientLogger( + packageRoot = 'node_modules/@omega-edit/client' +) { + if (!shouldPatchOmegaEditPackage(packageRoot, '2.0.0', 'client logger')) { + return + } + + const loggerTargets = [ + path.join(packageRoot, 'dist/cjs/logger.js'), + path.join(packageRoot, 'dist/esm/logger.js'), + ] + const transportPattern = + /setLogger\(buildLogger\(pino(?:_1\.default)?\.transport\(\{[\s\S]*?\}\)\)\);/ + + loggerTargets.forEach((loggerPath) => { + if (!fs.existsSync(loggerPath)) { + console.warn(`[omega-edit] Client logger not found: ${loggerPath}`) + return + } + + const source = fs.readFileSync(loggerPath, 'utf-8') + const patched = source.replace( + transportPattern, + 'setLogger(buildLogger(process.stderr));' + ) + + if (patched === source) { + if (!source.includes('setLogger(buildLogger(process.stderr));')) { + console.warn( + `[omega-edit] Unable to patch OmegaEdit client logger at ${loggerPath}; leaving upstream source unchanged.` + ) + } + return + } + + fs.writeFileSync(loggerPath, patched, 'utf-8') + }) +} + +function patchOmegaEditServerLocator(searchRoot = 'node_modules') { + const serverTargets = glob.sync('**/@omega-edit/server/out/index.js', { + cwd: searchRoot, + absolute: true, + nodir: true, + }) + const buggyLocator = '.replace("node_modules","")' + const knownFixedLocators = [ + '.slice(0,-"node_modules".length)', + '.slice(0,-12)', + ] + + if (serverTargets.length === 0) { + return + } + + serverTargets.forEach((serverPath) => { + const packageRoot = path.dirname(path.dirname(serverPath)) + if (!shouldPatchOmegaEditPackage(packageRoot, '2.0.0', 'server locator')) { + return + } + + const source = fs.readFileSync(serverPath, 'utf-8') + const patched = source.replaceAll(buggyLocator, knownFixedLocators[0]) + + if (patched === source) { + if (!knownFixedLocators.some((locator) => source.includes(locator))) { + console.warn( + `[omega-edit] Unable to patch OmegaEdit server locator at ${serverPath}; leaving upstream source unchanged.` + ) + } + return + } + + fs.writeFileSync(serverPath, patched, 'utf-8') + }) +} + +function patchOmegaEditRuntime( + packageRoot = 'node_modules/@omega-edit/client', + searchRoot = 'node_modules' +) { + patchOmegaEditClientLogger(packageRoot) + patchOmegaEditServerLocator(searchRoot) +} + +function copyPackageRuntimeTree( + packageName, + sourcePackageDir, + destinationPackageDir, + seen = new Set() +) { + const visitKey = `${sourcePackageDir}|${destinationPackageDir}` + if (seen.has(visitKey)) return + seen.add(visitKey) + + if (!fs.existsSync(sourcePackageDir)) { + throw new Error( + `Package source not found for ${packageName}: ${sourcePackageDir}` + ) + } + + rmFileOrDirectory(destinationPackageDir) + fs.mkdirSync(path.dirname(destinationPackageDir), { recursive: true }) + fs.cpSync(sourcePackageDir, destinationPackageDir, { + recursive: true, + force: true, + }) + + const packageJsonPath = path.join(sourcePackageDir, 'package.json') + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) + const dependencies = Object.keys(packageJson.dependencies || {}) + + if (dependencies.length === 0) return + + const destinationNodeModulesDir = path.join( + destinationPackageDir, + 'node_modules' + ) + + dependencies.forEach((dependencyName) => { + const sourceDependencyDirCandidates = [ + path.join( + sourcePackageDir, + 'node_modules', + packageNamePath(dependencyName) + ), + path.join('node_modules', packageNamePath(dependencyName)), + ] + const sourceDependencyDir = sourceDependencyDirCandidates.find( + (candidate) => fs.existsSync(candidate) + ) + + if (!sourceDependencyDir) { + throw new Error( + `Unable to resolve runtime dependency ${dependencyName} for ${packageName}` + ) + } + + copyPackageRuntimeTree( + dependencyName, + sourceDependencyDir, + path.join(destinationNodeModulesDir, packageNamePath(dependencyName)), + seen + ) + }) +} + +function syncOmegaEditClientRuntime() { + const clientPackageName = '@omega-edit/client' + const sourceClientDir = path.join( + 'node_modules', + packageNamePath(clientPackageName) + ) + const destinationClientDir = path.join( + 'dist/package/node_modules', + packageNamePath(clientPackageName) + ) + + patchOmegaEditRuntime(sourceClientDir, 'node_modules') + copyPackageRuntimeTree( + clientPackageName, + sourceClientDir, + destinationClientDir + ) + patchOmegaEditRuntime(destinationClientDir, 'dist/package/node_modules') +} + +function packageVsix() { + const vsceCommand = + process.platform === 'win32' + ? path.resolve('node_modules', '.bin', 'vsce.cmd') + : path.resolve('node_modules', '.bin', 'vsce') + + const result = child_process.spawnSync( + vsceCommand, + ['package', '--out', '../../'], + { + cwd: 'dist/package', + stdio: 'inherit', + shell: process.platform === 'win32', + } + ) + + if (result.error) { + console.error(result.error) + process.exit(1) + } + + process.exit(result.status === null ? 1 : result.status) +} + /* START SECTION: Update version */ // helper function to get the version passed in function parseArgs() { @@ -257,6 +480,11 @@ module.exports = { updateVersion: updateVersion, watch: watch, package: package, + patchOmegaEditClientLogger: patchOmegaEditClientLogger, + patchOmegaEditServerLocator: patchOmegaEditServerLocator, + patchOmegaEditRuntime: patchOmegaEditRuntime, + syncOmegaEditClientRuntime: syncOmegaEditClientRuntime, + packageVsix: packageVsix, checkMissingLicenseData: checkMissingLicenseData, checkLicenseCompatibility: checkLicenseCompatibility, } diff --git a/package.json b/package.json index 7b713a3d9..4d01150b9 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "gen-version-ts": "run-func build/yarn-scripts.ts genVersionTS", "nodeclean": "run-func build/yarn-scripts.ts nodeclean", "scalaclean": "run-func build/yarn-scripts.ts scalaclean", + "postinstall": "node -e \"const fs=require('fs'); const cp=require('child_process'); if (fs.existsSync('build/yarn-scripts.ts')) { const cmd=process.platform === 'win32' ? 'yarn.cmd' : 'yarn'; const result=cp.spawnSync(cmd, ['run-func', 'build/yarn-scripts.ts', 'patchOmegaEditRuntime'], { stdio: 'inherit', shell: process.platform === 'win32' }); if (result.error) { console.error(result.error); process.exit(1); } process.exit(result.status === null ? 1 : result.status); }\"", "check-missing-license-data": "run-func build/yarn-scripts.ts checkMissingLicenseData", "check-license-compatibility": "run-func build/yarn-scripts.ts checkLicenseCompatibility", "clean": "yarn nodeclean && yarn scalaclean", @@ -41,9 +42,9 @@ "watch:tdmlEditorJS": "esbuild src/tdmlEditor/webview/webview.js --outfile=dist/views/tdmlEditor/webview/webview.js --bundle --platform=node --format=cjs --watch", "compile:tdmlEditorJS": "esbuild src/tdmlEditor/webview/webview.js --outfile=dist/views/tdmlEditor/webview/webview.js --bundle --platform=node --format=cjs", "prepackage": "yarn install && yarn compile && yarn vite:pkg", - "package": "run-func build/yarn-scripts.ts package && yarn --cwd dist/package install && yarn --cwd dist/package vsce package --out ../../", + "package": "run-func build/yarn-scripts.ts package && yarn --cwd dist/package install --production && run-func build/yarn-scripts.ts syncOmegaEditClientRuntime && run-func build/yarn-scripts.ts packageVsix", "pretest": "yarn compile && yarn vite:dev", - "test": "sbt test && yarn test:svelte && node ./out/tests/runTest.js", + "test": "sbt test && yarn test:svelte && node ./out/tests/omegaEditServerLifecycle.js && node ./out/tests/runTest.js", "test:svelte": "mocha --import=tsx ./src/svelte/tests/**/*.test.ts", "sbt": "sbt Universal/stage", "svelte:check": "svelte-check --tsconfig ./src/svelte/tsconfig.json", @@ -54,7 +55,8 @@ "watch:vite-dev": "yarn vite build --mode development -c ./vite.config.mjs --watch" }, "dependencies": { - "@omega-edit/client": "^1.0.1", + "@omega-edit/client": "2.0.0", + "@omega-edit/server": "2.0.0", "@viperproject/locate-java-home": "1.1.17", "@vscode/debugadapter": "1.67.0", "@vscode/webview-ui-toolkit": "^1.2.2", @@ -110,6 +112,7 @@ "vscode-extension-tester": "5.9.1" }, "resolutions": { + "@omega-edit/server": "2.0.0", "cookie": ">=0.7.0", "diff": ">=8.0.3", "serialize-javascript": ">=7.0.3" @@ -118,6 +121,7 @@ "activationEvents": [ "onDebugResolve:dfdl", "onDebugDynamicConfigurations:dfdl", + "onCommand:extension.data.edit", "onCommand:extension.dfdl-debug.getSchemaName", "onCommand:extension.dfdl-debug.getDataName", "onCommand:extension.dfdl-debug.getTDMLName", @@ -662,8 +666,8 @@ "properties": { "file": { "type": "string", - "description": "Path to file to save logs at", - "default": "${workspaceFolder}/dataEditor-${omegaEditPort}.log" + "description": "Path to file to save logs at. Leave empty to use the OS app-data/XDG path.", + "default": "" }, "level": { "type": "string", @@ -679,7 +683,7 @@ } }, "default": { - "file": "${workspaceFolder}/dataEditor-${omegaEditPort}.log", + "file": "", "level": "info" } } @@ -687,7 +691,7 @@ "default": { "port": 9000, "logging": { - "file": "${workspaceFolder}/dataEditor-${omegaEditPort}.log", + "file": "", "level": "info" } } @@ -776,7 +780,7 @@ "dataEditor": { "port": 9000, "logging": { - "file": "${workspaceFolder}/dataEditor-${omegaEditPort}.log", + "file": "", "level": "info" } }, @@ -824,7 +828,7 @@ "dataEditor": { "port": 9000, "logging": { - "file": "^\"\\${workspaceFolder}/dataEditor-\\${omegaEditPort}.log\"", + "file": "", "level": "info" } }, @@ -944,7 +948,7 @@ "default": { "port": 9000, "logging": { - "file": "${workspaceFolder}/dataEditor-${omegaEditPort}.log", + "file": "", "level": "info" } } diff --git a/src/dataEditor/config/Config.ts b/src/dataEditor/config/Config.ts index 74f6a689f..eea54a6f0 100644 --- a/src/dataEditor/config/Config.ts +++ b/src/dataEditor/config/Config.ts @@ -20,11 +20,56 @@ import { configureIf, } from './ConfigKeyword' import { addToAppDataPath, rootPath } from './Extract' +import path from 'path' const portDefault = 9000 const logFileDefault = addToAppDataPath('dataEditor-${omegaEditPort}.log') const logLevelDefault = 'info' -const checkpointPathDefault = addToAppDataPath(`.checkpoint-${portDefault}`) +const legacyWorkspaceLogFileTemplate = `${WorkspaceKeyword}/dataEditor-${ServerPortKeyword}.log` + +export function getDefaultLogFilePath(port: number): string { + return addToAppDataPath(`dataEditor-${port}.log`) +} + +export function getDefaultCheckpointPath(port: number): string { + return addToAppDataPath(`.checkpoint-${port}`) +} + +export function isLegacyWorkspaceLogFile( + logFile: string, + port: number +): boolean { + const normalized = path.normalize(logFile) + return ( + normalized === path.normalize(legacyWorkspaceLogFileTemplate) || + normalized === path.normalize(path.join(rootPath, `dataEditor-${port}.log`)) + ) +} + +export function normalizeDataEditorLogFile( + logFile: string, + port: number +): string { + const trimmed = logFile.trim() + if (trimmed.length === 0) { + return getDefaultLogFilePath(port) + } + + const resolved = configureIf(trimmed, [ + { keyword: WorkspaceKeyword, replacement: rootPath }, + { keyword: ServerPortKeyword, replacement: port.toString() }, + ]) + const normalized = path.normalize(resolved) + + if ( + isLegacyWorkspaceLogFile(trimmed, port) || + isLegacyWorkspaceLogFile(normalized, port) + ) { + return getDefaultLogFilePath(port) + } + + return path.isAbsolute(normalized) ? normalized : addToAppDataPath(normalized) +} export type ConfigJSON = { port: number @@ -49,15 +94,12 @@ export class Config implements IConfig { port: portDefault, logFile: logFileDefault, logLevel: logLevelDefault, - checkpointPath: checkpointPathDefault, + checkpointPath: getDefaultCheckpointPath(portDefault), }) private constructor(configuration: Required) { const { port, logFile, logLevel, checkpointPath } = configuration this.port = port - this.logFile = configureIf(logFile, [ - { keyword: WorkspaceKeyword, replacement: rootPath }, - { keyword: ServerPortKeyword, replacement: port.toString() }, - ]) + this.logFile = normalizeDataEditorLogFile(logFile, port) this.logLevel = logLevel this.checkpointPath = checkpointPath } @@ -66,7 +108,7 @@ export class Config implements IConfig { port: json.port, logFile: json.logging.file, logLevel: json.logging.level, - checkpointPath: checkpointPathDefault, + checkpointPath: getDefaultCheckpointPath(json.port), }) } } diff --git a/src/dataEditor/config/Extract.ts b/src/dataEditor/config/Extract.ts index 3cf6d6728..dbfa372d9 100644 --- a/src/dataEditor/config/Extract.ts +++ b/src/dataEditor/config/Extract.ts @@ -15,7 +15,13 @@ * limitations under the License. */ import { Uri, workspace } from 'vscode' -import { Config, ConfigJSON, IConfig } from './Config' +import { + Config, + ConfigJSON, + IConfig, + getDefaultCheckpointPath, + getDefaultLogFilePath, +} from './Config' import XDGAppPaths from 'xdg-app-paths' import path from 'path' import { substituteVSCodeEnvVariables } from '../../utils' @@ -57,9 +63,10 @@ export function extractConfigurationVariables(): IConfig { substituteVSCodeEnvVariables( workspaceConfig.dataEditor?.logging?.file, APP_DATA_PATH - ).replaceAll(ServerPortKeyword, port) + ).replaceAll(ServerPortKeyword, port.toString()) ) - : Config.Default.logFile, // Get logging file path from settings.json if exists + : getDefaultLogFilePath(port), // Get logging file path from settings.json if exists + checkpointPath: getDefaultCheckpointPath(port), } if (configObjArray === undefined || configObjArray.length === 0) diff --git a/src/dataEditor/dataEditorClient.ts b/src/dataEditor/dataEditorClient.ts index def11f135..3d66829ce 100644 --- a/src/dataEditor/dataEditorClient.ts +++ b/src/dataEditor/dataEditorClient.ts @@ -17,7 +17,6 @@ import { ALL_EVENTS, - beginSessionTransaction, clear, countCharacters, CountKind, @@ -26,9 +25,6 @@ import { createViewport, del, edit, - EditorClient, - endSessionTransaction, - EventSubscriptionRequest, getByteOrderMark, getClient, getClientVersion, @@ -37,6 +33,7 @@ import { getCounts, getLanguage, getLogger, + resetClient, getServerInfo, getViewportData, IOFlags, @@ -45,15 +42,16 @@ import { profileSession, redo, replaceOneSession, + runSessionTransaction, saveSession, SaveStatus, searchSession, setLogger, startServer, stopProcessUsingPID, + subscribeViewportEvents, undo, ViewportDataResponse, - ViewportEvent, ViewportEventKind, } from '@omega-edit/client' import assert from 'assert' @@ -80,10 +78,10 @@ import { addActiveSession, removeActiveSession, } from './include/server/Sessions' +import { writeLogbackConfigFile } from './include/server/LogbackConfig' import { getCurrentHeartbeatInfo } from './include/server/heartbeat' import * as child_process from 'child_process' import { osCheck } from '../utils' -import { isDFDLDebugSessionActive } from './include/utils' // ***************************************************************************** // global constants @@ -99,6 +97,8 @@ export const APP_DATA_PATH: string = XDGAppPaths({ name: 'omega_edit' }).data() // ***************************************************************************** const HEARTBEAT_INTERVAL_MS: number = 1000 // 1 second (1000 ms) +const SERVER_SESSION_TIMEOUT_MS: number = 60 * 1000 +const SERVER_CLEANUP_INTERVAL_MS: number = 15 * 1000 const MAX_LOG_FILES: number = 5 // Maximum number of log files to keep TODO: make this configurable const OPEN_EDITORS = new Map() @@ -107,8 +107,47 @@ const OPEN_EDITORS = new Map() // ***************************************************************************** let serverInfo: ServerInfo = new ServerInfo() let checkpointPath: string = '' -let client: EditorClient let omegaEditPort: number = 0 +let configuredClientLogger: + | { + logFile: string + logLevel: string + } + | undefined + +function toMessageBytes(data: Uint8Array): number[] { + return Array.from(data) +} + +function fromMessageBytes(data: unknown): Uint8Array { + if (data instanceof Uint8Array) { + return data + } + if (Array.isArray(data)) { + return Uint8Array.from(data) + } + if ( + data && + typeof data === 'object' && + 'data' in data && + Array.isArray((data as { data?: unknown }).data) + ) { + return Uint8Array.from((data as { data: number[] }).data) + } + if (data && typeof data === 'object') { + const values = Object.entries(data as Record) + .filter( + (entry): entry is [string, number] => + /^\d+$/.test(entry[0]) && typeof entry[1] === 'number' + ) + .sort((a, b) => Number(a[0]) - Number(b[0])) + .map(([, value]) => value) + if (values.length > 0) { + return Uint8Array.from(values) + } + } + return new Uint8Array(0) +} // ***************************************************************************** // exported functions @@ -136,10 +175,19 @@ export class DataEditorClient implements vscode.Disposable { private displayState: DisplayState private currentViewportId: string private fileToEdit: string = '' + private fileInfoData: Record | undefined = undefined + private hasReceivedWebviewReady = false private omegaSessionId = '' private sendHeartbeatIntervalId: NodeJS.Timeout | number | undefined = undefined + private viewportSubscription: + | { + cancel(): void + } + | undefined = undefined private disposables: vscode.Disposable[] = [] + private readonly disposeCleanupComplete: Promise + private resolveDisposeCleanup: (() => void) | undefined = undefined constructor( protected context: vscode.ExtensionContext, @@ -150,6 +198,9 @@ export class DataEditorClient implements vscode.Disposable { panel: vscode.WebviewPanel ) { this.panel = panel + this.disposeCleanupComplete = new Promise((resolve) => { + this.resolveDisposeCleanup = resolve + }) this.panel.webview.onDidReceiveMessage(this.messageReceiver, this) this.disposables = [ @@ -179,6 +230,8 @@ export class DataEditorClient implements vscode.Disposable { clearInterval(this.sendHeartbeatIntervalId) this.sendHeartbeatIntervalId = undefined } + this.viewportSubscription?.cancel() + this.viewportSubscription = undefined for (let i = 0; i < this.disposables.length; i++) this.disposables[i].dispose() @@ -187,6 +240,11 @@ export class DataEditorClient implements vscode.Disposable { show(): void { this.panel.reveal() } + + async waitForDisposeCleanup(): Promise { + await this.disposeCleanupComplete + } + public static async open( context: vscode.ExtensionContext, view: string, @@ -212,27 +270,39 @@ export class DataEditorClient implements vscode.Disposable { panel ) - await editor.initialize() - - panel.onDidDispose(async () => { + panel.onDidDispose(() => { const pathKey = path.resolve(editor.fileToEdit).toLowerCase() OPEN_EDITORS.delete(pathKey) - await removeActiveSession(editor.sessionId()) - await editor.dispose() - }) - if (isDFDLDebugSessionActive()) { - editor.addDisposable( - vscode.debug.onDidTerminateDebugSession(async () => { - editor.dispose() + void (async () => { + try { + await editor.dispose() + await removeActiveSession(editor.sessionId()) + } finally { + editor.resolveDisposeCleanup?.() + } + })().catch((err) => { + getLogger().warn({ + fn: 'DataEditorClient::onDidDispose', + err: { + msg: `Failed to dispose data editor: ${String(err)}`, + stack: err instanceof Error ? err.stack : undefined, + }, }) - ) + }) + }) + + const initialized = await editor.initialize() + if (!initialized) { + return undefined } + return editor } - public async initialize() { + public async initialize(): Promise { checkpointPath = this.configVars.checkpointPath + let initialized = false if (this.fileToEdit !== '') { // Case: file passed in directly — check for duplicates now @@ -244,10 +314,14 @@ export class DataEditorClient implements vscode.Disposable { ) OPEN_EDITORS.get(realFilePath)?.reveal() this.panel.dispose() - return + return false } - await this.setupDataEditor() + initialized = await this.setupDataEditor() + if (!initialized) { + this.panel.dispose() + return false + } OPEN_EDITORS.set(realFilePath, this.panel) } else { // Case: no file passed in — prompt user @@ -269,33 +343,66 @@ export class DataEditorClient implements vscode.Disposable { ) OPEN_EDITORS.get(realFilePath)?.reveal() this.panel.dispose() - return + return false } - await this.setupDataEditor() + initialized = await this.setupDataEditor() + if (!initialized) { + this.panel.dispose() + return false + } OPEN_EDITORS.set(realFilePath, this.panel) } else { // User cancelled the dialog this.panel.dispose() - return + return false } } // send and initial heartbeat, then send the heartbeat to the webview at regular intervals - await this.sendHeartbeat() - this.sendHeartbeatIntervalId = setInterval(() => { - this.sendHeartbeat() - }, HEARTBEAT_INTERVAL_MS) + if (initialized) { + try { + await this.resyncWebview() + } catch (err) { + getLogger().warn({ + fn: 'DataEditorClient::initialize', + err: { + msg: `Initial webview sync failed: ${String(err)}`, + stack: err instanceof Error ? err.stack : undefined, + }, + }) + } + this.sendHeartbeatIntervalId = setInterval(() => { + void ( + this.hasReceivedWebviewReady + ? this.sendHeartbeat() + : this.resyncWebview() + ).catch((err) => { + getLogger().warn({ + fn: 'DataEditorClient::heartbeatInterval', + err: { + msg: `Webview sync failed: ${String(err)}`, + stack: err instanceof Error ? err.stack : undefined, + }, + }) + }) + }, HEARTBEAT_INTERVAL_MS) + } + return initialized } sessionId(): string { return this.omegaSessionId } - private async setupDataEditor() { + private async setupDataEditor(): Promise { assert( checkpointPath && checkpointPath.length > 0, 'checkpointPath is not set' ) + getLogger().info( + { fn: 'DataEditorClient::setupDataEditor', fileToEdit: this.fileToEdit }, + 'Starting data editor session setup' + ) let data = { byteOrderMark: '', @@ -323,6 +430,14 @@ export class DataEditorClient implements vscode.Disposable { createSessionResponse.hasFileSize() ? (createSessionResponse.getFileSize() as number) : 0 + getLogger().info( + { + fn: 'DataEditorClient::setupDataEditor', + sessionId: this.omegaSessionId, + fileSize: data.computedFileSize, + }, + 'Created data editor session' + ) const contentTypeResponse = await getContentType( this.omegaSessionId, @@ -360,7 +475,7 @@ export class DataEditorClient implements vscode.Disposable { const msg = isEmojiWindowsError ? `Unable to open ${this.fileToEdit}! Data editor doesn't support Emojis in filename on Windows.` - : `Failed to create session for ${this.fileToEdit}` + : `Failed to create session for ${this.fileToEdit}: ${String(err)}` getLogger().error({ err: { @@ -370,10 +485,9 @@ export class DataEditorClient implements vscode.Disposable { }) vscode.window.showErrorMessage(msg) - if (isEmojiWindowsError) { - // fine to return early here and not remove session b/c addActiveSession doesn't get called for this error. createSession() errors out. - return - } + // fine to return early here and not remove session b/c addActiveSession + // doesn't get called when createSession() errors out. + return false } // create the viewport @@ -387,10 +501,22 @@ export class DataEditorClient implements vscode.Disposable { ) this.currentViewportId = viewportDataResponse.getViewportId() assert(this.currentViewportId.length > 0, 'currentViewportId is not set') - await viewportSubscribe(this.panel, this.currentViewportId) + this.viewportSubscription = await viewportSubscribe( + this.panel, + this.currentViewportId + ) await sendViewportRefresh(this.panel, viewportDataResponse) - } catch { - const msg = `Failed to create viewport for ${this.fileToEdit}` + getLogger().info( + { + fn: 'DataEditorClient::setupDataEditor', + viewportId: this.currentViewportId, + }, + 'Created initial viewport' + ) + } catch (err) { + const msg = `Failed to create viewport for ${this.fileToEdit}: ${String( + err + )}` getLogger().error({ err: { msg: msg, @@ -398,47 +524,73 @@ export class DataEditorClient implements vscode.Disposable { }, }) vscode.window.showErrorMessage(msg) + return false } // send the initial file info to the webview + this.fileInfoData = data await this.panel.webview.postMessage({ command: MessageCommand.fileInfo, data: data, }) + getLogger().info( + { + fn: 'DataEditorClient::setupDataEditor', + sessionId: this.omegaSessionId, + viewportId: this.currentViewportId, + }, + 'Posted initial file info to webview' + ) + return true } private async sendHeartbeat() { const heartbeatInfo = getCurrentHeartbeatInfo() - await this.panel.webview.postMessage({ + const delivered = await this.panel.webview.postMessage({ command: MessageCommand.heartbeat, data: { latency: heartbeatInfo.latency, omegaEditPort: this.configVars.port, + serverCpuCount: heartbeatInfo.serverCpuCount, serverCpuLoadAverage: heartbeatInfo.serverCpuLoadAverage, + serverTimestamp: heartbeatInfo.serverTimestamp, serverUptime: heartbeatInfo.serverUptime, - serverUsedMemory: heartbeatInfo.serverUsedMemory, + serverResidentMemoryBytes: heartbeatInfo.serverResidentMemoryBytes, + serverVirtualMemoryBytes: heartbeatInfo.serverVirtualMemoryBytes, + serverPeakResidentMemoryBytes: + heartbeatInfo.serverPeakResidentMemoryBytes, sessionCount: heartbeatInfo.sessionCount, serverInfo: { omegaEditPort: this.configVars.port, serverVersion: serverInfo.serverVersion, serverHostname: serverInfo.serverHostname, serverProcessId: serverInfo.serverProcessId, - jvmVersion: serverInfo.jvmVersion, - jvmVendor: serverInfo.jvmVendor, - jvmPath: serverInfo.jvmPath, + runtimeKind: serverInfo.runtimeKind, + runtimeName: serverInfo.runtimeName, + platform: serverInfo.platform, availableProcessors: serverInfo.availableProcessors, + compiler: serverInfo.compiler, + buildType: serverInfo.buildType, + cppStandard: serverInfo.cppStandard, }, }, }) + getLogger().debug({ + fn: 'DataEditorClient::sendHeartbeat', + delivered, + hasReceivedWebviewReady: this.hasReceivedWebviewReady, + serverTimestamp: heartbeatInfo.serverTimestamp, + sessionCount: heartbeatInfo.sessionCount, + }) } private async sendChangesInfo() { // get the counts from the server const counts = await getCounts(this.omegaSessionId, [ - CountKind.COUNT_COMPUTED_FILE_SIZE, - CountKind.COUNT_CHANGE_TRANSACTIONS, - CountKind.COUNT_UNDO_TRANSACTIONS, + CountKind.COMPUTED_FILE_SIZE, + CountKind.CHANGE_TRANSACTIONS, + CountKind.UNDO_TRANSACTIONS, ]) // accumulate the counts into a single object @@ -450,18 +602,23 @@ export class DataEditorClient implements vscode.Disposable { } counts.forEach((count) => { switch (count.getKind()) { - case CountKind.COUNT_COMPUTED_FILE_SIZE: + case CountKind.COMPUTED_FILE_SIZE: data.computedFileSize = count.getCount() break - case CountKind.COUNT_CHANGE_TRANSACTIONS: + case CountKind.CHANGE_TRANSACTIONS: data.changeCount = count.getCount() break - case CountKind.COUNT_UNDO_TRANSACTIONS: + case CountKind.UNDO_TRANSACTIONS: data.undoCount = count.getCount() break } }) + this.fileInfoData = { + ...this.fileInfoData, + ...data, + } + // send the accumulated counts to the webview await this.panel.webview.postMessage({ command: MessageCommand.fileInfo, @@ -486,6 +643,19 @@ export class DataEditorClient implements vscode.Disposable { } break + case MessageCommand.webviewReady: + this.hasReceivedWebviewReady = true + getLogger().info( + { + fn: 'DataEditorClient::messageReceiver', + sessionId: this.omegaSessionId, + viewportId: this.currentViewportId, + }, + 'Received webviewReady from data editor' + ) + await this.resyncWebview() + break + case MessageCommand.scrollViewport: await this.scrollViewport( this.panel, @@ -522,8 +692,8 @@ export class DataEditorClient implements vscode.Disposable { await edit( this.omegaSessionId, message.data.offset, - message.data.originalSegment, - message.data.editedSegment + fromMessageBytes(message.data.originalSegment), + fromMessageBytes(message.data.editedSegment) ) await this.sendChangesInfo() break @@ -648,7 +818,7 @@ export class DataEditorClient implements vscode.Disposable { await this.panel.webview.postMessage({ command: MessageCommand.requestEditedData, data: { - data: Uint8Array.from(selectionData), + data: toMessageBytes(Uint8Array.from(selectionData)), dataDisplay: selectionDisplay, }, }) @@ -730,6 +900,38 @@ export class DataEditorClient implements vscode.Disposable { } } + private async resyncWebview() { + getLogger().info({ + fn: 'DataEditorClient::resyncWebview', + sessionId: this.omegaSessionId, + viewportId: this.currentViewportId, + hasReceivedWebviewReady: this.hasReceivedWebviewReady, + hasFileInfo: this.fileInfoData !== undefined, + }) + await this.displayState.sendUIThemeUpdate() + + if (this.fileInfoData) { + const delivered = await this.panel.webview.postMessage({ + command: MessageCommand.fileInfo, + data: this.fileInfoData, + }) + getLogger().debug({ + fn: 'DataEditorClient::resyncWebview', + message: 'fileInfo', + delivered, + }) + } + + if (this.currentViewportId) { + await sendViewportRefresh( + this.panel, + await getViewportData(this.currentViewportId) + ) + } + + await this.sendHeartbeat() + } + private async saveFileSegment( fileToSave: string, offset: number, @@ -750,15 +952,15 @@ export class DataEditorClient implements vscode.Disposable { await del(this.omegaSessionId, 0, offset) await this.sendChangesInfo() } else { - // delete from length to the end of the file and from 0 to offset in a single transaction - await beginSessionTransaction(this.omegaSessionId) - await del( - this.omegaSessionId, - offset + length, - computedFileSize - length - ) - await del(this.omegaSessionId, 0, offset) - await endSessionTransaction(this.omegaSessionId) + // Trim both sides atomically so undo/redo treats the segment save as one edit. + await runSessionTransaction(this.omegaSessionId, async () => { + await del( + this.omegaSessionId, + offset + length, + computedFileSize - offset - length + ) + await del(this.omegaSessionId, 0, offset) + }) await this.sendChangesInfo() } // save the segment to the file using the typical save method @@ -771,7 +973,7 @@ export class DataEditorClient implements vscode.Disposable { const saveResponse = await saveSession( this.omegaSessionId, fileToSave, - IOFlags.IO_FLG_OVERWRITE, + IOFlags.OVERWRITE, offset, length ) @@ -789,7 +991,7 @@ export class DataEditorClient implements vscode.Disposable { const saveResponse2 = await saveSession( this.omegaSessionId, fileToSave, - IOFlags.IO_FLG_FORCE_OVERWRITE, + IOFlags.FORCE_OVERWRITE, offset, length ) @@ -819,7 +1021,7 @@ export class DataEditorClient implements vscode.Disposable { const saveResponse = await saveSession( this.omegaSessionId, fileToSave, - IOFlags.IO_FLG_OVERWRITE + IOFlags.OVERWRITE ) if (saveResponse.getSaveStatus() === SaveStatus.MODIFIED) { // the file was modified since the session was created, query user to overwrite the modified file @@ -835,7 +1037,7 @@ export class DataEditorClient implements vscode.Disposable { const saveResponse2 = await saveSession( this.omegaSessionId, fileToSave, - IOFlags.IO_FLG_FORCE_OVERWRITE + IOFlags.FORCE_OVERWRITE ) saved = saveResponse2.getSaveStatus() === SaveStatus.SUCCESS } else { @@ -928,17 +1130,25 @@ async function createDataEditorWebviewPanel( // Make sure the omega edit port is configured configureOmegaEditPort(launchConfigVars) omegaEditPort = launchConfigVars.port + checkpointPath = launchConfigVars.checkpointPath + await setupLogging(launchConfigVars) // Start the server if it's not already running - if (!(await checkServerListening(omegaEditPort, OMEGA_EDIT_HOST))) { - await setupLogging(launchConfigVars) + const serverListening = await checkServerListening( + omegaEditPort, + OMEGA_EDIT_HOST + ) + if (!serverListening) { + resetOmegaEditConnectionState() + clearStoppedServerArtifacts() await serverStart() - client = await getClient(omegaEditPort, OMEGA_EDIT_HOST) - assert( - await checkServerListening(omegaEditPort, OMEGA_EDIT_HOST), - 'server not listening' - ) } + await getClient(omegaEditPort, OMEGA_EDIT_HOST) + assert( + await checkServerListening(omegaEditPort, OMEGA_EDIT_HOST), + 'server not listening' + ) + serverInfo = await getServerInfo() // Normalize workspace keyword if needed fileToEdit = fileToEdit.replace( @@ -961,6 +1171,35 @@ function rotateLogFiles(logFile: string): void { ctime: Date } + function isRotatedLogFileName(fileName: string): boolean { + const parsed = path.parse(logFile) + const currentFileName = parsed.base + const legacyPrefix = `${currentFileName}.` + + if (fileName === currentFileName) { + return false + } + + if (fileName.startsWith(legacyPrefix)) { + return true + } + + if (parsed.ext.length === 0) { + return false + } + + return ( + fileName.startsWith(`${parsed.name}.`) && fileName.endsWith(parsed.ext) + ) + } + + function getRotatedLogFileName(timestamp: string): string { + const parsed = path.parse(logFile) + return parsed.ext.length > 0 + ? `${parsed.name}.${timestamp}${parsed.ext}` + : `${parsed.base}.${timestamp}` + } + assert( MAX_LOG_FILES > 0, 'Maximum number of log files must be greater than 0' @@ -968,12 +1207,11 @@ function rotateLogFiles(logFile: string): void { if (fs.existsSync(logFile)) { const logDir = path.dirname(logFile) - const logFileName = path.basename(logFile) // Get list of existing log files const logFiles: LogFile[] = fs .readdirSync(logDir) - .filter((file) => file.startsWith(logFileName) && file !== logFileName) + .filter((file) => isRotatedLogFileName(file)) .map((file) => ({ path: path.join(logDir, file), ctime: fs.statSync(path.join(logDir, file)).ctime, @@ -988,7 +1226,7 @@ function rotateLogFiles(logFile: string): void { // Rename current log file with timestamp and create a new empty file const timestamp = new Date().toISOString().replace(/:/g, '-') - fs.renameSync(logFile, path.join(logDir, `${logFileName}.${timestamp}`)) + fs.renameSync(logFile, path.join(logDir, getRotatedLogFileName(timestamp))) } } @@ -1002,8 +1240,15 @@ async function setupLogging(configVars: editor_config.Config): Promise { process.env.OMEGA_EDIT_CLIENT_LOG_LEVEL || process.env.OMEGA_EDIT_LOG_LEVEL || configVars.logLevel + if ( + configuredClientLogger?.logFile === logFile && + configuredClientLogger.logLevel === logLevel + ) { + return + } rotateLogFiles(logFile) setLogger(createSimpleFileLogger(logFile, logLevel)) + configuredClientLogger = { logFile, logLevel } vscode.window.showInformationMessage(`Logging (${logLevel}) to '${logFile}'`) } @@ -1011,17 +1256,25 @@ async function sendViewportRefresh( panel: vscode.WebviewPanel, viewportDataResponse: ViewportDataResponse ): Promise { - await panel.webview.postMessage({ + const delivered = await panel.webview.postMessage({ command: MessageCommand.viewportRefresh, data: { viewportId: viewportDataResponse.getViewportId(), fileOffset: viewportDataResponse.getOffset(), length: viewportDataResponse.getLength(), bytesLeft: viewportDataResponse.getFollowingByteCount(), - data: viewportDataResponse.getData_asU8(), + data: toMessageBytes(viewportDataResponse.getData_asU8()), capacity: VIEWPORT_CAPACITY_MAX, }, }) + getLogger().debug({ + fn: 'sendViewportRefresh', + delivered, + viewportId: viewportDataResponse.getViewportId(), + offset: viewportDataResponse.getOffset(), + length: viewportDataResponse.getLength(), + bytesLeft: viewportDataResponse.getFollowingByteCount(), + }) } /** @@ -1033,28 +1286,17 @@ async function viewportSubscribe( panel: vscode.WebviewPanel, viewportId: string ) { - // subscribe to all viewport events - client - .subscribeToViewportEvents( - new EventSubscriptionRequest() - .setId(viewportId) - .setInterest(ALL_EVENTS & ~ViewportEventKind.VIEWPORT_EVT_MODIFY) - ) - .on('data', async (event: ViewportEvent) => { + return await subscribeViewportEvents({ + viewportId, + interest: ALL_EVENTS & ~ViewportEventKind.MODIFY, + onEvent: async (event) => { getLogger().debug({ viewportId: event.getViewportId(), event: event.getViewportEventKind(), }) await sendViewportRefresh(panel, await getViewportData(viewportId)) - }) - .on('error', (err) => { - // Call cancelled thrown sometimes when server is shutdown - if ( - !err.message.includes('Call cancelled') && - !err.message.includes('UNAVAILABLE') - ) - throw err - }) + }, + }) } class DisplayState { @@ -1074,7 +1316,7 @@ class DisplayState { this.sendUIThemeUpdate() } - private sendUIThemeUpdate() { + public sendUIThemeUpdate() { return this.panel.webview.postMessage({ command: MessageCommand.setUITheme, theme: this.colorThemeKind, @@ -1210,71 +1452,87 @@ function removeDirectory(dirPath: string): void { } } -export async function serverStop() { +function resetOmegaEditConnectionState(): void { + resetClient() +} + +function clearStoppedServerArtifacts(): void { const serverPidFile = getPidFile(omegaEditPort) if (fs.existsSync(serverPidFile)) { - const pid = parseInt(fs.readFileSync(serverPidFile).toString()) - if (await stopProcessUsingPID(pid)) { - vscode.window.setStatusBarMessage( - `Ωedit server stopped on port ${omegaEditPort} with PID ${pid}`, - new Promise((resolve) => { - setTimeout(() => { - resolve(true) - }, 4000) - }) - ) - removeDirectory(checkpointPath) - } else { - // Check again if the process has stopped after a short delay - await new Promise((resolve) => setTimeout(resolve, 500)) - if (!(await stopProcessUsingPID(pid))) { - vscode.window.showErrorMessage( - `Ωedit server on port ${omegaEditPort} with PID ${pid} failed to stop` - ) - } else { - vscode.window.setStatusBarMessage( - `Ωedit server stopped on port ${omegaEditPort} with PID ${pid}`, - new Promise((resolve) => { - setTimeout(() => { - resolve(true) - }, 4000) - }) - ) - removeDirectory(checkpointPath) - } + fs.unlinkSync(serverPidFile) + } + if (checkpointPath.length > 0) { + removeDirectory(checkpointPath) + } +} + +export async function serverStop() { + resetOmegaEditConnectionState() + const serverPidFile = getPidFile(omegaEditPort) + if (!fs.existsSync(serverPidFile)) { + if (!(await checkServerListening(omegaEditPort, OMEGA_EDIT_HOST))) { + clearStoppedServerArtifacts() } + return + } + + const pid = parseInt(fs.readFileSync(serverPidFile).toString()) + if (Number.isNaN(pid)) { + clearStoppedServerArtifacts() + return + } + + let stopped = await stopProcessUsingPID(pid) + if (!stopped) { + await new Promise((resolve) => setTimeout(resolve, 500)) + stopped = await stopProcessUsingPID(pid) } + + const serverListening = await checkServerListening( + omegaEditPort, + OMEGA_EDIT_HOST + ) + + if (stopped || !serverListening) { + vscode.window.setStatusBarMessage( + `Ωedit server stopped on port ${omegaEditPort} with PID ${pid}`, + new Promise((resolve) => { + setTimeout(() => { + resolve(true) + }, 4000) + }) + ) + clearStoppedServerArtifacts() + return + } + + vscode.window.showErrorMessage( + `Ωedit server on port ${omegaEditPort} with PID ${pid} failed to stop` + ) } function generateLogbackConfigFile( logFile: string, logLevel: string = 'INFO' ): string { - const dirname = path.dirname(logFile) - if (!fs.existsSync(dirname)) { - fs.mkdirSync(dirname, { recursive: true }) - } - logLevel = logLevel.toUpperCase() - const logbackConfig = `\n - - - ${logFile} - - [%date{ISO8601}] [%level] [%logger] [%marker] [%thread] - %msg MDC: {%mdc}%n - - - - - - -` const logbackConfigFile = path.join( APP_DATA_PATH, `serv-${omegaEditPort}.logconf.xml` ) rotateLogFiles(logFile) - fs.writeFileSync(logbackConfigFile, logbackConfig) - return logbackConfigFile // Return the path to the logback config file + return writeLogbackConfigFile(logbackConfigFile, logFile, logLevel) +} + +function getProcessCommandLine(pid: number): string { + return child_process + .execSync( + osCheck( + `powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\\"ProcessId = ${pid}\\\").CommandLine"`, + `ps -p ${pid} -o command=` + ) + ) + .toString('utf8') + .trim() } async function serverStart() { @@ -1285,18 +1543,7 @@ async function serverStart() { if (!isNaN(pid)) { // Ensure PID isn't assigned to a different process before stopping process try { - if ( - child_process - .execSync( - osCheck( - `wmic process where processid=${pid} get CommandLine`, - `ps -p ${pid} -o command=` - ) - ) - .toString('ascii') - .toLowerCase() - .includes('omega-edit') - ) { + if (getProcessCommandLine(pid).toLowerCase().includes('omega-edit')) { await serverStop() } else { fs.unlinkSync(serverPidFile) @@ -1341,12 +1588,12 @@ async function serverStart() { // Start the server and wait up to 10 seconds for it to start const serverPid = (await Promise.race([ - startServer( - omegaEditPort, - OMEGA_EDIT_HOST, - getPidFile(omegaEditPort), - logConfigFile - ), + startServer(omegaEditPort, OMEGA_EDIT_HOST, getPidFile(omegaEditPort), { + sessionTimeoutMs: SERVER_SESSION_TIMEOUT_MS, + cleanupIntervalMs: SERVER_CLEANUP_INTERVAL_MS, + shutdownWhenNoSessions: true, + logConfigFile, + }), new Promise((_resolve, reject) => { setTimeout(() => { reject((): Error => { diff --git a/src/dataEditor/include/server/LogbackConfig.ts b/src/dataEditor/include/server/LogbackConfig.ts new file mode 100644 index 000000000..282ab4b96 --- /dev/null +++ b/src/dataEditor/include/server/LogbackConfig.ts @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs' +import path from 'path' + +function ensureParentDirectory(filePath: string): void { + const dirname = path.dirname(filePath) + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, { recursive: true }) + } +} + +function buildLogbackConfig(logFile: string, logLevel: string): string { + return `\n + + + ${logFile} + + [%date{ISO8601}] [%level] [%logger] [%marker] [%thread] - %msg MDC: {%mdc}%n + + + + + + +` +} + +export function writeLogbackConfigFile( + logbackConfigFile: string, + logFile: string, + logLevel: string = 'INFO' +): string { + ensureParentDirectory(logFile) + ensureParentDirectory(logbackConfigFile) + fs.writeFileSync(logbackConfigFile, buildLogbackConfig(logFile, logLevel)) + return logbackConfigFile +} diff --git a/src/dataEditor/include/server/ServerInfo.ts b/src/dataEditor/include/server/ServerInfo.ts index db871da8b..4fcc89be8 100644 --- a/src/dataEditor/include/server/ServerInfo.ts +++ b/src/dataEditor/include/server/ServerInfo.ts @@ -24,33 +24,36 @@ export class ServerInfo implements IServerInfo { serverHostname: string = 'unknown' serverProcessId: number = 0 serverVersion: string = 'unknown' - jvmVersion: string = 'unknown' - jvmVendor: string = 'unknown' - jvmPath: string = 'unknown' + runtimeKind: string = 'unknown' + runtimeName: string = 'unknown' + platform: string = 'unknown' availableProcessors: number = 0 + compiler: string = 'unknown' + buildType: string = 'unknown' + cppStandard: string = 'unknown' } const OMEGA_EDIT_MAX_PORT: number = 65535 const OMEGA_EDIT_MIN_PORT: number = 1024 + export function configureOmegaEditPort(configVars: editor_config.Config): void { - let omegaEditPort = configVars.port - if (omegaEditPort === 0) { - if ( - omegaEditPort <= OMEGA_EDIT_MIN_PORT || - omegaEditPort > OMEGA_EDIT_MAX_PORT - ) { - const message = `Invalid port ${omegaEditPort} for Ωedit. Use a port between ${OMEGA_EDIT_MIN_PORT} and ${OMEGA_EDIT_MAX_PORT}` - omegaEditPort = 0 - throw new Error(message) - } - if (!fs.existsSync(configVars.checkpointPath)) { - fs.mkdirSync(configVars.checkpointPath, { recursive: true }) - } - assert( - fs.existsSync(configVars.checkpointPath), - 'checkpoint path does not exist' + const omegaEditPort = configVars.port + if ( + omegaEditPort <= OMEGA_EDIT_MIN_PORT || + omegaEditPort > OMEGA_EDIT_MAX_PORT + ) { + throw new Error( + `Invalid port ${omegaEditPort} for Ωedit. Use a port between ${OMEGA_EDIT_MIN_PORT} and ${OMEGA_EDIT_MAX_PORT}` ) - assert(omegaEditPort !== 0, 'omegaEditPort is not set') } + + if (!fs.existsSync(configVars.checkpointPath)) { + fs.mkdirSync(configVars.checkpointPath, { recursive: true }) + } + assert( + fs.existsSync(configVars.checkpointPath), + 'checkpoint path does not exist' + ) } + export type ServerStopPredicate = (context?: any) => boolean diff --git a/src/dataEditor/include/server/Sessions.ts b/src/dataEditor/include/server/Sessions.ts index d525cc6e5..911635733 100644 --- a/src/dataEditor/include/server/Sessions.ts +++ b/src/dataEditor/include/server/Sessions.ts @@ -14,12 +14,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { destroySession, getLogger, getSessionCount } from '@omega-edit/client' +import { destroySession, getLogger, resetClient } from '@omega-edit/client' import { updateHeartbeatInterval } from './heartbeat' -import { serverStop } from '../../dataEditorClient' let activeSessions: string[] = [] +function isServerUnavailableError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + + const message = err.message.toLowerCase() + return ( + message.includes('unavailable') || + message.includes('econnrefused') || + message.includes('connection refused') || + message.includes('channel closed') || + message.includes('socket closed') + ) +} + export function addActiveSession(sessionId: string): void { if (!activeSessions.includes(sessionId)) { activeSessions.push(sessionId) @@ -28,17 +42,32 @@ export function addActiveSession(sessionId: string): void { } } export async function removeActiveSession(sessionId: string) { + if (!sessionId) { + return + } + const index = activeSessions.indexOf(sessionId) + if (index === -1) { + return + } + activeSessions.splice(index, 1) updateHeartbeatInterval(activeSessions) - await destroySession(sessionId) - // Only stop the server if there are no active sessions - if ((await getSessionCount()) === 0) { - getLogger().info( - { fn: 'DataEditorClient::removeActiveSession' }, - 'Stopping server!' - ) - await serverStop() + try { + await destroySession(sessionId) + if (activeSessions.length === 0) { + resetClient() + } + } catch (err) { + if (isServerUnavailableError(err)) { + resetClient() + getLogger().info( + { fn: 'DataEditorClient::removeActiveSession', sessionId }, + 'Omega Edit server was already stopped during session cleanup' + ) + return + } + throw err } } diff --git a/src/dataEditor/include/server/heartbeat/HeartBeatInfo.ts b/src/dataEditor/include/server/heartbeat/HeartBeatInfo.ts index 5999dc3c6..e3a80925e 100644 --- a/src/dataEditor/include/server/heartbeat/HeartBeatInfo.ts +++ b/src/dataEditor/include/server/heartbeat/HeartBeatInfo.ts @@ -17,14 +17,13 @@ import { IServerHeartbeat } from '@omega-edit/client' export class HeartbeatInfo implements IServerHeartbeat { - omegaEditPort: number = 0 // Ωedit server port latency: number = 0 // latency in ms - serverCommittedMemory: number = 0 // committed memory in bytes serverCpuCount: number = 0 // cpu count - serverCpuLoadAverage: number = 0 // cpu load average - serverMaxMemory: number = 0 // max memory in bytes + serverCpuLoadAverage?: number = 0 // cpu load average + serverPeakResidentMemoryBytes?: number = 0 // peak resident memory in bytes + serverResidentMemoryBytes?: number = 0 // resident memory in bytes serverTimestamp: number = 0 // timestamp in ms serverUptime: number = 0 // uptime in ms - serverUsedMemory: number = 0 // used memory in bytes + serverVirtualMemoryBytes?: number = 0 // virtual memory in bytes sessionCount: number = 0 // session count } diff --git a/src/dataEditor/include/server/heartbeat/index.ts b/src/dataEditor/include/server/heartbeat/index.ts index 71c33b361..83786156b 100644 --- a/src/dataEditor/include/server/heartbeat/index.ts +++ b/src/dataEditor/include/server/heartbeat/index.ts @@ -14,26 +14,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getServerHeartbeat, IServerHeartbeat } from '@omega-edit/client' +import { + IServerHeartbeat, + type ServerHeartbeatLoop, + startServerHeartbeatLoop, +} from '@omega-edit/client' import { HeartbeatInfo } from './HeartBeatInfo' const HEARTBEAT_INTERVAL_MS: number = 1000 // 1 second (1000 ms) let heartbeatInfo: IServerHeartbeat = new HeartbeatInfo() -let getHeartbeatIntervalId: NodeJS.Timeout | number | undefined = undefined +let heartbeatLoop: ServerHeartbeatLoop | undefined = undefined export function updateHeartbeatInterval(activeSessions: string[]) { - if (getHeartbeatIntervalId) { - clearInterval(getHeartbeatIntervalId) + heartbeatLoop?.stop() + heartbeatLoop = undefined + + if (activeSessions.length === 0) { + heartbeatInfo = new HeartbeatInfo() + return } - getHeartbeatIntervalId = - activeSessions.length > 0 - ? setInterval(async () => { - heartbeatInfo = await getServerHeartbeat( - activeSessions, - HEARTBEAT_INTERVAL_MS * activeSessions.length - ) - }) - : undefined + + heartbeatLoop = startServerHeartbeatLoop({ + intervalMs: HEARTBEAT_INTERVAL_MS * activeSessions.length, + getSessionIds: () => [...activeSessions], + onHeartbeat: (nextHeartbeatInfo) => { + heartbeatInfo = nextHeartbeatInfo + }, + onError: () => { + heartbeatInfo = new HeartbeatInfo() + }, + }) } export function getCurrentHeartbeatInfo() { diff --git a/src/dataEditor/svelteWebviewInitializer.ts b/src/dataEditor/svelteWebviewInitializer.ts index b3839e524..1e04a6bab 100644 --- a/src/dataEditor/svelteWebviewInitializer.ts +++ b/src/dataEditor/svelteWebviewInitializer.ts @@ -40,17 +40,12 @@ export class SvelteWebviewInitializer { return webView.asWebviewUri(uri) }) const indexPath = this.getResourceUri('index', context) - let indexHTML = this.injectNonce( - this.getIndexHTML(context), - webView, - nonce, - scriptUri - )! - indexHTML = fs + let indexHTML = fs .readFileSync(indexPath!.fsPath, 'utf-8') .replace(/src="\.\/index.js"/, `src="${scriptUri.toString()}"`) .replace(/href="\.\/style.css"/, `href="${stylesUri.toString()}"`) - .replaceAll(/nonce="__nonce__"/g, `nonce="${nonce}""`) + .replaceAll(/nonce="__nonce__"/g, `nonce="${nonce}"`) + indexHTML = this.injectNonce(indexHTML, webView, nonce, scriptUri)! return indexHTML } private injectNonce( @@ -65,17 +60,6 @@ export class SvelteWebviewInitializer { ) return ret } - private getIndexHTML(context: vscode.ExtensionContext) { - const indexFile = vscode.Uri.joinPath( - context.extensionUri, - 'dist', - 'views', - 'dataEditor', - 'index.html' - ) - const indexContent = fs.readFileSync(indexFile.fsPath).toString() - return indexContent - } // get a nonce for use in a content security policy private getNonce(): string { let text = '' diff --git a/src/svelte/src/App.svelte b/src/svelte/src/App.svelte index dd7989998..3b430c848 100644 --- a/src/svelte/src/App.svelte +++ b/src/svelte/src/App.svelte @@ -17,6 +17,7 @@ limitations under the License.