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 7ebc454819b0470f8781bfa13af9a4d7a652170a
Author: Yang Luo <[email protected]>
AuthorDate: Sat Sep 12 13:45:09 2026 +0800

    fix(install): stop the running Gateway before replacing it
---
 desktop/gateway.go  | 25 +++++++++++++++++++++
 desktop/instance.go | 60 ++++++++++++++++++++++++++++++++++++++++++-------
 desktop/main.go     | 11 +++++++++
 scripts/install.ps1 | 65 +++++++++++++++++++++++++++++++++++++++++++++++------
 scripts/install.sh  | 23 +++++++++++++++++--
 5 files changed, 167 insertions(+), 17 deletions(-)

diff --git a/desktop/gateway.go b/desktop/gateway.go
index 2876790..4809b65 100644
--- a/desktop/gateway.go
+++ b/desktop/gateway.go
@@ -184,3 +184,28 @@ func stopServer() error {
        }
        return nil
 }
+
+// quitRunning stops the whole of a Gateway that is running out of this home:
+// the tray and its window through the socket they listen on, and the server
+// behind them whether or not the tray was what started it. It reports whether
+// there was anything to stop.
+//
+// This is what an installer calls before it replaces the executables. Windows
+// will not write over one that is running, and everywhere else an install that
+// left the old process serving would look like an update that did nothing.
+func quitRunning() bool {
+       port := instancePort()
+       stopped := askInstance(quitRequest)
+       if stopped {
+               waitForInstanceGone(port)
+       }
+
+       if !isServing(httpPort()) {
+               return stopped
+       }
+       if err := stopServer(); err != nil {
+               fmt.Fprintln(os.Stderr, "casbin-gateway-desktop: could not stop 
the Gateway server:", err)
+               return stopped
+       }
+       return true
+}
diff --git a/desktop/instance.go b/desktop/instance.go
index c237c61..0856838 100644
--- a/desktop/instance.go
+++ b/desktop/instance.go
@@ -28,10 +28,15 @@ import (
 
 const (
        showRequest = "show"
+       quitRequest = "quit"
        // restartRequest is the updated server saying the launcher was 
replaced too.
        restartRequest = "restart"
-       showReply      = "ok"
+       okReply        = "ok"
        instanceWait   = 2 * time.Second
+       // quitWait bounds the wait for a tray that agreed to go: it answers 
first
+       // and lets go of the socket a moment later.
+       quitWait = 15 * time.Second
+       quitPoll = 100 * time.Millisecond
 )
 
 // instanceMarker records the loopback port the running tray listens on. The
@@ -47,12 +52,27 @@ func instanceMarker() string {
 // what would have been a second tray icon and a second window becomes the one
 // that is there, raised. It reports whether a tray took the launch.
 func raiseRunningInstance() bool {
+       return askInstance(showRequest)
+}
+
+// instancePort is where the running tray listens, or 0 when nothing names it.
+func instancePort() int {
        data, err := os.ReadFile(instanceMarker())
        if err != nil {
-               return false
+               return 0
        }
        port, err := strconv.Atoi(strings.TrimSpace(string(data)))
        if err != nil || port <= 0 {
+               return 0
+       }
+       return port
+}
+
+// askInstance sends one request to the tray that is already running and 
reports
+// whether it took it.
+func askInstance(request string) bool {
+       port := instancePort()
+       if port == 0 {
                return false
        }
 
@@ -65,14 +85,34 @@ func raiseRunningInstance() bool {
        defer conn.Close()
 
        _ = conn.SetDeadline(time.Now().Add(instanceWait))
-       if _, err = io.WriteString(conn, showRequest+"\n"); err != nil {
+       if _, err = io.WriteString(conn, request+"\n"); err != nil {
                return false
        }
 
-       // Whatever holds that port now may not be a Gateway tray, so the 
launch is
+       // Whatever holds that port now may not be a Gateway tray, so the 
request is
        // only given up once one has answered.
        reply, err := bufio.NewReader(conn).ReadString('\n')
-       return err == nil && strings.TrimSpace(reply) == showReply
+       return err == nil && strings.TrimSpace(reply) == okReply
+}
+
+// waitForInstanceGone blocks until nothing answers on the port the tray was
+// listening on, which is the process itself going away rather than the marker
+// it deletes on the way out. An installer replacing the executable that 
process
+// runs from has to wait for exactly that.
+func waitForInstanceGone(port int) {
+       if port == 0 {
+               return
+       }
+
+       deadline := time.Now().Add(quitWait)
+       for time.Now().Before(deadline) {
+               conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", 
port), instanceWait)
+               if err != nil {
+                       return
+               }
+               conn.Close()
+               time.Sleep(quitPoll)
+       }
 }
 
 // heldPort is kept so a restart that could not start the new launcher can name
@@ -136,13 +176,17 @@ func serveInstanceRequest(conn net.Conn) {
        }
 
        // Answering first keeps whoever is waiting on this from outliving what 
it
-       // asked for.
+       // asked for - and, for a quit, from waiting on a process on its way 
out.
        switch strings.TrimSpace(line) {
        case showRequest:
-               _, _ = io.WriteString(conn, showReply+"\n")
+               _, _ = io.WriteString(conn, okReply+"\n")
                showWindow()
+       case quitRequest:
+               _, _ = io.WriteString(conn, okReply+"\n")
+               conn.Close()
+               quit()
        case restartRequest:
-               _, _ = io.WriteString(conn, showReply+"\n")
+               _, _ = io.WriteString(conn, okReply+"\n")
                conn.Close()
                restartLauncher()
        }
diff --git a/desktop/main.go b/desktop/main.go
index acebfcb..cd167b5 100644
--- a/desktop/main.go
+++ b/desktop/main.go
@@ -56,6 +56,17 @@ func main() {
                return
        }
 
+       // "quit" stops the Gateway that is running - window, tray and server - 
which
+       // is what the installers do before they replace the executables it runs
+       // from. It exits 2 when there was nothing running, so that an install 
which
+       // stopped one knows to start it again.
+       if len(os.Args) > 1 && os.Args[1] == "quit" {
+               if !quitRunning() {
+                       os.Exit(2)
+               }
+               return
+       }
+
        // "open-agent-link <url>" is what a URL scheme Gateway captured opens 
with.
        // The server routes the link; it is started from here because this
        // executable is windowed and flashes no console on the way.
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index c062032..c1e8819 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -9,7 +9,8 @@
 # Optional environment variables:
 #   INSTALL_DIR   where the executable and its data live
 #                 (default: $env:LOCALAPPDATA\casbin-gateway)
-#   NO_START      set to any value to install without starting Gateway
+#   NO_START      set to any value to install without starting Gateway; one 
that
+#                 was already running is stopped for the update and started 
again
 #   NO_AUTOSTART  set to any value to skip the login-time startup entry
 #   NO_SHORTCUT   set to any value to skip the desktop and Start menu shortcuts
 
@@ -30,6 +31,34 @@ $ProgressPreference = 'SilentlyContinue'
 
 function Write-Info { param([string]$Message) Write-Host $Message }
 
+# Windows will not write over an executable that is running. The Gateway this
+# install replaces is stopped first, but an agent can still hold one of its MCP
+# servers open, so a file that is locked anyway is renamed out of the way — 
that
+# Windows does allow — and the Gateway deletes the ".old" copy on its next 
start.
+function Install-Executable {
+    param([string]$Source, [string]$Destination)
+
+    try {
+        Copy-Item -Path $Source -Destination $Destination -Force
+        return
+    }
+    catch {
+        if (-not (Test-Path $Destination)) {
+            throw "cannot write $Destination ($_)"
+        }
+    }
+
+    $Name = Split-Path $Destination -Leaf
+    try {
+        Remove-Item -Path "$Destination.old" -Force -ErrorAction 
SilentlyContinue
+        Rename-Item -Path $Destination -NewName "$Name.old" -Force
+        Copy-Item -Path $Source -Destination $Destination -Force
+    }
+    catch {
+        throw "cannot replace $Name in $(Split-Path $Destination -Parent), 
something is still running it - close the agents that use Gateway and try again 
($_)"
+    }
+}
+
 # ── pick the archive for this machine 
─────────────────────────────────────────
 # Only the x86_64 archive is published; Windows on ARM runs it under the
 # built-in x64 emulation.
@@ -62,15 +91,34 @@ try {
     New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
 
     $DesktopExePath = Join-Path $InstallDir 'casbin-gateway-desktop.exe'
-    foreach ($executable in @('casbin-gateway.exe', 
'casbin-gateway-desktop.exe')) {
+
+    # ── stop the Gateway that is running 
──────────────────────────────────────
+    # Without this the install failed outright: Windows holds the running
+    # executables open, so there was no way to update without quitting Gateway
+    # from its tray icon first. The launcher from the archive is what stops it,
+    # because an installation old enough to have no "quit" command cannot stop
+    # itself, and because it runs from the temporary directory rather than from
+    # the files about to be replaced. CASBIN_GATEWAY_HOME points it at the
+    # installation instead of at the directory it was unpacked into.
+    $WasRunning = $false
+    if (Test-Path (Join-Path $InstallDir 'casbin-gateway.exe')) {
+        $env:CASBIN_GATEWAY_HOME = $InstallDir
         try {
-            Copy-Item -Path (Join-Path $Unpacked $executable) -Destination 
(Join-Path $InstallDir $executable) -Force
+            $quit = Start-Process -FilePath (Join-Path $Unpacked 
'casbin-gateway-desktop.exe') `
+                -ArgumentList 'quit' -WorkingDirectory $InstallDir -Wait 
-PassThru
+            $WasRunning = $quit.ExitCode -eq 0
         }
         catch {
-            # Windows locks a running executable, so this is what an upgrade 
over
-            # a started Gateway looks like.
-            throw "cannot replace $executable in $InstallDir, quit Casbin 
Gateway from its tray icon and try again ($_)"
+            Write-Info "Could not stop the running Casbin Gateway: $_"
         }
+        Remove-Item Env:\CASBIN_GATEWAY_HOME -ErrorAction SilentlyContinue
+        if ($WasRunning) {
+            Write-Info 'Stopped the running Gateway, and will start it again 
once it is updated'
+        }
+    }
+
+    foreach ($executable in @('casbin-gateway.exe', 
'casbin-gateway-desktop.exe')) {
+        Install-Executable -Source (Join-Path $Unpacked $executable) 
-Destination (Join-Path $InstallDir $executable)
     }
 
     foreach ($legalFile in @('LICENSE', 'NOTICE', 'DISCLAIMER')) {
@@ -149,7 +197,10 @@ Write-Info 'Closing its window leaves it running in the 
tray; quit it from there
 Write-Info 'Without the window: "casbin-gateway start", "casbin-gateway stop", 
"casbin-gateway status".'
 Write-Info ''
 
-if ($env:NO_START) {
+# A Gateway that was running when this started is started again even with
+# NO_START: the install is what stopped it, and leaving the machine without one
+# is not what "install without starting" asked for.
+if ($env:NO_START -and -not $WasRunning) {
     Write-Info 'Start it from the "Casbin Gateway" shortcut, or with: 
casbin-gateway start'
     return
 }
diff --git a/scripts/install.sh b/scripts/install.sh
index 9436e49..0dd91e1 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -12,7 +12,8 @@
 #                 (default: $HOME/.local/share/casbin-gateway)
 #   BIN_DIR       where the "casbin-gateway" command is placed
 #                 (default: $HOME/.local/bin)
-#   NO_START      set to any value to install without starting Gateway
+#   NO_START      set to any value to install without starting Gateway; one 
that
+#                 was already running is stopped for the update and started 
again
 #   NO_AUTOSTART  set to any value to skip the login-time startup entry
 #   NO_SHORTCUT   set to any value to skip the macOS bundle or the desktop 
entry
 
@@ -82,6 +83,21 @@ rm -rf "${stage}"
 mkdir -p "${stage}"
 tar -xzf "${tmpDir}/${archive}" -C "${stage}" --strip-components=1
 
+# ── stop the Gateway that is running 
──────────────────────────────────────────
+# Without this an install over a running Gateway put the new executables in
+# place and left the old process serving, so nothing appeared to change until
+# that process was stopped by hand. The launcher from the archive is what stops
+# it, because an installation old enough to have no "quit" command cannot stop
+# itself. CASBIN_GATEWAY_HOME points that launcher at the installation rather
+# than at the staging directory it runs from.
+wasRunning=""
+chmod 755 "${stage}/casbin-gateway-desktop"
+if [[ -e "${INSTALL_DIR}/casbin-gateway" ]] &&
+       CASBIN_GATEWAY_HOME="${INSTALL_DIR}" "${stage}/casbin-gateway-desktop" 
quit; then
+       wasRunning="yes"
+       info "Stopped the running Gateway, and will start it again once it is 
updated"
+fi
+
 for executable in casbin-gateway casbin-gateway-desktop; do
        mv -f "${stage}/${executable}" "${INSTALL_DIR}/${executable}"
        chmod 755 "${INSTALL_DIR}/${executable}"
@@ -186,7 +202,10 @@ if [[ -n "${autostartNote}" ]]; then
 fi
 info ""
 
-if [[ -n "${NO_START}" ]]; then
+# A Gateway that was running when this started is started again even with
+# NO_START: the install is what stopped it, and leaving the machine without one
+# is not what "install without starting" asked for.
+if [[ -n "${NO_START}" && -z "${wasRunning}" ]]; then
        info "Start it from the Casbin Gateway application, or with: 
casbin-gateway start"
        exit 0
 fi


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to