You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/04 13:15:59 UTC
(casbin-gateway) 01/02: perf(agents): read process command lines without spawning PowerShell
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 3d30263cd62d5698fb3efd90e225f4ef2f63583f
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Sep 4 21:06:55 2026 +0800
perf(agents): read process command lines without spawning PowerShell
---
agentprocess/list_windows.go | 95 ++++++++++++++++++++++---------------
util/daemon.go | 24 ++++++++--
web/src/locales/en/data.json | 3 ++
web/src/locales/zh/data.json | 3 ++
web/src/pages/AgentRecordsPage.tsx | 2 +-
web/src/pages/AgentSessionsPage.tsx | 2 +-
web/src/pages/AgentVersionsPage.tsx | 2 +-
web/src/pages/AgentsPage.tsx | 2 +-
web/src/pages/LlmRecordsPage.tsx | 2 +-
web/src/pages/ProviderListPage.tsx | 2 +-
10 files changed, 89 insertions(+), 48 deletions(-)
diff --git a/agentprocess/list_windows.go b/agentprocess/list_windows.go
index a0a1265..4b93d50 100644
--- a/agentprocess/list_windows.go
+++ b/agentprocess/list_windows.go
@@ -18,27 +18,20 @@ package agentprocess
import (
"context"
- "encoding/json"
- "os/exec"
- "strings"
- "syscall"
"unsafe"
"golang.org/x/sys/windows"
)
-// commandScript reads what the process was started with, which is the only way
-// an agent running under an interpreter names itself. WMI answers this in about
-// a second, so it is asked for only when an image path cannot settle it.
-const commandScript = `Get-CimInstance Win32_Process | ForEach-Object { [PSCustomObject]@{ pid = $_.ProcessId; parent = $_.ParentProcessId; path = $_.ExecutablePath; command = $_.CommandLine } } | ConvertTo-Json -Compress`
-
-func list(ctx context.Context, withCommands bool) []Process {
- if withCommands {
- if result := listWithCommands(ctx); len(result) > 0 {
- return result
- }
+func list(_ context.Context, withCommands bool) []Process {
+ processes := listImages()
+ if !withCommands {
+ return processes
}
- return listImages()
+ for i := range processes {
+ processes[i].Command = commandLine(uint32(processes[i].Pid))
+ }
+ return processes
}
// listImages walks the process table for image paths alone, which the OS
@@ -88,35 +81,61 @@ func imagePath(pid uint32) string {
return windows.UTF16ToString(buffer[:size])
}
-func listWithCommands(ctx context.Context) []Process {
- cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", commandScript)
- cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
- output, err := cmd.Output()
+// commandLine reads what a process was started with, which is the only way an
+// agent running under an interpreter names itself. It comes out of the
+// process's own parameter block: asking WMI for the whole table instead costs
+// about three seconds on a host with a thousand processes, and that is the
+// wait the agents page used to open with.
+//
+// An empty answer is normal — a process this one may not open, or a 32-bit one
+// whose parameter block is laid out differently — and means the installation is
+// recognised by its image path alone, as it is when commands are not read.
+func commandLine(pid uint32) string {
+ handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err != nil {
- return nil
+ return ""
}
+ defer windows.CloseHandle(handle)
- body := strings.TrimSpace(string(output))
- // ConvertTo-Json writes a bare object when only one process came back.
- if strings.HasPrefix(body, "{") {
- body = "[" + body + "]"
+ var wow64 bool
+ if windows.IsWow64Process(handle, &wow64) != nil || wow64 {
+ return ""
}
- var rows []struct {
- Pid int `json:"pid"`
- Parent int `json:"parent"`
- Path string `json:"path"`
- Command string `json:"command"`
+
+ var info windows.PROCESS_BASIC_INFORMATION
+ if windows.NtQueryInformationProcess(handle, windows.ProcessBasicInformation,
+ unsafe.Pointer(&info), uint32(unsafe.Sizeof(info)), nil) != nil {
+ return ""
}
- if json.Unmarshal([]byte(body), &rows) != nil {
- return nil
+
+ var peb windows.PEB
+ if !readMemory(handle, uintptr(unsafe.Pointer(info.PebBaseAddress)), unsafe.Pointer(&peb), unsafe.Sizeof(peb)) {
+ return ""
+ }
+ var parameters windows.RTL_USER_PROCESS_PARAMETERS
+ if !readMemory(handle, uintptr(unsafe.Pointer(peb.ProcessParameters)), unsafe.Pointer(¶meters), unsafe.Sizeof(parameters)) {
+ return ""
}
- result := make([]Process, 0, len(rows))
- for _, row := range rows {
- if row.Pid <= 0 {
- continue
- }
- result = append(result, Process{Pid: row.Pid, Parent: row.Parent, Path: row.Path, Command: row.Command})
+ // Length is a uint16, so it bounds the read on its own.
+ length := parameters.CommandLine.Length
+ if length == 0 {
+ return ""
+ }
+ buffer := make([]uint16, length/2)
+ if !readMemory(handle, uintptr(unsafe.Pointer(parameters.CommandLine.Buffer)), unsafe.Pointer(&buffer[0]), uintptr(length)) {
+ return ""
+ }
+ return windows.UTF16ToString(buffer)
+}
+
+func readMemory(handle windows.Handle, address uintptr, into unsafe.Pointer, size uintptr) bool {
+ if address == 0 {
+ return false
+ }
+ var read uintptr
+ if windows.ReadProcessMemory(handle, address, (*byte)(into), size, &read) != nil {
+ return false
}
- return result
+ return read == size
}
diff --git a/util/daemon.go b/util/daemon.go
index 513a700..2ef07f2 100644
--- a/util/daemon.go
+++ b/util/daemon.go
@@ -28,8 +28,10 @@ const (
// EnvDaemonChildKey marks the copy started by "start", so it serves instead
// of spawning another one.
EnvDaemonChildKey = "CASBIN_GATEWAY_DAEMON"
- // daemonStartTimeout bounds the wait for the detached copy to answer.
- daemonStartTimeout = 30 * time.Second
+ // daemonStartTimeout bounds the wait for the detached copy to answer. A
+ // first start creates the database and scans the disk for agents, so it is
+ // generous; running out of it is not reported as a failure.
+ daemonStartTimeout = 90 * time.Second
daemonPollInterval = 200 * time.Millisecond
)
@@ -89,6 +91,14 @@ func startDetached(port int, logPath string) {
os.Exit(1)
}
+ // A start that has not finished is not a start that failed, so the two are
+ // told apart by whether the copy is still there.
+ exited := make(chan struct{})
+ go func() {
+ _, _ = process.Wait()
+ close(exited)
+ }()
+
deadline := time.Now().Add(daemonStartTimeout)
for time.Now().Before(deadline) {
if isServing(port) {
@@ -96,11 +106,17 @@ func startDetached(port int, logPath string) {
fmt.Printf(" Log: %s Stop it with: casbin-gateway stop\n", logPath)
return
}
+ select {
+ case <-exited:
+ fmt.Printf("Casbin Gateway stopped while starting up. See %s\n", logPath)
+ os.Exit(1)
+ default:
+ }
time.Sleep(daemonPollInterval)
}
- fmt.Printf("Casbin Gateway did not answer on port %d within %v. See %s\n", port, daemonStartTimeout, logPath)
- os.Exit(1)
+ fmt.Printf("Casbin Gateway is still starting on port %d after %v (pid %d).\n", port, daemonStartTimeout, process.Pid)
+ fmt.Printf(" Log: %s Check it with: casbin-gateway status\n", logPath)
}
// StartDetached launches the executable at path with no terminal attached,
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index ab02c9d..b84fd5d 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -26,6 +26,7 @@
"Advanced view hint": "Every installation found, with its processes, path, owner and install method.",
"Agent": "Agent",
"Agent Records": "Activity",
+ "Agent Session": "Session",
"Agent Sessions": "Sessions",
"Agent Sessions detail": "Every conversation your agents have had, from monitoring and from the transcripts they write themselves",
"Agent configuration": "Agent configuration",
@@ -228,6 +229,7 @@
"Read from the agent's own logs": "Read from the agent's own logs",
"Read many files at once": "Read many files at once",
"Reading the project": "Reading the project",
+ "Record": "Record",
"Records": "Records",
"Records are stored in the database and survive a Gateway restart": "Records are stored in the database and survive a Gateway restart",
"Remember something": "Remember something",
@@ -833,6 +835,7 @@
"Path": "Path",
"Previous": "Previous",
"Published": "Published",
+ "Record": "Record",
"Records": "Records",
"Refresh": "Refresh",
"Release notes": "Release notes",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 390f3ba..63d04ac 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -26,6 +26,7 @@
"Advanced view hint": "扫描到的每个安装,含进程、路径、所有者和安装方式。",
"Agent": "智能体",
"Agent Records": "活动记录",
+ "Agent Session": "会话",
"Agent Sessions": "会话",
"Agent Sessions detail": "Agent 的每一次会话,既来自监控,也来自它们自己写下的记录",
"Agent configuration": "Agent 配置文件",
@@ -228,6 +229,7 @@
"Read from the agent's own logs": "读自 agent 自己的日志",
"Read many files at once": "一次读取多个文件",
"Reading the project": "读取项目",
+ "Record": "记录数",
"Records": "记录数",
"Records are stored in the database and survive a Gateway restart": "记录保存在数据库中,Gateway 重启后依然存在",
"Remember something": "记住信息",
@@ -905,6 +907,7 @@
"Path": "路径",
"Previous": "上一页",
"Published": "发布于",
+ "Record": "日志",
"Records": "日志",
"Refresh": "刷新",
"Release notes": "发布说明",
diff --git a/web/src/pages/AgentRecordsPage.tsx b/web/src/pages/AgentRecordsPage.tsx
index 7ba1d1a..0afa898 100644
--- a/web/src/pages/AgentRecordsPage.tsx
+++ b/web/src/pages/AgentRecordsPage.tsx
@@ -348,7 +348,7 @@ export default function AgentRecordsPage({account}: {account: Account}) {
<DataTable
title={i18next.t("agent:Agent Records")}
- description={`${records.length} ${i18next.t("agent:Records")}`}
+ description={records.length === 1 ? `${records.length} ${i18next.t("agent:Record")}` : `${records.length} ${i18next.t("agent:Records")}`}
columns={columns}
dataSource={records}
rowKey={record => String(record.id)}
diff --git a/web/src/pages/AgentSessionsPage.tsx b/web/src/pages/AgentSessionsPage.tsx
index d7f12c4..c3cdbef 100644
--- a/web/src/pages/AgentSessionsPage.tsx
+++ b/web/src/pages/AgentSessionsPage.tsx
@@ -361,7 +361,7 @@ export default function AgentSessionsPage({account}: {account: Account}) {
<DataTable
description={
rows.length === ordered.length
- ? `${ordered.length.toLocaleString()} ${i18next.t("agent:Agent Sessions")}`
+ ? (ordered.length === 1 ? `${ordered.length} ${i18next.t("agent:Agent Session")}` : `${ordered.length.toLocaleString()} ${i18next.t("agent:Agent Sessions")}`)
: `${rows.length.toLocaleString()} / ${ordered.length.toLocaleString()} ${i18next.t("agent:Agent Sessions")}`
}
columns={columns}
diff --git a/web/src/pages/AgentVersionsPage.tsx b/web/src/pages/AgentVersionsPage.tsx
index d43f442..b76e0be 100644
--- a/web/src/pages/AgentVersionsPage.tsx
+++ b/web/src/pages/AgentVersionsPage.tsx
@@ -237,7 +237,7 @@ export default function AgentVersionsPage({account}: {account: Account}) {
<DataTable
title={i18next.t("agent:Agents")}
- description={`${rows.length} ${i18next.t("agent:Agents")}`}
+ description={rows.length === 1 ? `${rows.length} ${i18next.t("agent:Agent")}` : `${rows.length} ${i18next.t("agent:Agents")}`}
columns={columns}
dataSource={rows}
rowKey="key"
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index eb20c51..485ba13 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -285,7 +285,7 @@ export default function AgentsPage({account}: {account: Account}) {
<DataTable
title={i18next.t("agent:Agents")}
- description={`${agents.length} ${i18next.t("agent:Agents")}`}
+ description={agents.length === 1 ? `${agents.length} ${i18next.t("agent:Agent")}` : `${agents.length} ${i18next.t("agent:Agents")}`}
columns={columns}
dataSource={agents}
rowKey={agentKey}
diff --git a/web/src/pages/LlmRecordsPage.tsx b/web/src/pages/LlmRecordsPage.tsx
index 38a6e79..d921b6b 100644
--- a/web/src/pages/LlmRecordsPage.tsx
+++ b/web/src/pages/LlmRecordsPage.tsx
@@ -737,7 +737,7 @@ export default function LlmRecordsPage({account}: {account: Account}) {
<DataTable
title={i18next.t("llm:LLM Records")}
- description={`${total.toLocaleString()} ${i18next.t("general:Records")}`}
+ description={total === 1 ? `${total} ${i18next.t("general:Record")}` : `${total.toLocaleString()} ${i18next.t("general:Records")}`}
columns={columns}
dataSource={records}
rowKey={record => String(record.id)}
diff --git a/web/src/pages/ProviderListPage.tsx b/web/src/pages/ProviderListPage.tsx
index 621979d..f6b2584 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -453,7 +453,7 @@ export default function ProviderListPage({account}: {account: Account}) {
{error !== "" ? <MessageAlert title={i18next.t("provider:Failed to get providers")} description={error} /> : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-sm">
- {`${total} ${i18next.t("provider:Providers")}`}
+ {total === 1 ? `${total} ${i18next.t("provider:Provider")}` : `${total} ${i18next.t("provider:Providers")}`}
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => loadQuotas(true)} loading={refreshingQuotas}>
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org