Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions backend/internal/handler/admin/system_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,18 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
}
succeeded = true

// Schedule platform restart after the response is sent.
// On Windows the process that just swapped the binary still runs the
// old image; waiting for a second /restart click can leave the old
// process alive forever (pre-fix builds no-op, or the user never
// clicks). Linux relies on systemd Restart=always after exit.
go func() {
time.Sleep(500 * time.Millisecond)
sysutil.RestartServiceAsync()
}()
Comment on lines +123 to +131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep system-operation exclusion active through the restart handoff.

The deferred release(..., succeeded=true) runs as each callback returns, while the restart waits another 500ms. A second update or rollback can therefore acquire the lock and begin before the first operation’s scheduled restart terminates it, risking an interrupted binary operation. Represent “restart pending” in the lock/status flow, or otherwise keep competing system operations excluded until handoff.

  • backend/internal/handler/admin/system_handler.go#L123-L131: prevent a new system operation from starting before the update-triggered restart begins.
  • backend/internal/handler/admin/system_handler.go#L202-L206: apply the same restart-pending exclusion to rollback.
📍 Affects 1 file
  • backend/internal/handler/admin/system_handler.go#L123-L131 (this comment)
  • backend/internal/handler/admin/system_handler.go#L202-L206
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/handler/admin/system_handler.go` around lines 123 - 131,
Keep system-operation exclusion active until the scheduled restart handoff
begins: update the release/lock flow around the update restart callback so the
500ms delay does not allow another operation to acquire the lock, and apply the
same restart-pending handling in the rollback restart callback. Modify both
affected sites in backend/internal/handler/admin/system_handler.go (lines
123-131 and 202-206), preserving normal release behavior for operations that do
not trigger a restart.


return gin.H{
"message": "Update completed. Please restart the service.",
"message": "Update completed. Service restart initiated.",
"need_restart": true,
"operation_id": lock.OperationID(),
}, nil
Expand Down Expand Up @@ -189,8 +199,14 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
}
succeeded = true

// Same as update: binary is on disk; load it without a manual stop/start.
go func() {
time.Sleep(500 * time.Millisecond)
sysutil.RestartServiceAsync()
}()

return gin.H{
"message": "Rollback completed. Please restart the service.",
"message": "Rollback completed. Service restart initiated.",
"need_restart": true,
"version": targetVersion,
"operation_id": lock.OperationID(),
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/handler/admin/system_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ func TestSystemHandlerRollbackWithVersionCallsRollbackToVersion(t *testing.T) {
var body systemUpdateResponseEnvelope
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, 0, body.Code)
require.Equal(t, "Rollback completed. Please restart the service.", body.Data.Message)
require.Equal(t, "Rollback completed. Service restart initiated.", body.Data.Message)
}

func TestSystemHandlerRollbackWithDisallowedVersionReturnsBadRequest(t *testing.T) {
Expand Down
20 changes: 14 additions & 6 deletions backend/internal/pkg/sysutil/restart_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,20 @@ func platformRestart() error {
}

// buildWindowsRestartScript waits for the current PID to exit, then starts the
// service again. Prefer native-control.ps1 (preserves env + log redirection +
// pid file); otherwise re-launch the binary in the same working directory.
// service again. Prefer native-control.ps1 restart-app (app-only, keeps DB/Redis);
// fall back to start; otherwise re-launch the binary in the same working directory.
func buildWindowsRestartScript(pid int, exe, workDir string) string {
exeDir := filepath.Dir(exe)
nativeControl := filepath.Join(exeDir, "native-control.ps1")

var relaunch string
if _, err := os.Stat(nativeControl); err == nil {
// Prefer restart-app (only cycles sub2api). Older scripts without that
// action fall back to start (safe when the old process has already exited).
relaunch = fmt.Sprintf(
`& %s start; if ($LASTEXITCODE -ne 0) { throw "native-control start failed: $LASTEXITCODE" }`,
`& %s restart-app; if ($LASTEXITCODE -ne 0) { & %s start }; `+
`if ($LASTEXITCODE -ne 0) { throw "native-control restart/start failed: $LASTEXITCODE" }`,
psQuote(nativeControl),
psQuote(nativeControl),
Comment on lines 81 to 85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect native-control action dispatch and its failure signaling; do not execute it.
fd -a '^native-control\.ps1$' . -0 |
  xargs -0 -r rg -n -C3 'restart-app|Unknown|throw|exit|ErrorActionPreference'

Repository: 706412584/sub2api

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)native-control\.ps1$|restart_windows\.go$|restart.*windows' || true

echo "== locate native-control =="
fd -a 'native-control|restart_windows' . -0 | sed 's#^\./##' | tr '\0' '\n'

echo "== outline restart_windows if present =="
f="$(fd 'restart_windows\.go' . | head -n 1)"
if [ -n "$f" ]; then
  echo "FILE $f"
  wc -l "$f"
  ast-grep outline "$f" || true
  sed -n '60,130p' "$f"
fi

echo "== search restart-app/native-control references =="
rg -n -C3 'restart-app|native-control|Restart-App|restartApp|Start-App|start-app|Start-Service|ErrorActionPreference' . --glob '!**/.git/**' || true

Repository: 706412584/sub2api

Length of output: 8223


🌐 Web query:

PowerShell $ErrorActionPreference Stop command exit code LASTEXITCODE throw behavior

💡 Result:

In PowerShell, $ErrorActionPreference = 'Stop' controls how non-terminating errors are handled, but it does not directly monitor or react to the $LASTEXITCODE of external commands [1][2]. Mechanism and Behavior: 1. $ErrorActionPreference = 'Stop' escalates non-terminating errors (cmdlet-based) into terminating errors [1][3][4]. 2. This preference does not affect external program exit codes by default [2][5]. Even if set to 'Stop', a failing external command will simply set $LASTEXITCODE to a non-zero value without throwing an exception or aborting the script [2]. 3. To trigger an error in response to a non-zero $LASTEXITCODE, you must manually check the variable and handle it, typically by using 'throw' [2][5]. 4. An exception to this is in PowerShell 7.4+, where you can set $PSNativeCommandUseErrorActionPreference = $true [2]. When enabled, non-zero exit codes from external programs generate a PowerShell error, which is then governed by your $ErrorActionPreference [2]. If $ErrorActionPreference is set to 'Stop' in this scenario, the script will abort upon an external command failure [2]. Throw Behavior: - The 'throw' keyword generates a script-terminating error by default, unwinding the call stack unless caught by a try/catch or trap statement [6]. - $ErrorActionPreference can suppress 'throw' if set to 'SilentlyContinue' or 'Ignore', though the error record is still captured in the $Error variable [6][7]. - If you encounter an external command failure, manually calling 'throw' is the standard way to bridge the gap between $LASTEXITCODE and the PowerShell try/catch error handling mechanism [2][5]. Summary of Interaction: - $ErrorActionPreference = 'Stop' applies to native PowerShell cmdlet errors [4][5]. - To handle external exit codes, check $LASTEXITCODE manually or use $PSNativeCommandUseErrorActionPreference = $true in PowerShell 7.4+ to integrate them into the standard error stream [2].

Citations:


🌐 Web query:

PowerShell 7.4 PSNativeCommandUseErrorActionPreference true error code exceptions

💡 Result:

In PowerShell 7.4, the preference variable $PSNativeCommandUseErrorActionPreference allows you to manage how native commands with non-zero exit codes are handled [1][2]. When set to $true, a non-zero exit code triggers the creation of a NativeCommandExitException, which is treated as a PowerShell error [3][4]. Key behaviors include: 1. Error Type: When $PSNativeCommandUseErrorActionPreference is $true, a non-zero exit code results in a non-terminating error by default [3][5]. 2. ErrorActionPreference Integration: This error respects the current $ErrorActionPreference [3][2]. If you set $ErrorActionPreference to Stop, the non-terminating error is promoted to a script-terminating error [3][5]. This allows you to catch the exception using standard try/catch blocks [3][6]. 3. Exception Details: The error is specifically represented by a NativeCommandExitException [3][4]. This exception contains details such as the path of the native command, the non-zero exit code returned, and the process ID [4]. 4. Scope Control: Because it is a preference variable, you can localize its effect by setting it within a script block [1][2]. This is particularly useful for native commands like robocopy that may return non-zero exit codes for informational purposes rather than errors [1]. Prior to PowerShell 7.4, this functionality was available as an experimental feature (PSNativeCommandErrorActionPreference) [7][6]. In 7.4, it became a mainstream, stable feature [7][8]. Example of handling native command errors with try/catch: $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true try { # Replace with a command that returns a non-zero exit code & "my-native-command.exe" } catch [System.Management.Automation.NativeCommandExitException] { Write-Host "Caught native command error: $($_.Exception.Message)" }

Citations:


🌐 Web query:

PowerShell $ErrorActionPreference Stop cmdlet error LastExitCode undefined command behavior

💡 Result:

In PowerShell, the behavior of an undefined command (a command not found by the shell) is determined by the shell's parsing process before it ever interacts with the $ErrorActionPreference variable or generates an exit code [1][2]. When you attempt to run an undefined command: 1. Command Parsing: PowerShell's engine attempts to resolve the command name. If it cannot find the command as a cmdlet, function, script, or executable, it throws a non-terminating error (or a statement-terminating error depending on context/host) indicating the command is not recognized. Because this occurs at the parsing or binding stage, $ErrorActionPreference = 'Stop' generally does not intercept or handle "command not found" errors in the way it handles errors emitted by executing cmdlets [1][2]. 2. $LASTEXITCODE Behavior: $LASTEXITCODE is specifically designed to store the exit code of the last Win32 executable that was executed [3][4]. If an attempt to run a command fails because the command itself is undefined (and thus no external process was ever successfully launched), $LASTEXITCODE will retain its previous value and will not be updated to reflect a failure [3][5]. It does not indicate success or failure for PowerShell internal operations [4][5]. 3. Success Tracking ($?): The automatic variable $? provides the most reliable way to check the success of the last operation [6][4]. If a command fails to execute because it is undefined, $? will be set to $False [6][4]. To reliably handle undefined commands or external process failures: - Use $? to check if the last operation succeeded [3][4]. - Do not rely on $LASTEXITCODE to detect if a command was found; only use it to check the exit code of an external process that successfully started [3][5]. - If you need to stop script execution when a command is missing, explicitly check $? after the command call and throw an error if it is $False [7]. Top results: [1], [3], [4], [5]

Citations:


🌐 Web query:

PowerShell try catch command not found $ErrorActionPreference Stop statement-terminating error

💡 Result:

In PowerShell, a CommandNotFoundException is classified as a statement-terminating error [1]. By default, statement-terminating errors stop the current statement from executing but do not necessarily terminate the entire script [2]. To ensure a CommandNotFoundException is caught by a try/catch block, you must escalate it to a script-terminating error [3][4]. 1. Using $ErrorActionPreference = 'Stop': When you set $ErrorActionPreference = 'Stop' within a scope, PowerShell elevates both non-terminating and statement-terminating errors to script-terminating errors [3][5]. This causes the error to propagate up the call stack, allowing the try/catch block to intercept it [3][6]. 2. Using -ErrorAction Stop: While the common parameter -ErrorAction Stop is frequently used to handle errors, its effect on command-not-found scenarios can vary depending on the command type [6]. For maximum robustness in ensuring a statement-terminating error is caught, wrapping the command in a try/catch block is the standard approach [4][7]. Example of catching a command not found error: try { # If the command is missing, this will trigger the catch block NonExistentCommand -ErrorAction Stop } catch [System.Management.Automation.CommandNotFoundException] { Write-Host "Caught the CommandNotFoundException: $($.Exception.Message)" } catch { Write-Host "Caught a different error: $($.Exception.Message)" } Key concepts: - Statement-terminating errors: Stop the specific command or statement but allow the script to continue by default [2]. - Script-terminating errors: Stop the execution of the entire script or call stack unless handled by try/catch or trap [2][3]. - $ErrorActionPreference = 'Stop': Promotes errors to script-terminating, ensuring they are catchable [3][5]. - Catching specific exceptions: You can catch specific error types, such as [System.Management.Automation.CommandNotFoundException], to handle them uniquely [7].

Citations:


🌐 Web query:

PowerShell command not found non-zero lastexitcode with $ErrorActionPreference Stop actual exit code

💡 Result:

When a command (external program) is not found in PowerShell, it typically triggers a CommandNotFoundException [1]. Whether this behavior interacts with $ErrorActionPreference and affects the final exit code depends on how you run the command and the PowerShell version [2][3][4]. To ensure robust error handling and proper exit code management, follow these guidelines: 1. Understanding CommandNotFound: When PowerShell fails to find an executable, it throws a non-terminating error by default [1]. If $ErrorActionPreference is set to 'Stop', this error is promoted to a script-terminating error [5][6]. 2. Using $PSNativeCommandUseErrorActionPreference (PowerShell 7.4+): For external programs, $ErrorActionPreference alone does not natively control exit code behavior in older versions [3][4]. In PowerShell 7.4 and later, you can enable the $PSNativeCommandUseErrorActionPreference feature [2][4]: - Set $PSNativeCommandUseErrorActionPreference = $true. - Set $ErrorActionPreference = 'Stop'. When enabled, any non-zero exit code from an external program generates a NativeCommandExitException [2][7]. Because this is a PowerShell error, it respects $ErrorActionPreference, effectively allowing you to catch the failure in a try/catch block [2][8][4]. 3. Manual Exit Code Handling (Recommended for Compatibility): If you cannot rely on the $PSNativeCommandUseErrorActionPreference feature (e.g., in Windows PowerShell or for broader script compatibility), you must manually check the exit code [3][4]: try { # Attempt to run the command $commandOutput = my-command if ($LASTEXITCODE -ne 0) { throw "Command failed with exit code $LASTEXITCODE" } } catch { # Handle the error, including 'command not found' or non-zero exit codes Write-Error "An error occurred: $($_.Exception.Message)" exit 1 } 4. Important Caveats: - -ErrorAction Stop: Do not pass -ErrorAction Stop to external programs; it is a common parameter for PowerShell cmdlets and will likely be passed as an invalid argument to the external program itself [9]. - $?: Avoid relying solely on $? to detect failure for external programs, as it can be set to $false by stderr output even when the exit code is 0 [10][3]. Always check $LASTEXITCODE [3][4]. - pwsh -Command: Be aware that running a script via pwsh -Command may default the process exit code to 1 if an exception occurs or $? is $false, regardless of the script's intended exit code [11]. Use explicit exit statements if you need to control the shell's final return value [11][4].

Citations:


Wrap native-control relaunches in try/catch before relying on fallback logic.

With $ErrorActionPreference = 'Stop', a terminatable PowerShell failure during restart-app can abort the remaining script before the $LASTEXITCODE checks run. Catch these failures and run the start fallback for both caught errors and non-zero exit codes so the old Windows process exit doesn’t leave the service stopped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/pkg/sysutil/restart_windows.go` around lines 81 - 85, Update
the relaunch command construction in the Windows restart flow to wrap
native-control restart-app execution in PowerShell try/catch. Ensure the start
fallback runs both when restart-app throws a terminating error and when it
completes with a non-zero $LASTEXITCODE, while preserving the final failure
propagation when the fallback also fails.

)
} else {
Expand All @@ -94,16 +98,20 @@ func buildWindowsRestartScript(pid int, exe, workDir string) string {
)
}

