From 57f5cfa672659abc4f630f52905568907efa6be5 Mon Sep 17 00:00:00 2001 From: larry foulkrod Date: Thu, 13 Aug 2026 19:06:26 -0500 Subject: [PATCH 1/3] Detach macOS polling helpers from terminal session --- engine/process_darwin.go | 11 ++++++----- engine/process_darwin_test.go | 11 +++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/engine/process_darwin.go b/engine/process_darwin.go index 62df7ea..0937272 100644 --- a/engine/process_darwin.go +++ b/engine/process_darwin.go @@ -7,12 +7,13 @@ import ( "syscall" ) -// prepareHiddenConsoleCommand keeps non-interactive helpers out of the TUI's -// foreground process group. Terminal.app includes every process in that group -// in its title, so the dashboard's frequent Podman probes would otherwise make -// the title alternate between "podman — omnideck" and "omnideck". +// prepareHiddenConsoleCommand starts non-interactive helpers in a new session. +// A separate process group is not enough: the child still belongs to the TUI's +// terminal session, so Terminal.app can expose its name as the active process +// while the dashboard polls Podman. A new session has no controlling terminal +// and therefore cannot replace Omnideck in the terminal title. func prepareHiddenConsoleCommand(command *exec.Cmd) { - command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } func prepareVisibleCommand(command *exec.Cmd) { diff --git a/engine/process_darwin_test.go b/engine/process_darwin_test.go index ef21033..509ebf4 100644 --- a/engine/process_darwin_test.go +++ b/engine/process_darwin_test.go @@ -7,15 +7,18 @@ import ( "testing" ) -func TestPrepareHiddenConsoleCommandUsesSeparateProcessGroup(t *testing.T) { +func TestPrepareHiddenConsoleCommandUsesSeparateSession(t *testing.T) { command := exec.Command("podman", "info") prepareHiddenConsoleCommand(command) - if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid { - t.Fatalf("macOS background helpers must use a separate process group, got %#v", command.SysProcAttr) + if command.SysProcAttr == nil || !command.SysProcAttr.Setsid { + t.Fatalf("macOS background helpers must use a separate session, got %#v", command.SysProcAttr) + } + if command.SysProcAttr.Setpgid { + t.Fatalf("macOS background helpers must not combine setsid and setpgid, got %#v", command.SysProcAttr) } prepareVisibleCommand(command) if command.SysProcAttr != nil { - t.Fatalf("visible macOS helpers must remain in the foreground process group, got %#v", command.SysProcAttr) + t.Fatalf("visible macOS helpers must remain in the terminal session, got %#v", command.SysProcAttr) } } From c8c25f83b87d113fd54e041dad3ae7e76ac895b3 Mon Sep 17 00:00:00 2001 From: larry foulkrod Date: Thu, 13 Aug 2026 19:17:37 -0500 Subject: [PATCH 2/3] Poll Podman through macOS service socket --- engine/podman.go | 10 ++- engine/podman_service.go | 135 ++++++++++++++++++++++++++++++++ engine/podman_service_darwin.go | 21 +++++ engine/podman_service_other.go | 7 ++ engine/podman_service_test.go | 77 ++++++++++++++++++ 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 engine/podman_service.go create mode 100644 engine/podman_service_darwin.go create mode 100644 engine/podman_service_other.go create mode 100644 engine/podman_service_test.go diff --git a/engine/podman.go b/engine/podman.go index 559bc07..e621b6c 100644 --- a/engine/podman.go +++ b/engine/podman.go @@ -332,6 +332,10 @@ func buildPodmanRunArgs(opts RunOptions, windowsHostAddress ...string) []string // ContainerStats returns live CPU and memory stats for a running container. func (e *PodmanEngine) ContainerStats(name string) (cpu string, cpuPct float64, ram, ramTotal string, ramPct float64, err error) { + if service, ok := hostPodmanServiceClient(); ok { + return service.containerStats(name) + } + cmd := buildCmd("podman", "stats", "--no-stream", "--format", "{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}", name) out, runErr := cmd.Output() @@ -370,8 +374,12 @@ func (e *PodmanEngine) ContainerStats(name string) (cpu string, cpuPct float64, } // ContainerInspect returns status and metadata about a container in one -// Podman process so live dashboards do not need a separate status probe. +// request so live dashboards do not need a separate status probe. func (e *PodmanEngine) ContainerInspect(name string) (InspectData, error) { + if service, ok := hostPodmanServiceClient(); ok { + return service.containerInspect(name) + } + format := `{{.State.Status}}|{{.State.StartedAt}}|{{.Created}}|{{.RestartCount}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}` cmd := buildCmd("podman", "inspect", "--format", format, name) out, err := cmd.Output() diff --git a/engine/podman_service.go b/engine/podman_service.go new file mode 100644 index 0000000..90a57bd --- /dev/null +++ b/engine/podman_service.go @@ -0,0 +1,135 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +const podmanServiceRequestTimeout = 2500 * time.Millisecond + +type podmanServiceClient struct { + client *http.Client + baseURL string +} + +func newUnixPodmanServiceClient(socketPath string) *podmanServiceClient { + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", socketPath) + }, + } + return &podmanServiceClient{ + client: &http.Client{Transport: transport, Timeout: podmanServiceRequestTimeout}, + baseURL: "http://podman", + } +} + +func (c *podmanServiceClient) get(path string, target any) error { + request, err := http.NewRequestWithContext(processCtx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return fmt.Errorf("creating Podman service request: %w", err) + } + response, err := c.client.Do(request) + if err != nil { + return fmt.Errorf("contacting Podman service: %w", err) + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + detail, _ := io.ReadAll(io.LimitReader(response.Body, maxCommandOutput)) + detailText := strings.TrimSpace(string(detail)) + if detailText == "" { + detailText = response.Status + } + return fmt.Errorf("Podman service returned %s: %s", response.Status, detailText) + } + if err := json.NewDecoder(response.Body).Decode(target); err != nil { + return fmt.Errorf("decoding Podman service response: %w", err) + } + return nil +} + +type podmanServiceInspect struct { + Created string `json:"Created"` + RestartCount int `json:"RestartCount"` + State struct { + Status string `json:"Status"` + StartedAt string `json:"StartedAt"` + Health *struct { + Status string `json:"Status"` + } `json:"Health"` + } `json:"State"` +} + +func (c *podmanServiceClient) containerInspect(name string) (InspectData, error) { + var response podmanServiceInspect + path := "/containers/" + url.PathEscape(name) + "/json" + if err := c.get(path, &response); err != nil { + return InspectData{}, fmt.Errorf("podman inspect: %w", err) + } + inspect := InspectData{ + Status: response.State.Status, + RestartCount: response.RestartCount, + } + inspect.CreatedAt, _ = time.Parse(time.RFC3339Nano, response.Created) + inspect.StartedAt, _ = time.Parse(time.RFC3339Nano, response.State.StartedAt) + if response.State.Health != nil && response.State.Health.Status != "none" { + inspect.HealthStatus = response.State.Health.Status + } + return inspect, nil +} + +type podmanServiceStatsReport struct { + Stats []struct { + CPU float64 `json:"CPU"` + MemUsage uint64 `json:"MemUsage"` + MemLimit uint64 `json:"MemLimit"` + MemPerc float64 `json:"MemPerc"` + } `json:"Stats"` +} + +func (c *podmanServiceClient) containerStats(name string) (cpu string, cpuPct float64, ram, ramTotal string, ramPct float64, err error) { + query := url.Values{ + "containers": {name}, + "stream": {"false"}, + } + var response podmanServiceStatsReport + if err := c.get("/v1.0.0/libpod/containers/stats?"+query.Encode(), &response); err != nil { + return "", 0, "", "", 0, fmt.Errorf("podman stats: %w", err) + } + if len(response.Stats) == 0 { + return "", 0, "", "", 0, fmt.Errorf("podman stats: Podman service returned no container statistics") + } + stats := response.Stats[0] + return formatServicePercent(stats.CPU), stats.CPU / 100, + formatServiceBytes(stats.MemUsage), formatServiceBytes(stats.MemLimit), stats.MemPerc / 100, nil +} + +func formatServicePercent(value float64) string { + return fmt.Sprintf("%.2f%%", value) +} + +func formatServiceBytes(value uint64) string { + const unit = uint64(1024) + if value < unit { + return fmt.Sprintf("%dB", value) + } + units := [...]string{"KiB", "MiB", "GiB", "TiB", "PiB"} + amount := float64(value) + for _, suffix := range units { + amount /= float64(unit) + if amount < float64(unit) || suffix == units[len(units)-1] { + formatted := fmt.Sprintf("%.1f", amount) + formatted = strings.TrimSuffix(formatted, ".0") + return formatted + suffix + } + } + return fmt.Sprintf("%dB", value) +} diff --git a/engine/podman_service_darwin.go b/engine/podman_service_darwin.go new file mode 100644 index 0000000..5b3638e --- /dev/null +++ b/engine/podman_service_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin + +package engine + +import ( + "os" + "path/filepath" + "sync" +) + +var darwinPodmanServiceClient = sync.OnceValue(func() *podmanServiceClient { + socket := filepath.Join(os.TempDir(), "podman", OmnideckMachineName+"-api.sock") + return newUnixPodmanServiceClient(socket) +}) + +// hostPodmanServiceClient keeps periodic dashboard reads inside this process. +// Terminal.app tracks short-lived descendants even when they have their own +// session, so launching `podman` every poll still changes the terminal title. +func hostPodmanServiceClient() (*podmanServiceClient, bool) { + return darwinPodmanServiceClient(), true +} diff --git a/engine/podman_service_other.go b/engine/podman_service_other.go new file mode 100644 index 0000000..b864b3a --- /dev/null +++ b/engine/podman_service_other.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package engine + +func hostPodmanServiceClient() (*podmanServiceClient, bool) { + return nil, false +} diff --git a/engine/podman_service_test.go b/engine/podman_service_test.go new file mode 100644 index 0000000..9e60ee7 --- /dev/null +++ b/engine/podman_service_test.go @@ -0,0 +1,77 @@ +package engine + +import ( + "fmt" + "math" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestPodmanServiceContainerInspect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/containers/omnideck/json" { + t.Fatalf("request path = %q", r.URL.Path) + } + fmt.Fprint(w, `{ + "Created":"2026-08-13T20:00:00.123456789Z", + "RestartCount":2, + "State":{ + "Status":"running", + "StartedAt":"2026-08-13T20:01:00.987654321Z", + "Health":{"Status":"healthy"} + } + }`) + })) + defer server.Close() + + client := &podmanServiceClient{client: server.Client(), baseURL: server.URL} + inspect, err := client.containerInspect("omnideck") + if err != nil { + t.Fatal(err) + } + if inspect.Status != "running" || inspect.RestartCount != 2 || inspect.HealthStatus != "healthy" { + t.Fatalf("unexpected inspect data: %+v", inspect) + } + if want := time.Date(2026, 8, 13, 20, 0, 0, 123456789, time.UTC); !inspect.CreatedAt.Equal(want) { + t.Fatalf("created = %s, want %s", inspect.CreatedAt, want) + } + if want := time.Date(2026, 8, 13, 20, 1, 0, 987654321, time.UTC); !inspect.StartedAt.Equal(want) { + t.Fatalf("started = %s, want %s", inspect.StartedAt, want) + } +} + +func TestPodmanServiceContainerStats(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1.0.0/libpod/containers/stats" { + t.Fatalf("request path = %q", r.URL.Path) + } + if r.URL.Query().Get("containers") != "omnideck" || r.URL.Query().Get("stream") != "false" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + fmt.Fprint(w, `{"Error":null,"Stats":[{"CPU":0.1800404,"MemUsage":112783360,"MemLimit":2147483648,"MemPerc":5.251884}]}`) + })) + defer server.Close() + + client := &podmanServiceClient{client: server.Client(), baseURL: server.URL} + cpu, cpuPct, ram, ramTotal, ramPct, err := client.containerStats("omnideck") + if err != nil { + t.Fatal(err) + } + if cpu != "0.18%" || math.Abs(cpuPct-0.001800404) > 1e-12 || ram != "107.6MiB" || ramTotal != "2GiB" || math.Abs(ramPct-0.05251884) > 1e-12 { + t.Fatalf("unexpected stats: cpu=%q cpuPct=%v ram=%q total=%q ramPct=%v", cpu, cpuPct, ram, ramTotal, ramPct) + } +} + +func TestPodmanServiceReportsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"cause":"no such container"}`, http.StatusNotFound) + })) + defer server.Close() + + client := &podmanServiceClient{client: server.Client(), baseURL: server.URL} + if _, err := client.containerInspect("missing"); err == nil { + t.Fatal("expected service error") + } +} From 53901ec3e552a72f3d12e29f419a9a3acce99d83 Mon Sep 17 00:00:00 2001 From: larry foulkrod Date: Thu, 13 Aug 2026 19:27:59 -0500 Subject: [PATCH 3/3] Keep Podman socket setup Darwin-only --- engine/podman_service.go | 17 ----------------- engine/podman_service_darwin.go | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/engine/podman_service.go b/engine/podman_service.go index 90a57bd..4d8de99 100644 --- a/engine/podman_service.go +++ b/engine/podman_service.go @@ -1,37 +1,20 @@ package engine import ( - "context" "encoding/json" "fmt" "io" - "net" "net/http" "net/url" "strings" "time" ) -const podmanServiceRequestTimeout = 2500 * time.Millisecond - type podmanServiceClient struct { client *http.Client baseURL string } -func newUnixPodmanServiceClient(socketPath string) *podmanServiceClient { - transport := &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - var dialer net.Dialer - return dialer.DialContext(ctx, "unix", socketPath) - }, - } - return &podmanServiceClient{ - client: &http.Client{Transport: transport, Timeout: podmanServiceRequestTimeout}, - baseURL: "http://podman", - } -} - func (c *podmanServiceClient) get(path string, target any) error { request, err := http.NewRequestWithContext(processCtx, http.MethodGet, c.baseURL+path, nil) if err != nil { diff --git a/engine/podman_service_darwin.go b/engine/podman_service_darwin.go index 5b3638e..25624f0 100644 --- a/engine/podman_service_darwin.go +++ b/engine/podman_service_darwin.go @@ -3,11 +3,30 @@ package engine import ( + "context" + "net" + "net/http" "os" "path/filepath" "sync" + "time" ) +const podmanServiceRequestTimeout = 2500 * time.Millisecond + +func newUnixPodmanServiceClient(socketPath string) *podmanServiceClient { + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", socketPath) + }, + } + return &podmanServiceClient{ + client: &http.Client{Transport: transport, Timeout: podmanServiceRequestTimeout}, + baseURL: "http://podman", + } +} + var darwinPodmanServiceClient = sync.OnceValue(func() *podmanServiceClient { socket := filepath.Join(os.TempDir(), "podman", OmnideckMachineName+"-api.sock") return newUnixPodmanServiceClient(socket)