Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion WebUI/electron/subprocesses/linuxPackageInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ export async function hasPkexec(): Promise<boolean> {
export async function isAptPackageInstalled(packageName: string): Promise<boolean> {
try {
const { stdout } = await execAsync("dpkg-query -W -f='${db:Status-Status}' " + packageName)
return stdout.trim() === 'installed'
// On multiarch systems (e.g. i386 packages pulled in by Wine/Steam) an
// unqualified package name matches one record per installed architecture,
// so dpkg-query concatenates the status field with no separator (e.g.
// "installedinstalled"). Accept any repetition of "installed", but still
// reject unrelated statuses that contain it as a substring (e.g.
// "not-installed").
return /^(installed)+$/.test(stdout.trim())
} catch {
return false
}
Expand Down
40 changes: 34 additions & 6 deletions WebUI/electron/subprocesses/openVINOBackendService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,39 @@ import { resolveDefaultDevice } from './defaultDeviceSelection.ts'

const execAsync = promisify(exec)

/**
* Parse `ldconfig -p` output into a map of soname -> real library path.
*
* On multiarch systems (e.g. i386 packages pulled in by Wine/Steam) ldconfig -p
* lists one entry per installed architecture under the SAME soname, e.g.:
* libxml2.so.16 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libxml2.so.16
* libxml2.so.16 (libc6) => /usr/lib/i386-linux-gnu/libxml2.so.16
* AI Playground's Linux build is x86-64 only, so an i386 (or other foreign-arch)
* entry must never win over an x86-64 one, regardless of line order: loading a
* 32-bit library into the 64-bit OVMS process fails with "wrong ELF class:
* ELFCLASS32". A foreign-arch tag never contains "x86-64"/"x86_64" (i386 is
* tagged just "(libc6)" on an amd64 host), so once an x86-64 entry is seen for a
* soname it is kept even if a later foreign-arch line repeats that soname.
*/
export function parseLdconfigOutput(ldconfigOutput: string): Map<string, string> {
const ldconfigMap = new Map<string, string>()
const x86_64Sonames = new Set<string>()
for (const line of ldconfigOutput.split('\n')) {
// Line format: " libfoo.so.2 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libfoo.so.2"
const m = line.match(/^\s*(\S+)\s+\(([^)]*)\)\s+=>\s+(\S+)/)
if (!m?.[1] || !m?.[3]) continue
const [, soname, tag, libPath] = m
const isX86_64 = /x86[-_]64/.test(tag ?? '')
if (isX86_64) {
ldconfigMap.set(soname, libPath)
x86_64Sonames.add(soname)
} else if (!x86_64Sonames.has(soname)) {
ldconfigMap.set(soname, libPath)
}
}
return ldconfigMap
}

interface OvmsServerProcess {
process: ChildProcess
port: number
Expand Down Expand Up @@ -503,12 +536,7 @@ export class OpenVINOBackendService implements ApiService {
return
}

const ldconfigMap = new Map<string, string>()
for (const line of ldconfigOutput.split('\n')) {
// Line format: " libfoo.so.2 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libfoo.so.2"
const m = line.match(/^\s*(\S+)\s+\([^)]+\)\s+=>\s+(\S+)/)
if (m?.[1] && m?.[2]) ldconfigMap.set(m[1], m[2])
}
const ldconfigMap = parseLdconfigOutput(ldconfigOutput)

for (const missingLib of missingLibs) {
const symlinkPath = path.join(ovmsLibDir, missingLib)
Expand Down
51 changes: 51 additions & 0 deletions WebUI/electron/test/subprocesses/linuxPackageInstaller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const execMock = vi.fn()

vi.mock('child_process', () => ({
exec: (...args: unknown[]) => execMock(...args),
}))

// isAptPackageInstalled is imported after the mock so promisify(exec) picks it up.
const { isAptPackageInstalled } = await import('../../subprocesses/linuxPackageInstaller')

function mockDpkgQueryOutput(stdout: string) {
execMock.mockImplementation((_cmd: string, cb: (err: unknown, result: unknown) => void) => {
cb(null, { stdout, stderr: '' })
})
}

function mockDpkgQueryError() {
execMock.mockImplementation((_cmd: string, cb: (err: unknown, result: unknown) => void) => {
cb(new Error('no packages found matching pkg'), null)
})
}

describe('isAptPackageInstalled', () => {
beforeEach(() => {
execMock.mockReset()
})

it('returns true for a normal single-architecture match', async () => {
mockDpkgQueryOutput('installed')
await expect(isAptPackageInstalled('libgomp1')).resolves.toBe(true)
})

it('returns true when dpkg-query concatenates statuses for a multiarch package', async () => {
// Reproduces the real output on a system with both amd64 and i386 installed
// (e.g. i386 pulled in by Wine/Steam), which previously broke the exact
// string-equality check.
mockDpkgQueryOutput('installedinstalled')
await expect(isAptPackageInstalled('libgomp1')).resolves.toBe(true)
})

it('returns false when the package is not installed', async () => {
mockDpkgQueryOutput('not-installed')
await expect(isAptPackageInstalled('libgomp1')).resolves.toBe(false)
})

it('returns false when dpkg-query errors (package unknown)', async () => {
mockDpkgQueryError()
await expect(isAptPackageInstalled('does-not-exist')).resolves.toBe(false)
})
})
50 changes: 50 additions & 0 deletions WebUI/electron/test/subprocesses/openVINOBackendService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest'

vi.mock('electron', () => ({ app: { isPackaged: false } }))

import { parseLdconfigOutput } from '../../subprocesses/openVINOBackendService'

describe('parseLdconfigOutput', () => {
it('resolves a single-architecture soname to its path', () => {
const output = '\tlibxml2.so.16 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libxml2.so.16\n'
const map = parseLdconfigOutput(output)
expect(map.get('libxml2.so.16')).toBe('/usr/lib/x86_64-linux-gnu/libxml2.so.16')
})

it('prefers the x86-64 entry over a foreign-arch entry listed after it', () => {
// Reproduces real `ldconfig -p` output on a multiarch system (e.g. i386
// pulled in by Wine/Steam) with both amd64 and i386 libxml2 installed.
// The i386 line has no "x86-64" tag and appears after the x86-64 line.
const output = [
'\tlibxml2.so.16 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libxml2.so.16',
'\tlibxml2.so.16 (libc6) => /usr/lib/i386-linux-gnu/libxml2.so.16',
'',
].join('\n')
const map = parseLdconfigOutput(output)
expect(map.get('libxml2.so.16')).toBe('/usr/lib/x86_64-linux-gnu/libxml2.so.16')
})

it('prefers the x86-64 entry even when the foreign-arch entry is listed first', () => {
const output = [
'\tlibxml2.so.16 (libc6) => /usr/lib/i386-linux-gnu/libxml2.so.16',
'\tlibxml2.so.16 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libxml2.so.16',
'',
].join('\n')
const map = parseLdconfigOutput(output)
expect(map.get('libxml2.so.16')).toBe('/usr/lib/x86_64-linux-gnu/libxml2.so.16')
})

it('falls back to the only available entry when no x86-64 tag exists at all', () => {
const output = '\tlibfoo.so.1 (libc6) => /usr/lib/i386-linux-gnu/libfoo.so.1\n'
const map = parseLdconfigOutput(output)
expect(map.get('libfoo.so.1')).toBe('/usr/lib/i386-linux-gnu/libfoo.so.1')
})

it('ignores unparsable lines', () => {
const output = ['1234 libs found in cache `/etc/ld.so.cache\'', '\tnot a valid line', ''].join(
'\n',
)
const map = parseLdconfigOutput(output)
expect(map.size).toBe(0)
})
})