(fix) module list status bug..
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
+131
-14
@@ -22,6 +22,7 @@ const terminalStopTimers = new Map<string, NodeJS.Timeout>()
|
|||||||
const intentionalTerminalStops = new Set<string>()
|
const intentionalTerminalStops = new Set<string>()
|
||||||
const nav2RuntimeObstacleNames = new Set<string>()
|
const nav2RuntimeObstacleNames = new Set<string>()
|
||||||
const remoteSshQueues = new Map<string, Promise<void>>()
|
const remoteSshQueues = new Map<string, Promise<void>>()
|
||||||
|
const remoteDockerStatsRetryAfter = new Map<string, number>()
|
||||||
const remoteGitModuleRoots = new Map<string, string>()
|
const remoteGitModuleRoots = new Map<string, string>()
|
||||||
const remoteSshControlDirectory = resolve(homedir(), '.autonomy-studio', 'ssh', 'control')
|
const remoteSshControlDirectory = resolve(homedir(), '.autonomy-studio', 'ssh', 'control')
|
||||||
const moduleCacheRoot = resolve(homedir(), '.caches')
|
const moduleCacheRoot = resolve(homedir(), '.caches')
|
||||||
@@ -1377,12 +1378,15 @@ async function collectRemoteAvailableModules(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
.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<string, { running: boolean; exists: boolean; status: string }>()
|
const dockerStatuses = new Map<string, { running: boolean; exists: boolean; status: string }>()
|
||||||
const dockerStats = new Map<string, { cpu: number; memory: number }>()
|
const dockerStats = new Map<string, { cpu: number; memory: number }>()
|
||||||
const installedModules = new Map<string, { path: string; parameters: string[] }>()
|
const installedModules = new Map<string, { path: string; parameters: string[] }>()
|
||||||
|
|
||||||
for (const line of output.split('\n')) {
|
for (const line of `${output}\n${statsOutput}`.split('\n')) {
|
||||||
if (!line.trim()) continue
|
if (!line.trim()) continue
|
||||||
const [kind, first = '', second = '', third = ''] = line.split('\t')
|
const [kind, first = '', second = '', third = ''] = line.split('\t')
|
||||||
if (kind === 'DOCKER' && first) {
|
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<string> {
|
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise<string> {
|
||||||
return new Promise<string>((resolvePromise, rejectPromise) => {
|
return new Promise<string>((resolvePromise, rejectPromise) => {
|
||||||
let output = ''
|
let output = ''
|
||||||
|
let terminated = false
|
||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -1511,7 +1517,18 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const timeout = setTimeout(() => {
|
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`))
|
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
|
|
||||||
@@ -1527,6 +1544,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
|||||||
})
|
})
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
|
if (terminated) return
|
||||||
if (code === 0) resolvePromise(output)
|
if (code === 0) resolvePromise(output)
|
||||||
else rejectPromise(new Error(output.trim() || `${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}`))
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
async function runSsh(target: RemoteWorkspaceTarget, command: string, timeoutMs?: number): Promise<void> {
|
||||||
await runSshOutput(target, command, timeoutMs)
|
await runSshOutput(target, command, timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyRemoteFileToLocal(
|
||||||
|
target: RemoteWorkspaceTarget,
|
||||||
|
remotePath: string,
|
||||||
|
localPath: string,
|
||||||
|
timeoutMs = 900_000,
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
async function readRemoteSavedMapArtifacts(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
artifacts: SavedMapArtifactPath[],
|
artifacts: SavedMapArtifactPath[],
|
||||||
@@ -1929,25 +2043,31 @@ async function copyRemoteActiveRtabmapDatabaseToLocal(
|
|||||||
'destination_db.close()',
|
'destination_db.close()',
|
||||||
'source_db.close()',
|
'source_db.close()',
|
||||||
].join('; ')
|
].join('; ')
|
||||||
const remoteCommand = [
|
const createSnapshotCommand = [
|
||||||
'set -e',
|
'set -e',
|
||||||
`SOURCE=${quoteShellSingle(sourcePath)}`,
|
`SOURCE=${quoteShellSingle(sourcePath)}`,
|
||||||
'test -f "$SOURCE" || { echo "3D SLAM DB를 찾을 수 없습니다: $SOURCE" >&2; exit 1; }',
|
'test -f "$SOURCE" || { echo "3D SLAM DB를 찾을 수 없습니다: $SOURCE" >&2; exit 1; }',
|
||||||
'SNAPSHOT=$(mktemp)',
|
'SNAPSHOT=$(mktemp --suffix=.db)',
|
||||||
`python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`,
|
`python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`,
|
||||||
'base64 -w0 "$SNAPSHOT"',
|
'printf "%s" "$SNAPSHOT"',
|
||||||
'rm -f "$SNAPSHOT"',
|
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const base64 = (await runSshOutput(target, remoteCommand, 120_000)).trim()
|
|
||||||
if (!base64) throw new Error('3D SLAM DB 스냅샷을 읽지 못했습니다.')
|
|
||||||
|
|
||||||
await syncWorkspaceSavedMapArtifacts(localWorkspaceRoot, [])
|
await syncWorkspaceSavedMapArtifacts(localWorkspaceRoot, [])
|
||||||
const root = expandUserPath(localWorkspaceRoot)
|
const root = expandUserPath(localWorkspaceRoot)
|
||||||
if (!root) throw new Error('로컬 워크스페이스 경로가 설정되어 있지 않습니다.')
|
if (!root) throw new Error('로컬 워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||||
const mapsDirectory = resolve(root, 'maps')
|
const mapsDirectory = resolve(root, 'maps')
|
||||||
const destination = resolve(mapsDirectory, destinationFileName)
|
const destination = resolve(mapsDirectory, destinationFileName)
|
||||||
if (!destination.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 3D 맵 경로가 올바르지 않습니다.')
|
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}` }
|
return { databasePath: `/workspace/maps/${destinationFileName}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2149,9 +2269,6 @@ function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModu
|
|||||||
`ROOT=${quoteShellSingle(workspaceRoot)}`,
|
`ROOT=${quoteShellSingle(workspaceRoot)}`,
|
||||||
...expandRemotePathVar('ROOT'),
|
...expandRemotePathVar('ROOT'),
|
||||||
'timeout 5s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true',
|
'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) => {
|
...moduleFolders.map((folderName) => {
|
||||||
const modulePath = remotePathJoin('$ROOT', folderName)
|
const modulePath = remotePathJoin('$ROOT', folderName)
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ const moduleGuidePages = [
|
|||||||
imageAlt: '로컬 환경 모듈 가이드',
|
imageAlt: '로컬 환경 모듈 가이드',
|
||||||
imageSrc: './assets/images/module-guide-local.png',
|
imageSrc: './assets/images/module-guide-local.png',
|
||||||
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
|
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: [
|
parameters: [
|
||||||
['기본', 'sim_mode:=true, platform:=turtlebot3, robot:=turtlebot3, level:=2d'],
|
['기본', 'sim_mode:=true, platform:=turtlebot3, robot:=turtlebot3, level:=2d'],
|
||||||
['Perception', 'sim_mode:=true'],
|
['Perception', 'sim_mode:=true'],
|
||||||
@@ -474,7 +474,7 @@ const moduleGuidePages = [
|
|||||||
imageAlt: '원격 플랫폼 환경 모듈 가이드',
|
imageAlt: '원격 플랫폼 환경 모듈 가이드',
|
||||||
imageSrc: './assets/images/module-guide-remote-platform.png',
|
imageSrc: './assets/images/module-guide-remote-platform.png',
|
||||||
slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'],
|
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: [
|
parameters: [
|
||||||
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=2d'],
|
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=2d'],
|
||||||
['Perception', 'sim_mode:=false'],
|
['Perception', 'sim_mode:=false'],
|
||||||
@@ -486,7 +486,7 @@ const moduleGuidePages = [
|
|||||||
imageAlt: '원격 3D SLAM 환경 모듈 가이드',
|
imageAlt: '원격 3D SLAM 환경 모듈 가이드',
|
||||||
imageSrc: './assets/images/module-guide-remote-3d.png',
|
imageSrc: './assets/images/module-guide-remote-3d.png',
|
||||||
slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'],
|
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: [
|
parameters: [
|
||||||
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=3d']
|
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=3d']
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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({
|
export function useWorkspaceModuleList({
|
||||||
clonePathPreview,
|
clonePathPreview,
|
||||||
curriculumModules,
|
curriculumModules,
|
||||||
@@ -164,8 +184,11 @@ export function useWorkspaceModuleList({
|
|||||||
if (requestSequence !== loadSequenceRef.current) return
|
if (requestSequence !== loadSequenceRef.current) return
|
||||||
setModuleListError(null)
|
setModuleListError(null)
|
||||||
const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
|
const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
|
||||||
setAvailableModules(modules)
|
setAvailableModules((current) => {
|
||||||
storeRemoteModuleCache(moduleCacheKey, modules)
|
const nextModules = retainRunningModuleStats(modules, current)
|
||||||
|
storeRemoteModuleCache(moduleCacheKey, nextModules)
|
||||||
|
return nextModules
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +196,11 @@ export function useWorkspaceModuleList({
|
|||||||
if (requestSequence !== loadSequenceRef.current) return
|
if (requestSequence !== loadSequenceRef.current) return
|
||||||
setModuleListError(null)
|
setModuleListError(null)
|
||||||
const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
|
const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
|
||||||
setAvailableModules(modules)
|
setAvailableModules((current) => {
|
||||||
storeRemoteModuleCache(moduleCacheKey, modules)
|
const nextModules = retainRunningModuleStats(modules, current)
|
||||||
|
storeRemoteModuleCache(moduleCacheKey, nextModules)
|
||||||
|
return nextModules
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestSequence !== loadSequenceRef.current) return
|
if (requestSequence !== loadSequenceRef.current) return
|
||||||
console.error('[modules] list failed', error)
|
console.error('[modules] list failed', error)
|
||||||
|
|||||||
Reference in New Issue
Block a user