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 <[email protected]> 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: [email protected] For additional commands, e-mail: [email protected]