// Wait until the old process is gone, then give handles a moment to release
// (important when stdout/stderr log files are still locked on Windows).
// Wait until the old process is gone; force-kill if it ignores graceful exit
// (stuck handlers). Then give handles a moment to release log file locks.
return fmt.Sprintf(
`$ErrorActionPreference = 'Stop'; `+
`$targetPid = %d; `+
`for ($i = 0; $i -lt 150; $i++) { `+
`for ($i = 0; $i -lt 100; $i++) { `+
`if (-not (Get-Process -Id $targetPid -ErrorAction SilentlyContinue)) { break }; `+
`Start-Sleep -Milliseconds 200 `+
`}; `+
`if (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) { `+
`Stop-Process -Id $targetPid -Force -ErrorAction SilentlyContinue; `+
`Start-Sleep -Milliseconds 500 `+
`}; `+
`if (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) { `+
`throw "timed out waiting for process $targetPid to exit" `+
`}; `+
`Start-Sleep -Milliseconds 500; `+
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/pkg/sysutil/restart_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ func TestBuildWindowsRestartScriptPrefersNativeControl(t *testing.T) {

require.Contains(t, script, "$targetPid = 4242")
require.Contains(t, script, "native-control.ps1")
require.Contains(t, script, "restart-app")
require.Contains(t, script, "start")
require.Contains(t, script, "Stop-Process -Id $targetPid -Force")
require.NotContains(t, script, "Start-Process -FilePath")
}

Expand Down