diff --git a/src/main/ipc/managed-host-status-publication.test.ts b/src/main/ipc/managed-host-status-publication.test.ts index 6807215984b..7874cb6390e 100644 --- a/src/main/ipc/managed-host-status-publication.test.ts +++ b/src/main/ipc/managed-host-status-publication.test.ts @@ -14,7 +14,7 @@ vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ getSshTargetRegistryStore: () => null })) -const { clearManagedServerNotes, publishHostServerStatus } = +const { clearLaunchSessionNote, clearManagedServerNotes, publishHostServerStatus } = await import('./runtime-environment-managed-tunnel') const { clearSshHostServerStatus, getSshHostServerStatus, setSshHostServerStatus } = await import('../ssh/ssh-host-server-status') @@ -108,4 +108,13 @@ describe('publishing a verified managed host status', () => { expect(getSshHostServerStatus('ssh-2')).toBeUndefined() expect(mocks.notify).not.toHaveBeenCalled() }) + + it('keeps the launch-session warning through an update; only a verified restart clears it', () => { + mocks.getState.mockReturnValue(connection('connected')) + setSshHostServerStatus('ssh-1', { ...deferred, launchSessionEnded: true }) + clearManagedServerNotes('ssh-1', 'env-1') + expect(getSshHostServerStatus('ssh-1')).toEqual({ ...ready, launchSessionEnded: true }) + clearLaunchSessionNote('ssh-1', 'env-1') + expect(getSshHostServerStatus('ssh-1')).toEqual(ready) + }) }) diff --git a/src/main/ipc/managed-orcad-actions.ts b/src/main/ipc/managed-orcad-actions.ts index dfa03e1b734..927ba232de1 100644 --- a/src/main/ipc/managed-orcad-actions.ts +++ b/src/main/ipc/managed-orcad-actions.ts @@ -12,6 +12,8 @@ import { getOrcadSshDeployment } from '../../shared/runtime-environments' import { resolveEnvironment } from '../../shared/runtime-environment-store' import type { OrcadManagedRestartResult } from '../../shared/orcad-managed-runtime' import { restartManagedOrcadEnvironment } from '../ssh/orcad-managed-launch-session' +import { clearLaunchSessionNote } from './runtime-environment-managed-tunnel' +import { forgetLaunchSessionVerdict } from './ssh-host-launch-session-note' import type { ManagedServerActions } from '../runtime/managed-server-actions-registry' import { retireRemovedRuntimeEnvironment, @@ -104,6 +106,8 @@ export async function restartManagedOrcad( const targetId = getOrcadSshDeployment(environment)?.sshTargetId if (targetId) { options.clearHostServerNotes(targetId, result.environmentId) + forgetLaunchSessionVerdict(targetId) + clearLaunchSessionNote(targetId, result.environmentId) } } return result diff --git a/src/main/ipc/runtime-environment-managed-tunnel.ts b/src/main/ipc/runtime-environment-managed-tunnel.ts index 6d5eb72deac..d2a31cc6071 100644 --- a/src/main/ipc/runtime-environment-managed-tunnel.ts +++ b/src/main/ipc/runtime-environment-managed-tunnel.ts @@ -58,7 +58,18 @@ export async function resolveManagedRuntimeEnvironment( } } else { // Why here: an auto-restored host may never see an SSH connect, so it would never update. - void updateManagedOrcadOnRestore(environment.id, createDeps) + const update = updateManagedOrcadOnRestore(environment.id, createDeps) + const targetId = getOrcadSshDeployment(environment)?.sshTargetId + if (targetId) { + // Why after the update: its settled status would otherwise replace the warning. + // Why lazy: the note publishes through this module. + void Promise.resolve(update) + .then(() => import('./ssh-host-launch-session-note')) + .then(({ noteEndedLaunchSshSession }) => + noteEndedLaunchSshSession(targetId, environment.id) + ) + .catch(() => {}) + } } return resolveEnvironment(userDataPath, environment.id) } @@ -126,7 +137,21 @@ export function clearManagedServerNotes(targetId: string, environmentId: string) if (current?.kind !== 'managed' || current.environmentId !== environmentId) { return } - if (current.update || current.serving || current.launchSessionEnded) { - publishHostServerStatus(targetId, { kind: 'managed', environmentId }) + if (current.update || current.serving) { + // An update can keep the terminal daemon, so only a verified restart clears that warning. + publishHostServerStatus(targetId, { + kind: 'managed', + environmentId, + ...(current.launchSessionEnded ? { launchSessionEnded: true as const } : {}) + }) + } +} + +/** A restart that verified a fresh daemon and a live launching session clears the warning. */ +export function clearLaunchSessionNote(targetId: string, environmentId: string): void { + const current = getSshHostServerStatus(targetId) + if (current?.kind === 'managed' && current.environmentId === environmentId) { + const { launchSessionEnded: _cleared, ...rest } = current + publishHostServerStatus(targetId, rest) } } diff --git a/src/main/ipc/ssh-host-launch-session-note.test.ts b/src/main/ipc/ssh-host-launch-session-note.test.ts new file mode 100644 index 00000000000..75ad8e617be --- /dev/null +++ b/src/main/ipc/ssh-host-launch-session-note.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshManagedServerStatus } from '../../shared/ssh-types' + +const mocks = vi.hoisted(() => { + const status: { current: SshManagedServerStatus | undefined } = { current: undefined } + const os: { current: string } = { current: 'darwin' } + const connection: { current: { getConnectGeneration: () => number } } = { + current: { getConnectGeneration: () => 1 } + } + const environments: { current: string[] } = { current: ['env-1'] } + return { read: vi.fn(), publish: vi.fn(), os, connection, status, environments } +}) + +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/u' }) +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + listEnvironments: () => mocks.environments.current.map((id) => ({ id })) +})) +vi.mock('../ssh/orcad-managed-launch-session', () => ({ + readManagedOrcadLaunchSshSession: mocks.read +})) +vi.mock('../ssh/ssh-host-platform-memo', () => ({ + knownSshHostPlatform: () => ({ os: mocks.os.current }) +})) +vi.mock('../ssh/ssh-host-server-status', () => ({ + getSshHostServerStatus: () => mocks.status.current +})) +vi.mock('./ssh-ipc-context', () => ({ + connectionManager: { getConnection: () => mocks.connection.current } +})) +vi.mock('./runtime-environment-managed-tunnel', () => ({ publishHostServerStatus: mocks.publish })) + +const { forgetLaunchSessionVerdict, noteEndedLaunchSshSession } = + await import('./ssh-host-launch-session-note') + +beforeEach(() => { + vi.clearAllMocks() + mocks.os.current = 'darwin' + mocks.connection.current = { getConnectGeneration: () => 1 } + mocks.status.current = undefined + mocks.environments.current = ['env-1'] + mocks.read.mockResolvedValue('exited') +}) + +describe('noteEndedLaunchSshSession', () => { + it('flags a restored host that has no status yet', async () => { + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.publish).toHaveBeenCalledWith('ssh-1', { + kind: 'managed', + environmentId: 'env-1', + launchSessionEnded: true + }) + }) + + it('checks once per SSH connection, and again on a new one', async () => { + await noteEndedLaunchSshSession('ssh-1', 'env-1') + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.read).toHaveBeenCalledOnce() + mocks.connection.current = { getConnectGeneration: () => 1 } + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.read).toHaveBeenCalledTimes(2) + }) + + it('checks again after the same connection reconnects', async () => { + let generation = 1 + mocks.connection.current = { getConnectGeneration: () => generation } + mocks.read.mockResolvedValue('live') + await noteEndedLaunchSshSession('ssh-1', 'env-1') + generation = 2 + mocks.read.mockResolvedValue('exited') + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.publish).toHaveBeenCalledOnce() + }) + + it('retries when a start or update was still deciding', async () => { + mocks.status.current = { kind: 'setting-up', phase: 'updating' } + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.publish).not.toHaveBeenCalled() + mocks.status.current = { kind: 'managed', environmentId: 'env-1' } + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.publish).toHaveBeenCalledOnce() + }) + + it('reapplies a known ended session to a status a later connect replaced', async () => { + await noteEndedLaunchSshSession('ssh-1', 'env-1') + mocks.status.current = { kind: 'managed', environmentId: 'env-1' } + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.read).toHaveBeenCalledOnce() + expect(mocks.publish).toHaveBeenLastCalledWith('ssh-1', { + kind: 'managed', + environmentId: 'env-1', + launchSessionEnded: true + }) + }) + + it('probes again after a verified restart forgets the verdict', async () => { + await noteEndedLaunchSshSession('ssh-1', 'env-1') + forgetLaunchSessionVerdict('ssh-1') + mocks.read.mockResolvedValue('live') + mocks.status.current = { kind: 'managed', environmentId: 'env-1' } + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.read).toHaveBeenCalledTimes(2) + expect(mocks.publish).toHaveBeenCalledOnce() + }) + + it('probes again once a live verdict is old', async () => { + let clock = 0 + mocks.read.mockResolvedValue('live') + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + clock = 60_000 + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + expect(mocks.read).toHaveBeenCalledOnce() + clock = 11 * 60_000 + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + expect(mocks.read).toHaveBeenCalledTimes(2) + }) + + it('drops an answer whose server was unlinked or whose connection changed meanwhile', async () => { + mocks.read.mockImplementationOnce(async () => { + mocks.environments.current = [] + return 'exited' + }) + await noteEndedLaunchSshSession('ssh-1', 'env-1') + mocks.read.mockImplementationOnce(async () => { + mocks.connection.current = { getConnectGeneration: () => 1 } + return 'exited' + }) + mocks.environments.current = ['env-1'] + mocks.connection.current = { getConnectGeneration: () => 1 } + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.publish).not.toHaveBeenCalled() + }) + + it('never probes a Linux or Windows host', async () => { + mocks.os.current = 'linux' + await noteEndedLaunchSshSession('ssh-1', 'env-1') + mocks.os.current = 'win32' + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.read).not.toHaveBeenCalled() + }) + + it.each(['live', 'unverifiable', 'not-recorded'])('shows nothing for %s', async (verdict) => { + mocks.read.mockResolvedValue(verdict) + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.publish).not.toHaveBeenCalled() + }) + + it('leaves another server decision alone', async () => { + mocks.status.current = { kind: 'relay', reason: 'source_changed' } + await noteEndedLaunchSshSession('ssh-1', 'env-1') + expect(mocks.publish).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/ssh-host-launch-session-note.ts b/src/main/ipc/ssh-host-launch-session-note.ts index 2918b16e27c..1177a14cbe3 100644 --- a/src/main/ipc/ssh-host-launch-session-note.ts +++ b/src/main/ipc/ssh-host-launch-session-note.ts @@ -1,34 +1,94 @@ -/** After a managed connect: flags a macOS host whose server outlived its launching SSH session. */ +/** After a managed connect or restore: flags a macOS host whose server outlived its launching SSH session. */ import { getAppEnvironment } from '../../shared/app-environment' +import { listEnvironments } from '../../shared/runtime-environment-store' import { readManagedOrcadLaunchSshSession } from '../ssh/orcad-managed-launch-session' import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' import { getSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { connectionManager } from './ssh-ipc-context' import { publishHostServerStatus } from './runtime-environment-managed-tunnel' -/** Never blocks or fails the connect; anything but a proven exit shows nothing. */ +// Once per SSH session: a restore runs on every runtime call, and the answer only changes when +// a session ends, which the next session (a new connection or reconnect generation) checks. +// The ended verdict is kept so a status a later connect replaces gets the warning back. Any +// other verdict expires: another desktop's launching session can end while this one stays. +const checked = new WeakMap() +const RECHECK_MS = 10 * 60_000 + +/** Never blocks or fails the caller; anything but a proven exit shows nothing. */ export async function noteEndedLaunchSshSession( targetId: string, - environmentId: string + environmentId: string, + now: () => number = Date.now ): Promise { // Why the memo: Linux and Windows hosts cost no extra round trip; the tunnel build fills it. if (knownSshHostPlatform(targetId)?.os !== 'darwin') { return } + const connection = connectionManager?.getConnection(targetId) + if (!connection) { + return + } + const generation = connection.getConnectGeneration() + const prior = checked.get(connection) + if (prior?.generation === generation && (prior.ended || now() - prior.at < RECHECK_MS)) { + if (prior.ended) { + flag(targetId, environmentId) + } + return + } + checked.set(connection, { generation, ended: false, at: now() }) try { const session = await readManagedOrcadLaunchSshSession( getAppEnvironment().getPath('userData'), environmentId ) - const status = getSshHostServerStatus(targetId) + // Why: a disconnect, reconnect or unlink during the probe makes its answer someone else's. if ( - session === 'exited' && - status?.kind === 'managed' && - status.environmentId === environmentId && - !status.serving + connectionManager?.getConnection(targetId) !== connection || + connection.getConnectGeneration() !== generation ) { - publishHostServerStatus(targetId, { ...status, launchSessionEnded: true }) + return + } + const status = getSshHostServerStatus(targetId) + if (status?.kind === 'setting-up') { + // A start or update is still deciding; a later call checks again. + checked.delete(connection) + return + } + if (session === 'exited') { + checked.set(connection, { generation, ended: true, at: now() }) + flag(targetId, environmentId) } } catch (error) { console.warn('[ssh] Could not check which SSH session started the managed server:', error) } } + +/** A restored host may have no status yet; any other server decision is left alone. */ +function flag(targetId: string, environmentId: string): void { + const userDataPath = getAppEnvironment().getPath('userData') + if (!listEnvironments(userDataPath).some((entry) => entry.id === environmentId)) { + return + } + const status = getSshHostServerStatus(targetId) + if ( + !status || + (status.kind === 'managed' && + status.environmentId === environmentId && + !status.serving && + !status.launchSessionEnded) + ) { + publishHostServerStatus(targetId, { + ...(status ?? { kind: 'managed', environmentId }), + launchSessionEnded: true + }) + } +} + +/** After a verified restart: the cached ended verdict no longer describes the host. */ +export function forgetLaunchSessionVerdict(targetId: string): void { + const connection = connectionManager?.getConnection(targetId) + if (connection) { + checked.delete(connection) + } +} diff --git a/src/main/ssh/orcad-launch-ssh-session.test.ts b/src/main/ssh/orcad-launch-ssh-session.test.ts index 7da2980241b..1b8a3df329a 100644 --- a/src/main/ssh/orcad-launch-ssh-session.test.ts +++ b/src/main/ssh/orcad-launch-ssh-session.test.ts @@ -19,7 +19,7 @@ import { getRemoteHostPlatform } from './ssh-remote-platform' const mac = getRemoteHostPlatform('darwin-arm64') const linux = getRemoteHostPlatform('linux-x64') -// Table rows are `pid|ppid|comm|lstart`; a PID in FAKE_PS_GONE has no process, and any other +// Table rows are `pid|ppid|comm|lstart|args`; a PID in FAKE_PS_GONE has no process, and any other // unlisted PID is the calling shell, whose parent is FAKE_PS_SHELL_PARENT. const FAKE_PS = `#!/bin/sh [ -n "$FAKE_PS_BROKEN" ] && exit 1 @@ -33,6 +33,7 @@ case "$field" in ppid) echo " $(echo "$row" | cut -d'|' -f2)";; comm) echo "$row" | cut -d'|' -f3;; lstart) echo "$row" | cut -d'|' -f4;; + args) echo "$row" | cut -d'|' -f5;; esac ` @@ -65,6 +66,24 @@ function writeRecord(pid: string, record: string | null): void { } } +function publishDaemon(pid: number, args: string): void { + spawnSync('mkdir', [join(dir, 'daemon')]) + writeFileSync(join(dir, 'daemon', 'daemon-v1.pid'), JSON.stringify({ pid, startedAtMs: 1 })) + writeFileSync( + join(dir, 'ps-table'), + `${TABLE}\n${pid}|1|/opt/homebrew/bin/node|Thu Oct 9 07:59:59 2026|${args}` + ) +} + +function launchRecord(env: Record = {}): string { + const pidFile = join(dir, ORCAD_PID_FILENAME) + return sh( + `${captureOrcadLaunchSshSessionCommand()} sleep 0 & echo $! > '${pidFile}' && ` + + `${writeOrcadLaunchSshSessionCommand(mac, dir)} && cat '${pidFile}'`, + env + ).trim() +} + function probe(env: Record = {}): string { return parseOrcadLaunchSshSession(sh(orcadLaunchSshSessionProbeCommand(mac, dir, dir), env)) } @@ -132,17 +151,11 @@ describe.skipIf(process.platform === 'win32')('orcad launch SSH session', () => it('carries the earlier session forward when the terminal daemon outlived orcad', () => { writeRecord('1', '1 400 Thu Oct 9 07:59:59 2026') - spawnSync('mkdir', [join(dir, 'daemon')]) - // This test process stands in for the surviving daemon. - writeFileSync( - join(dir, 'daemon', 'daemon-v1.pid'), - JSON.stringify({ pid: process.pid, startedAtMs: 1 }) + publishDaemon( + process.pid, + `node /x/daemon-entry.js --socket ${join(dir, 'daemon')}/daemon.sock` ) - const pidFile = join(dir, ORCAD_PID_FILENAME) - const pid = sh( - `${captureOrcadLaunchSshSessionCommand()} sleep 0 & echo $! > '${pidFile}' && ` + - `${writeOrcadLaunchSshSessionCommand(mac, dir)} && cat '${pidFile}'` - ).trim() + const pid = launchRecord() expect(readFileSync(join(dir, ORCAD_LAUNCH_SSH_SESSION_FILENAME), 'utf8').trim()).toBe( `${pid} 400 Thu Oct 9 07:59:59 2026` ) @@ -150,14 +163,43 @@ describe.skipIf(process.platform === 'win32')('orcad launch SSH session', () => }) it('records nothing when a surviving daemon’s session was never recorded', () => { - spawnSync('mkdir', [join(dir, 'daemon')]) - writeFileSync(join(dir, 'daemon', 'daemon-v1.pid'), JSON.stringify({ pid: process.pid })) - sh( - `${captureOrcadLaunchSshSessionCommand()} sleep 0 & ${writeOrcadLaunchSshSessionCommand(mac, dir)}` + publishDaemon( + process.pid, + `node /x/daemon-entry.js --socket ${join(dir, 'daemon')}/daemon.sock` ) + launchRecord() expect(existsSync(join(dir, ORCAD_LAUNCH_SSH_SESSION_FILENAME))).toBe(false) }) + it.each([ + ['a stale PID file naming another process', '/usr/sbin/cron'], + ['another data root’s daemon', 'node /x/daemon-entry.js --socket /other/daemon/daemon.sock'] + ])('records this launch’s own session over %s', (_case, args) => { + writeRecord('1', '1 400 Thu Oct 9 07:59:59 2026') + publishDaemon(process.pid, args) + const pid = launchRecord() + expect(readFileSync(join(dir, ORCAD_LAUNCH_SSH_SESSION_FILENAME), 'utf8').trim()).toBe( + `${pid} 500 Thu Oct 9 08:00:00 2026` + ) + }) + + it('leaves the attribution unknown when a live daemon’s command line can’t be read', () => { + writeRecord('1', '1 400 Thu Oct 9 07:59:59 2026') + publishDaemon(process.pid, '') + launchRecord() + expect(existsSync(join(dir, ORCAD_LAUNCH_SSH_SESSION_FILENAME))).toBe(false) + }) + + it('creates the data root on a first deploy before recording', () => { + const data = join(dir, 'fresh', 'data') + const pidFile = join(dir, ORCAD_PID_FILENAME) + sh( + `${captureOrcadLaunchSshSessionCommand()} sleep 0 & echo $! > '${pidFile}' && ` + + `${writeOrcadLaunchSshSessionCommand(mac, data)}` + ) + expect(existsSync(join(data, ORCAD_LAUNCH_SSH_SESSION_FILENAME))).toBe(true) + }) + it('removes an earlier record when this launch finds no session', () => { writeRecord('1', '1 500 Thu Oct 9 08:00:00 2026') sh( diff --git a/src/main/ssh/orcad-launch-ssh-session.ts b/src/main/ssh/orcad-launch-ssh-session.ts index e8546898fc8..71dc9f37392 100644 --- a/src/main/ssh/orcad-launch-ssh-session.ts +++ b/src/main/ssh/orcad-launch-ssh-session.ts @@ -57,14 +57,23 @@ function recordPath(host: RemoteHostPlatform, userDataDir: string): string { return shellEscape(joinRemotePath(host, userDataDir, ORCAD_LAUNCH_SSH_SESSION_FILENAME)) } -/** True when a terminal daemon this data root published is still running. */ +/** + * Returns 0 when a terminal daemon this data root published is still running, 1 when none is, + * and 2 when a live PID's command line can't be read. Like `commandLineMatchesDaemon`, a PID + * counts only when its command line is this root's daemon: a stale PID file after a reboot can + * name an unrelated process. + */ function liveDaemonShellFunction(host: RemoteHostPlatform, userDataDir: string): string { const daemonDir = shellEscape(joinRemotePath(host, userDataDir, 'daemon')) return ( - `orcad_daemon_live() { for orcad_df in ${daemonDir}/daemon-v*.pid; do ` + + `orcad_daemon_live() { orcad_ddir=${daemonDir}; for orcad_df in "$orcad_ddir"/daemon-v*.pid; do ` + '[ -f "$orcad_df" ] || continue; ' + 'orcad_dp=$(sed -n \'s/.*"pid":\\([0-9][0-9]*\\).*/\\1/p\' "$orcad_df" | head -n 1); ' + - '[ -n "$orcad_dp" ] && kill -0 "$orcad_dp" 2>/dev/null && return 0; done; return 1; };' + '[ -n "$orcad_dp" ] || continue; ' + + 'kill -0 "$orcad_dp" 2>/dev/null || continue; ' + + 'orcad_da=$(ps -o args= -p "$orcad_dp" 2>/dev/null); [ -n "$orcad_da" ] || return 2; ' + + 'case "$orcad_da" in *daemon-entry*"$orcad_ddir"* | *"$orcad_ddir"*daemon-entry*) return 0;; esac; ' + + 'done; return 1; };' ) } @@ -78,10 +87,15 @@ export function writeOrcadLaunchSshSessionCommand( ): string { const file = recordPath(host, userDataDir) return ( - `{ orcad_bg=$!; ${liveDaemonShellFunction(host, userDataDir)} ` + - 'if orcad_daemon_live; then ' + + // Why mkdir: on a first deploy the detached server may not have created its data root yet. + `{ orcad_bg=$!; ( umask 077; mkdir -p ${shellEscape(userDataDir)} ); ` + + `${liveDaemonShellFunction(host, userDataDir)} ` + + 'orcad_daemon_live; orcad_dl=$?; ' + + 'if [ "$orcad_dl" = 0 ]; then ' + `orcad_prev=$(cat ${file}); rm -f ${file}; set -- $orcad_prev; ` + `[ "$#" -lt 3 ] || { shift; ( umask 077; echo "$orcad_bg" "$@" > ${file} ); }; ` + + // An unreadable daemon leaves the attribution unknown, never this launch's. + `elif [ "$orcad_dl" = 2 ]; then rm -f ${file}; ` + `else rm -f ${file}; [ -z "$orcad_session" ] || ` + `( umask 077; echo "$orcad_bg" $orcad_session > ${file} ); fi; :; } 2>/dev/null` )