fix(clients): flag the restart a partly-applied edit or delete still needs

63b46cd6 made a multi-inbound client op apply its inbounds concurrently and
stop aborting at the first failure, so an error can now come back together
with needRestart=true: the inbounds that succeeded committed real changes and
their Xray still needs the restart. That commit taught the two callers it
converted — create and attach — to read the flag before the error check.

d34ec97f then routed Update, Delete, Detach and DeleteByEmail's record-less
fallback through the same fanout but touched no caller, so on a master with
several nodes a partly-applied edit or delete returned (true, err) into a
handler that returned on err first. Xray was never flagged for the work that
landed and notifyClientsChanged never fired, so the running config kept
serving the pre-edit client set and every open panel showed stale rows until
something else happened to trigger a restart.

Read needRestart before the error check in update, delete and detach, and
broadcast on needRestart || err == nil — the same shape create and attach have
had since 63b46cd6. The predicate is a strict superset of the old err == nil,
and needRestart is only ever assigned after a runSerializedTx commit, so it
firing genuinely means something landed.

The three handlers are pinned by a new controller test each: one client on two
inbounds, the second one's settings JSON corrupted so the op commits on one and
fails on the other, asserting both the success:false response and the restart
flag. All three fail without the change.

The API docs for update, del and detach now describe the partial-application
contract, as add and attach already did. Detach ends at the fanout so every one
of its errors carries the inbound prefix; update and delete write the client
record afterwards, and a failure there is reported without one.
This commit is contained in:
Sanaei
2026-09-07 01:51:01 +02:00
parent 33058c8eed
commit f072d0448d
6 changed files with 209 additions and 12 deletions
@@ -570,6 +570,25 @@ _openapi:
one per line. `limitHwid` is applied only when every inbound one per line. `limitHwid` is applied only when every inbound
succeeded, so re-run the call after fixing the failure. succeeded, so re-run the call after fixing the failure.
heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: 'The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
are reported together, one per line. So a `success:false` response can
still have applied the edit to the remaining inbounds. The client
record is written after the inbounds, so a failure there is reported
without an `inbound <id>:` prefix and leaves the inbound edits in
place.'
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- content: 'The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
are reported together, one per line. So a `success:false` response can
still have removed the client from the remaining inbounds; the client
record is kept in that case, so re-running the call retries exactly
the leftovers. The record and traffic rows are dropped after the
inbounds, so a failure there is reported without an `inbound <id>:`
prefix and leaves the client already removed from every inbound.'
heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
- content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
instead of being given a fresh address, so the call fails with instead of being given a fresh address, so the call fails with
`inbound <id>: wireguard: allowedIPs entry already used by another `inbound <id>: wireguard: allowedIPs entry already used by another
@@ -579,6 +598,13 @@ _openapi:
independently, so the remaining ones are still attached and a independently, so the remaining ones are still attached and a
`success:false` response can be partial.' `success:false` response can be partial.'
heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
- content: 'The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
are reported together, one per line. So a `success:false` response can
still have detached the remaining inbounds. Detach writes nothing
beyond the inbounds, so every error carries the prefix.'
heading: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+3
View File
@@ -7591,6 +7591,7 @@
], ],
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).", "summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
"operationId": "post_panel_api_clients_update_email", "operationId": "post_panel_api_clients_update_email",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [ "parameters": [
{ {
"name": "email", "name": "email",
@@ -7654,6 +7655,7 @@
], ],
"summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.", "summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
"operationId": "post_panel_api_clients_del_email", "operationId": "post_panel_api_clients_del_email",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
"parameters": [ "parameters": [
{ {
"name": "email", "name": "email",
@@ -7781,6 +7783,7 @@
], ],
"summary": "Detach a client from one or more inbounds without deleting the client.", "summary": "Detach a client from one or more inbounds without deleting the client.",
"operationId": "post_panel_api_clients_email_detach", "operationId": "post_panel_api_clients_email_detach",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have detached the remaining inbounds. Detach writes nothing beyond the inbounds, so every error carries the prefix.",
"parameters": [ "parameters": [
{ {
"name": "email", "name": "email",
+3
View File
@@ -7591,6 +7591,7 @@
], ],
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).", "summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
"operationId": "post_panel_api_clients_update_email", "operationId": "post_panel_api_clients_update_email",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [ "parameters": [
{ {
"name": "email", "name": "email",
@@ -7654,6 +7655,7 @@
], ],
"summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.", "summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
"operationId": "post_panel_api_clients_del_email", "operationId": "post_panel_api_clients_del_email",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
"parameters": [ "parameters": [
{ {
"name": "email", "name": "email",
@@ -7781,6 +7783,7 @@
], ],
"summary": "Detach a client from one or more inbounds without deleting the client.", "summary": "Detach a client from one or more inbounds without deleting the client.",
"operationId": "post_panel_api_clients_email_detach", "operationId": "post_panel_api_clients_email_detach",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have detached the remaining inbounds. Detach writes nothing beyond the inbounds, so every error carries the prefix.",
"parameters": [ "parameters": [
{ {
"name": "email", "name": "email",
+6
View File
@@ -1151,6 +1151,8 @@ export const sections: readonly Section[] = [
path: '/panel/api/clients/update/:email', path: '/panel/api/clients/update/:email',
summary: summary:
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).', 'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
description:
'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
params: [ params: [
{ {
name: 'email', name: 'email',
@@ -1167,6 +1169,8 @@ export const sections: readonly Section[] = [
path: '/panel/api/clients/del/:email', path: '/panel/api/clients/del/:email',
summary: summary:
'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.', 'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
description:
'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.',
params: [ params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
{ {
@@ -1200,6 +1204,8 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/clients/:email/detach', path: '/panel/api/clients/:email/detach',
summary: 'Detach a client from one or more inbounds without deleting the client.', summary: 'Detach a client from one or more inbounds without deleting the client.',
description:
'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have detached the remaining inbounds. Detach writes nothing beyond the inbounds, so every error carries the prefix.',
params: [ params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
{ {
+30 -12
View File
@@ -215,30 +215,42 @@ func (a *ClientController) update(c *gin.Context) {
} }
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds")) inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...) needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
// Flagged before the error check: a partly-applied edit leaves the change
// committed on the inbounds that succeeded, and those still need the restart.
if needRestart {
a.xrayService.SetToNeedRestart()
}
// A partly-applied call committed real changes; a rejected one touched
// nothing, and broadcasting those would refetch every panel for nothing.
if needRestart || err == nil {
notifyClientsChanged()
}
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil) jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
} }
func (a *ClientController) delete(c *gin.Context) { func (a *ClientController) delete(c *gin.Context) {
email := c.Param("email") email := c.Param("email")
keepTraffic := c.Query("keepTraffic") == "1" keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic) needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
// Flagged before the error check: a partly-applied delete already removed
// the client from the inbounds that succeeded, and those need the restart.
if needRestart {
a.xrayService.SetToNeedRestart()
}
// A partly-applied call committed real removals; a rejected one touched
// nothing, and broadcasting those would refetch every panel for nothing.
if needRestart || err == nil {
notifyClientsChanged()
}
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
} }
type attachDetachBody struct { type attachDetachBody struct {
@@ -640,15 +652,21 @@ func (a *ClientController) detach(c *gin.Context) {
return return
} }
needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds) needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
// Flagged before the error check: a partly-applied detach already removed
// the client from the inbounds that succeeded, and those need the restart.
if needRestart {
a.xrayService.SetToNeedRestart()
}
// A partly-applied call committed real removals; a rejected one touched
// nothing, and broadcasting those would refetch every panel for nothing.
if needRestart || err == nil {
notifyClientsChanged()
}
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil) jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
} }
type bulkResetRequest struct { type bulkResetRequest struct {
@@ -0,0 +1,141 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// seedPartlyApplyingClient puts one client on two inbounds and corrupts the second
// one's settings, so a later op succeeds on one inbound and fails on the other.
func seedPartlyApplyingClient(t *testing.T, email string, basePort int) (healthyID, brokenID int) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
ids := make([]int, 0, 2)
for i := range 2 {
ib := &model.Inbound{
UserId: 1, Enable: true, Port: basePort + i,
Tag: "in-" + string(rune('a'+i)) + "-partial",
Protocol: model.VLESS, Settings: `{"clients": []}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %d: %v", i, err)
}
ids = append(ids, ib.Id)
}
if _, err := (&service.ClientService{}).Create(&service.InboundService{}, &service.ClientCreatePayload{
Client: model.Client{Email: email, ID: "11111111-2222-3333-4444-555555555555", SubID: "sub-" + email, Enable: true},
InboundIds: ids,
}); err != nil {
t.Fatalf("seed Create across both inbounds: %v", err)
}
if err := db.Model(&model.Inbound{}).Where("id = ?", ids[1]).
Update("settings", `{"clients":`).Error; err != nil {
t.Fatalf("corrupt inbound %d settings: %v", ids[1], err)
}
return ids[0], ids[1]
}
func postCtx(t *testing.T, email string, body any) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "email", Value: email}}
payload := []byte("{}")
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
t.Fatalf("marshal body: %v", err)
}
}
c.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload))
c.Request.Header.Set("Content-Type", "application/json")
return c, w
}
// assertPartialApply pins that the op really failed on one inbound, so a green
// test cannot be a plain full success that never exercised the error path.
func assertPartialApply(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
var msg entity.Msg
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
t.Fatalf("decode response %q: %v", w.Body.String(), err)
}
if msg.Success {
t.Fatalf("response reports success=true, want the partial apply to report failure: %q", w.Body.String())
}
}
// TestUpdateHandlerFlagsRestartOnPartialApply pins that an edit committed on some
// inbounds and failed on others still flags Xray, as create/attach already did.
func TestUpdateHandlerFlagsRestartOnPartialApply(t *testing.T) {
const email = "partial-update@example.com"
seedPartlyApplyingClient(t, email, 43310)
a := &ClientController{}
a.xrayService.IsNeedRestartAndSetFalse()
c, w := postCtx(t, email, map[string]any{
"email": email, "id": "11111111-2222-3333-4444-555555555555",
"subId": "sub-" + email, "enable": true, "comment": "edited",
})
a.update(c)
assertPartialApply(t, w)
if !a.xrayService.IsNeedRestartAndSetFalse() {
t.Fatal("a partly-applied client edit left Xray unflagged for restart")
}
}
// TestDeleteHandlerFlagsRestartOnPartialApply is the delete-side twin: the
// removals that landed still need the restart the error path used to discard.
func TestDeleteHandlerFlagsRestartOnPartialApply(t *testing.T) {
const email = "partial-delete@example.com"
seedPartlyApplyingClient(t, email, 43320)
a := &ClientController{}
a.xrayService.IsNeedRestartAndSetFalse()
c, w := postCtx(t, email, nil)
a.delete(c)
assertPartialApply(t, w)
if !a.xrayService.IsNeedRestartAndSetFalse() {
t.Fatal("a partly-applied client delete left Xray unflagged for restart")
}
}
// TestDetachHandlerFlagsRestartOnPartialApply covers the third converted path.
func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) {
const email = "partial-detach@example.com"
healthyID, brokenID := seedPartlyApplyingClient(t, email, 43330)
a := &ClientController{}
a.xrayService.IsNeedRestartAndSetFalse()
c, w := postCtx(t, email, attachDetachBody{InboundIds: []int{healthyID, brokenID}})
a.detach(c)
assertPartialApply(t, w)
if !a.xrayService.IsNeedRestartAndSetFalse() {
t.Fatal("a partly-applied client detach left Xray unflagged for restart")
}
}