From f588b9f4062bb3c8bf3e8a0017b279e3ba7f679f Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 5 May 2026 00:20:14 -0400 Subject: [PATCH] fix(container): activate podman socket on Linux when not running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, the podman API socket is not always active — it requires systemd socket activation or an explicit service start. This matches Podman Desktop's behavior by spawning `podman system service --time=0` when the socket is missing, waiting up to 5s for it to appear, and killing the process on extension deactivation. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Brian --- .../podman/podman-linux-finder.spec.ts | 209 ++++++++++++++---- .../podman/podman-linux-finder.ts | 50 ++++- 2 files changed, 221 insertions(+), 38 deletions(-) diff --git a/extensions/container/packages/extension/src/helper/socket-finder/podman/podman-linux-finder.spec.ts b/extensions/container/packages/extension/src/helper/socket-finder/podman/podman-linux-finder.spec.ts index cec72a4c19..b06cb64e7f 100644 --- a/extensions/container/packages/extension/src/helper/socket-finder/podman/podman-linux-finder.spec.ts +++ b/extensions/container/packages/extension/src/helper/socket-finder/podman/podman-linux-finder.spec.ts @@ -16,21 +16,30 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; import type { PathLike } from 'node:fs'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { PodmanSocketLinuxFinder } from './podman-linux-finder'; vi.mock(import('node:fs')); +vi.mock(import('node:child_process')); let originalXdgRuntimeDir: string | undefined; +let originalFlatpakId: string | undefined; beforeEach(() => { vi.resetAllMocks(); originalXdgRuntimeDir = process.env.XDG_RUNTIME_DIR; + originalFlatpakId = process.env.FLATPAK_ID; + vi.mocked(spawn).mockReturnValue({ + on: vi.fn(), + kill: vi.fn(), + } as unknown as ChildProcess); }); afterEach(() => { @@ -39,20 +48,14 @@ afterEach(() => { } else { delete process.env.XDG_RUNTIME_DIR; } + if (originalFlatpakId !== undefined) { + process.env.FLATPAK_ID = originalFlatpakId; + } else { + delete process.env.FLATPAK_ID; + } }); -test('findPaths returns empty array when no sockets exist', async () => { - const finder = new PodmanSocketLinuxFinder(); - - process.env.XDG_RUNTIME_DIR = '/run/user/1000'; - vi.mocked(existsSync).mockReturnValue(false); - - const result = await finder.findPaths(); - - expect(result).toEqual([]); -}); - -test('findPaths returns rootless socket when it exists', async () => { +test('findPaths returns rootless socket when it already exists without spawning', async () => { const finder = new PodmanSocketLinuxFinder(); process.env.XDG_RUNTIME_DIR = '/run/user/1000'; @@ -65,19 +68,7 @@ test('findPaths returns rootless socket when it exists', async () => { expect(result).toContain(expectedSocket); expect(result).not.toContain('/run/podman/podman.sock'); -}); - -test('findPaths returns rootful socket when it exists', async () => { - const finder = new PodmanSocketLinuxFinder(); - - delete process.env.XDG_RUNTIME_DIR; - vi.mocked(existsSync).mockImplementation((path: PathLike) => { - return String(path) === '/run/podman/podman.sock'; - }); - - const result = await finder.findPaths(); - - expect(result).toEqual(['/run/podman/podman.sock']); + expect(spawn).not.toHaveBeenCalled(); }); test('findPaths returns both sockets when both exist', async () => { @@ -94,17 +85,6 @@ test('findPaths returns both sockets when both exist', async () => { expect(result).toContain('/run/podman/podman.sock'); }); -test('findPaths returns empty array when XDG_RUNTIME_DIR is not set and rootful socket does not exist', async () => { - const finder = new PodmanSocketLinuxFinder(); - - delete process.env.XDG_RUNTIME_DIR; - vi.mocked(existsSync).mockReturnValue(false); - - const result = await finder.findPaths(); - - expect(result).toEqual([]); -}); - test('findPaths falls back to /run/user/$UID when XDG_RUNTIME_DIR is unset', async () => { const finder = new PodmanSocketLinuxFinder(); @@ -122,3 +102,158 @@ test('findPaths falls back to /run/user/$UID when XDG_RUNTIME_DIR is unset', asy expect(result).toContain(expectedSocket); } }); + +test('dispose is safe to call when no process was spawned', () => { + const finder = new PodmanSocketLinuxFinder(); + + expect(() => finder.dispose()).not.toThrow(); +}); + +describe('with fake timers', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('findPaths returns empty array when no sockets exist and podman service fails to start', async () => { + const finder = new PodmanSocketLinuxFinder(); + + process.env.XDG_RUNTIME_DIR = '/run/user/1000'; + vi.mocked(existsSync).mockReturnValue(false); + + const resultPromise = finder.findPaths(); + await vi.advanceTimersByTimeAsync( + PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS, + ); + const result = await resultPromise; + + expect(result).toEqual([]); + expect(spawn).toHaveBeenCalledWith('podman', ['system', 'service', '--time=0'], { stdio: 'ignore' }); + }); + + test('findPaths returns rootful socket when rootless is unavailable', async () => { + const finder = new PodmanSocketLinuxFinder(); + + delete process.env.XDG_RUNTIME_DIR; + const uid = process.getuid?.(); + const rootlessSocket = uid !== undefined ? resolve(`/run/user/${uid}`, 'podman/podman.sock') : undefined; + + vi.mocked(existsSync).mockImplementation((path: PathLike) => { + if (String(path) === '/run/podman/podman.sock') return true; + if (rootlessSocket && String(path) === rootlessSocket) return false; + return false; + }); + + const resultPromise = finder.findPaths(); + await vi.advanceTimersByTimeAsync( + PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS, + ); + const result = await resultPromise; + + expect(result).toEqual(['/run/podman/podman.sock']); + }); + + test('findPaths returns empty array when XDG_RUNTIME_DIR is not set and rootful socket does not exist', async () => { + const finder = new PodmanSocketLinuxFinder(); + + delete process.env.XDG_RUNTIME_DIR; + vi.mocked(existsSync).mockReturnValue(false); + + const resultPromise = finder.findPaths(); + await vi.advanceTimersByTimeAsync( + PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS, + ); + const result = await resultPromise; + + expect(result).toEqual([]); + }); + + test('findPaths spawns podman service when rootless socket does not exist and returns socket after it appears', async () => { + const finder = new PodmanSocketLinuxFinder(); + + process.env.XDG_RUNTIME_DIR = '/run/user/1000'; + const expectedSocket = resolve('/run/user/1000', 'podman/podman.sock'); + + let callCount = 0; + vi.mocked(existsSync).mockImplementation((path: PathLike) => { + if (String(path) === expectedSocket) { + callCount++; + return callCount >= 3; + } + return false; + }); + + const resultPromise = finder.findPaths(); + await vi.advanceTimersByTimeAsync( + PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS, + ); + const result = await resultPromise; + + expect(spawn).toHaveBeenCalledWith('podman', ['system', 'service', '--time=0'], { stdio: 'ignore' }); + expect(result).toContain(expectedSocket); + }); + + test('findPaths uses flatpak-spawn when FLATPAK_ID is set', async () => { + const finder = new PodmanSocketLinuxFinder(); + + process.env.XDG_RUNTIME_DIR = '/run/user/1000'; + process.env.FLATPAK_ID = 'com.example.app'; + vi.mocked(existsSync).mockReturnValue(false); + + const resultPromise = finder.findPaths(); + await vi.advanceTimersByTimeAsync( + PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS, + ); + await resultPromise; + + expect(spawn).toHaveBeenCalledWith('flatpak-spawn', ['--host', 'podman', 'system', 'service', '--time=0'], { + stdio: 'ignore', + }); + }); + + test('dispose kills the spawned podman process', async () => { + const finder = new PodmanSocketLinuxFinder(); + const killMock = vi.fn(); + vi.mocked(spawn).mockReturnValue({ + on: vi.fn(), + kill: killMock, + } as unknown as ChildProcess); + + process.env.XDG_RUNTIME_DIR = '/run/user/1000'; + vi.mocked(existsSync).mockReturnValue(false); + + const resultPromise = finder.findPaths(); + await vi.advanceTimersByTimeAsync( + PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS, + ); + await resultPromise; + + finder.dispose(); + + expect(killMock).toHaveBeenCalled(); + }); + + test('findPaths does not spawn a second process if one is already running', async () => { + const finder = new PodmanSocketLinuxFinder(); + + process.env.XDG_RUNTIME_DIR = '/run/user/1000'; + vi.mocked(existsSync).mockReturnValue(false); + + const resultPromise1 = finder.findPaths(); + await vi.advanceTimersByTimeAsync( + PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS, + ); + await resultPromise1; + + const resultPromise2 = finder.findPaths(); + await vi.advanceTimersByTimeAsync( + PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS, + ); + await resultPromise2; + + expect(spawn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/container/packages/extension/src/helper/socket-finder/podman/podman-linux-finder.ts b/extensions/container/packages/extension/src/helper/socket-finder/podman/podman-linux-finder.ts index b18b00a57c..b933b72a85 100644 --- a/extensions/container/packages/extension/src/helper/socket-finder/podman/podman-linux-finder.ts +++ b/extensions/container/packages/extension/src/helper/socket-finder/podman/podman-linux-finder.ts @@ -16,15 +16,22 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import { injectable } from 'inversify'; +import { injectable, preDestroy } from 'inversify'; import type { SocketFinder } from '/@/api/socket-finder'; @injectable() export class PodmanSocketLinuxFinder implements SocketFinder { + static readonly SOCKET_WAIT_ATTEMPTS = 50; + static readonly SOCKET_WAIT_INTERVAL_MS = 100; + + #podmanProcess: ChildProcess | undefined; + async findPaths(): Promise { const paths: string[] = []; @@ -34,6 +41,9 @@ export class PodmanSocketLinuxFinder implements SocketFinder { const xdgRuntimeDir = process.env.XDG_RUNTIME_DIR ?? (uid !== undefined ? `/run/user/${uid}` : undefined); if (xdgRuntimeDir) { const rootlessSocket = resolve(xdgRuntimeDir, 'podman/podman.sock'); + if (!existsSync(rootlessSocket)) { + await this.startPodmanService(rootlessSocket); + } if (existsSync(rootlessSocket)) { paths.push(rootlessSocket); } @@ -47,4 +57,42 @@ export class PodmanSocketLinuxFinder implements SocketFinder { return paths; } + + protected async startPodmanService(socketPath: string): Promise { + if (this.#podmanProcess) { + return; + } + + let command = 'podman'; + let args = ['system', 'service', '--time=0']; + if (process.env.FLATPAK_ID) { + command = 'flatpak-spawn'; + args = ['--host', 'podman', ...args]; + } + + this.#podmanProcess = spawn(command, args, { stdio: 'ignore' }); + this.#podmanProcess.on('error', err => { + console.error('Failed to start podman system service:', err); + this.#podmanProcess = undefined; + }); + + for (let i = 0; i < PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS; i++) { + if (existsSync(socketPath)) { + return; + } + await new Promise(resolve => setTimeout(resolve, PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS)); + } + + console.error( + `Could not find the socket at ${socketPath} after ${(PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS) / 1000}s. The command podman system service --time=0 did not work to start the podman socket.`, + ); + } + + @preDestroy() + dispose(): void { + if (this.#podmanProcess) { + this.#podmanProcess.kill(); + this.#podmanProcess = undefined; + } + } }