From bfb3cbaf9b070e1cf4f7a2987387b2c38edab518 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 7 Sep 2026 14:16:17 +0900 Subject: [PATCH] (fix) module list status bug.. --- package.json | 2 +- src/main/index.ts | 145 ++++++++++++++++-- .../src/features/workspace/ModuleListView.tsx | 6 +- .../workspace/useWorkspaceModuleList.ts | 34 +++- 4 files changed, 165 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index d596333..6a0fa0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.20", + "version": "1.1.21", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/src/main/index.ts b/src/main/index.ts index 72d79b0..30dc482 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ const terminalStopTimers = new Map() const intentionalTerminalStops = new Set() const nav2RuntimeObstacleNames = new Set() const remoteSshQueues = new Map>() +const remoteDockerStatsRetryAfter = new Map() const remoteGitModuleRoots = new Map() const remoteSshControlDirectory = resolve(homedir(), '.autonomy-studio', 'ssh', 'control') const moduleCacheRoot = resolve(homedir(), '.caches') @@ -1377,12 +1378,15 @@ async function collectRemoteAvailableModules( } }) .sort((a, b) => a.sortOrder - b.sortOrder) - const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 30_000) + const [output, statsOutput] = await Promise.all([ + runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 30_000), + readRemoteDockerStatsOutput(target), + ]) const dockerStatuses = new Map() const dockerStats = new Map() const installedModules = new Map() - for (const line of output.split('\n')) { + for (const line of `${output}\n${statsOutput}`.split('\n')) { if (!line.trim()) continue const [kind, first = '', second = '', third = ''] = line.split('\t') if (kind === 'DOCKER' && first) { @@ -1501,8 +1505,10 @@ function remoteRepositoryUrl(repositoryUrl: string): string { async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise { return new Promise((resolvePromise, rejectPromise) => { let output = '' + let terminated = false const child = spawn(command, args, { cwd, + detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, @@ -1511,7 +1517,18 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e } }) const timeout = setTimeout(() => { - if (!child.killed) child.kill('SIGTERM') + terminated = true + if (process.platform !== 'win32' && child.pid) { + const processId = child.pid + // SSH/SCP spawn a child process. Kill the whole group so a timed-out + // transfer cannot leave an SSH child alive in the background. + try { process.kill(-processId, 'SIGTERM') } catch { /* already exited */ } + setTimeout(() => { + try { process.kill(-processId, 'SIGKILL') } catch { /* already exited */ } + }, 1_000).unref() + } else if (!child.killed) { + child.kill('SIGTERM') + } rejectPromise(new Error(`${command} ${args.join(' ')} timed out`)) }, timeoutMs) @@ -1527,6 +1544,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e }) child.on('close', (code) => { clearTimeout(timeout) + if (terminated) return if (code === 0) resolvePromise(output) else rejectPromise(new Error(output.trim() || `${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}`)) }) @@ -1890,10 +1908,106 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time )) } +async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise { + const normalized = normalizeRemoteTarget(target) + return runCommandOutput( + 'ssh', + [ + '-p', + String(normalized.port), + '-o', + 'BatchMode=yes', + '-o', + 'StrictHostKeyChecking=accept-new', + '-o', + 'ConnectTimeout=5', + '-o', + 'PreferredAuthentications=publickey', + '-o', + 'PasswordAuthentication=no', + '-o', + 'KbdInteractiveAuthentication=no', + '-o', + 'ControlMaster=no', + '-i', + normalized.identityFile, + `${normalized.user}@${normalized.host}`, + command, + ], + undefined, + undefined, + timeoutMs, + ) +} + +async function readRemoteDockerStatsOutput(target: RemoteWorkspaceTarget): Promise { + const normalized = normalizeRemoteTarget(target) + const key = `${normalized.user}@${normalized.host}:${normalized.port}` + if ((remoteDockerStatsRetryAfter.get(key) ?? 0) > Date.now()) return '' + + try { + const output = await runUnqueuedSshOutput( + normalized, + 'timeout -k 1s 2s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true', + 4_000, + ) + remoteDockerStatsRetryAfter.delete(key) + return output + } catch { + // Do not spawn another blocked Docker client on every ten-second module + // refresh when the Jetson Docker daemon is under load. + remoteDockerStatsRetryAfter.set(key, Date.now() + 60_000) + return '' + } +} + async function runSsh(target: RemoteWorkspaceTarget, command: string, timeoutMs?: number): Promise { await runSshOutput(target, command, timeoutMs) } +async function copyRemoteFileToLocal( + target: RemoteWorkspaceTarget, + remotePath: string, + localPath: string, + timeoutMs = 900_000, +): Promise { + const normalized = normalizeRemoteTarget(target) + const host = normalized.host.includes(':') ? `[${normalized.host}]` : normalized.host + // A large 3D map database can take several minutes over a relayed Tailscale + // link. Keep that file transfer outside the serialized command queue so it + // cannot prevent module state, stop, or control requests from reaching the + // platform in the meantime. + await runCommand( + 'scp', + [ + '-q', + '-P', + String(normalized.port), + '-o', + 'BatchMode=yes', + '-o', + 'StrictHostKeyChecking=accept-new', + '-o', + 'ConnectTimeout=5', + '-o', + 'PreferredAuthentications=publickey', + '-o', + 'PasswordAuthentication=no', + '-o', + 'KbdInteractiveAuthentication=no', + '-o', + 'ControlMaster=no', + '-i', + normalized.identityFile, + `${normalized.user}@${host}:${remotePath}`, + localPath, + ], + undefined, + undefined, + timeoutMs, + ) +} + async function readRemoteSavedMapArtifacts( targetInput: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[], @@ -1929,25 +2043,31 @@ async function copyRemoteActiveRtabmapDatabaseToLocal( 'destination_db.close()', 'source_db.close()', ].join('; ') - const remoteCommand = [ + const createSnapshotCommand = [ 'set -e', `SOURCE=${quoteShellSingle(sourcePath)}`, 'test -f "$SOURCE" || { echo "3D SLAM DB를 찾을 수 없습니다: $SOURCE" >&2; exit 1; }', - 'SNAPSHOT=$(mktemp)', + 'SNAPSHOT=$(mktemp --suffix=.db)', `python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`, - 'base64 -w0 "$SNAPSHOT"', - 'rm -f "$SNAPSHOT"', + 'printf "%s" "$SNAPSHOT"', ].join('\n') - const base64 = (await runSshOutput(target, remoteCommand, 120_000)).trim() - if (!base64) throw new Error('3D SLAM DB 스냅샷을 읽지 못했습니다.') - await syncWorkspaceSavedMapArtifacts(localWorkspaceRoot, []) const root = expandUserPath(localWorkspaceRoot) if (!root) throw new Error('로컬 워크스페이스 경로가 설정되어 있지 않습니다.') const mapsDirectory = resolve(root, 'maps') const destination = resolve(mapsDirectory, destinationFileName) if (!destination.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 3D 맵 경로가 올바르지 않습니다.') - await writeFile(destination, Buffer.from(base64, 'base64')) + const snapshotPath = (await runSshOutput(target, createSnapshotCommand, 120_000)).trim() + if (!snapshotPath.startsWith('/tmp/')) throw new Error('3D SLAM DB 스냅샷을 만들지 못했습니다.') + const temporaryDestination = `${destination}.download-${process.pid}-${Date.now()}` + + try { + await copyRemoteFileToLocal(target, snapshotPath, temporaryDestination) + await rename(temporaryDestination, destination) + } finally { + await rm(temporaryDestination, { force: true }).catch(() => undefined) + await runSsh(target, `rm -f ${quoteShellSingle(snapshotPath)}`, 15_000).catch(() => undefined) + } return { databasePath: `/workspace/maps/${destinationFileName}` } } @@ -2149,9 +2269,6 @@ function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModu `ROOT=${quoteShellSingle(workspaceRoot)}`, ...expandRemotePathVar('ROOT'), 'timeout 5s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true', - // docker stats can stall on a remote Jetson. Module state must not wait - // behind a long statistics sample. - 'timeout 2s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true', ...moduleFolders.map((folderName) => { const modulePath = remotePathJoin('$ROOT', folderName) return [ diff --git a/src/renderer/src/features/workspace/ModuleListView.tsx b/src/renderer/src/features/workspace/ModuleListView.tsx index 3b60688..7151ddd 100644 --- a/src/renderer/src/features/workspace/ModuleListView.tsx +++ b/src/renderer/src/features/workspace/ModuleListView.tsx @@ -462,7 +462,7 @@ const moduleGuidePages = [ imageAlt: '로컬 환경 모듈 가이드', imageSrc: './assets/images/module-guide-local.png', slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'], - navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'], + navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'], parameters: [ ['기본', 'sim_mode:=true, platform:=turtlebot3, robot:=turtlebot3, level:=2d'], ['Perception', 'sim_mode:=true'], @@ -474,7 +474,7 @@ const moduleGuidePages = [ imageAlt: '원격 플랫폼 환경 모듈 가이드', imageSrc: './assets/images/module-guide-remote-platform.png', slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'], - navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'], + navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'], parameters: [ ['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=2d'], ['Perception', 'sim_mode:=false'], @@ -486,7 +486,7 @@ const moduleGuidePages = [ imageAlt: '원격 3D SLAM 환경 모듈 가이드', imageSrc: './assets/images/module-guide-remote-3d.png', slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'], - navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'], + navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'], parameters: [ ['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=3d'] ] diff --git a/src/renderer/src/features/workspace/useWorkspaceModuleList.ts b/src/renderer/src/features/workspace/useWorkspaceModuleList.ts index 9565f12..12622bc 100644 --- a/src/renderer/src/features/workspace/useWorkspaceModuleList.ts +++ b/src/renderer/src/features/workspace/useWorkspaceModuleList.ts @@ -59,6 +59,26 @@ function parseRemoteWorkspaceTarget(targetKey: string): RemoteWorkspaceTarget | } } +function retainRunningModuleStats(modules: ModuleRow[], previousModules: ModuleRow[]): ModuleRow[] { + const previousByKey = new Map( + previousModules + .filter((module) => module.state === 'running') + .map((module) => [module.folderName ?? module.name, module]), + ) + + return modules.map((module) => { + if (module.state !== 'running') return module + const previous = previousByKey.get(module.folderName ?? module.name) + if (!previous) return module + + return { + ...module, + cpu: module.cpu ?? previous.cpu, + memory: module.memory ?? previous.memory, + } + }) +} + export function useWorkspaceModuleList({ clonePathPreview, curriculumModules, @@ -164,8 +184,11 @@ export function useWorkspaceModuleList({ if (requestSequence !== loadSequenceRef.current) return setModuleListError(null) const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)) - setAvailableModules(modules) - storeRemoteModuleCache(moduleCacheKey, modules) + setAvailableModules((current) => { + const nextModules = retainRunningModuleStats(modules, current) + storeRemoteModuleCache(moduleCacheKey, nextModules) + return nextModules + }) return } @@ -173,8 +196,11 @@ export function useWorkspaceModuleList({ if (requestSequence !== loadSequenceRef.current) return setModuleListError(null) const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)) - setAvailableModules(modules) - storeRemoteModuleCache(moduleCacheKey, modules) + setAvailableModules((current) => { + const nextModules = retainRunningModuleStats(modules, current) + storeRemoteModuleCache(moduleCacheKey, nextModules) + return nextModules + }) } catch (error) { if (requestSequence !== loadSequenceRef.current) return console.error('[modules] list failed', error)