diff --git a/internal/winexec/run_windows.go b/internal/winexec/run_windows.go index bb12790..c91b05a 100644 --- a/internal/winexec/run_windows.go +++ b/internal/winexec/run_windows.go @@ -19,12 +19,16 @@ func Run(ctx context.Context, script string) (string, error) { out, err := cmd.Output() if err != nil { - if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { - return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr))) - } + // Checked before the ExitError/stderr branch: CommandContext kills the + // process on timeout, and that kill can itself produce an ExitError + // carrying stderr text, so a genuine timeout would otherwise surface + // as that stderr instead of the "timed out" message callers match on. if ctx.Err() == context.DeadlineExceeded { return "", fmt.Errorf("powershell: timed out") } + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr))) + } return "", fmt.Errorf("powershell: %w", err) } return string(out), nil diff --git a/internal/workloads/services_windows.go b/internal/workloads/services_windows.go index c370326..b6790cc 100644 --- a/internal/workloads/services_windows.go +++ b/internal/workloads/services_windows.go @@ -12,8 +12,16 @@ const servicesTimeout = 60 * time.Second const servicesScript = ` $ErrorActionPreference = 'Stop' -$svcs = Get-CimInstance Win32_Service | - Select-Object Name,DisplayName,State,StartMode,PathName,ExitCode +$svcs = Get-CimInstance Win32_Service | ForEach-Object { + [pscustomobject]@{ + Name = $_.Name + DisplayName = $_.DisplayName + State = $_.State + StartMode = $_.StartMode + PathName = $_.PathName + ExitCode = $_.ExitCode + } +} ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress ` diff --git a/internal/workloads/winparse.go b/internal/workloads/winparse.go index c869f2f..5aaa20b 100644 --- a/internal/workloads/winparse.go +++ b/internal/workloads/winparse.go @@ -113,10 +113,15 @@ func parseServices(jsonText, systemRoot string) ([]Workload, error) { continue } - state := "stopped" + // The wire shape is shared with the systemd collector — both report + // under kind "unit" — so the state word has to be too, or the UI + // (which colours and filters on it, and does so before it knows + // which platform sent the row) needs two vocabularies for one kind. + // running/stopped/failed become active/inactive/failed to match. + state := "inactive" switch { case running: - state = "running" + state = "active" case failed: state = "failed" } @@ -189,7 +194,10 @@ func parseEvents(jsonText, serviceName, displayName string, tail int) (string, e continue } } - msg := strings.TrimSpace(strings.ReplaceAll(e.M, "\r\n", " ")) + // Collapse every newline form, not just "\r\n": a message containing a + // bare "\n" would otherwise still break the one-line-per-event shape + // this renders for the log dialog, and undercount the tail trim above. + msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M)) lines = append(lines, e.T+" "+e.L+" "+msg) } diff --git a/internal/workloads/winparse_test.go b/internal/workloads/winparse_test.go index e2605de..9106bea 100644 --- a/internal/workloads/winparse_test.go +++ b/internal/workloads/winparse_test.go @@ -59,12 +59,12 @@ func TestParseServicesFilters(t *testing.T) { t.Fatalf("got %d workloads, want 3: %+v", len(got), got) } - if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "running" { + if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "active" { t.Errorf("Contoso = %+v", w) } // Enabled but not running is exactly the row worth seeing. - if byID["Fabrikam"].State != "stopped" { - t.Errorf("Fabrikam state = %q, want stopped", byID["Fabrikam"].State) + if byID["Fabrikam"].State != "inactive" { + t.Errorf("Fabrikam state = %q, want inactive", byID["Fabrikam"].State) } // A non-zero exit code on a stopped service is a crash, not a clean stop. if byID["Crashed"].State != "failed" { @@ -80,15 +80,15 @@ func TestParseServicesExitCode1077(t *testing.T) { if err != nil { t.Fatalf("parseServices: %v", err) } - if len(got) != 1 || got[0].State != "stopped" { - t.Fatalf("got %+v, want one stopped workload", got) + if len(got) != 1 || got[0].State != "inactive" { + t.Fatalf("got %+v, want one inactive workload", got) } } func TestParseServicesSingleObjectAndEmpty(t *testing.T) { one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}` got, err := parseServices(one, `C:\WINDOWS`) - if err != nil || len(got) != 1 { + if err != nil || len(got) != 1 || got[0].State != "active" { t.Fatalf("single object: got %+v, err %v", got, err) } @@ -130,6 +130,25 @@ func TestParseEventsFormatsAndOrders(t *testing.T) { } } +// A message containing a bare "\n" (no carriage return) must still collapse to +// one line, or it silently multiplies into several output lines and throws +// off the tail trim's count. +func TestParseEventsCollapsesBareLF(t *testing.T) { + in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]` + + got, err := parseEvents(in, "Contoso", "Contoso Broker", 500) + if err != nil { + t.Fatalf("parseEvents: %v", err) + } + if strings.Count(got, "\n") != 0 { + t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got) + } + want := "2026-08-13T10:00:00Z Error broker died stack trace here" + if got != want { + t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want) + } +} + // Service Control Manager logs every service on the host under one provider, so // its rows must be filtered down to the target or the log is somebody else's. func TestParseEventsFiltersOtherServicesSCM(t *testing.T) {