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(&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: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org