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(&parameters), 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]

Reply via email to