5 changed files with 126 additions and 164 deletions
+8 -8
View File
@@ -1719,12 +1719,12 @@ issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是
- 备选方案:为每个未测子项补验收用例(本波不做,避免为变绿放松断言)。
- 影响:汇总改为通过 19、部分通过 4(F03/F08/F21/F22)、失败 0。F19 仍引用仓库内 SDK 清单、本波不重跑。
### ¸´ÉóÐÞ¸´ R3-03
### 复审修复 R3-07
1. **д¶ÓÁÐÂúʱ Close ²»ÔÙÓë enqueue ËÀËø**
- ÈÕÆÚ£º2026-09-30
- Ô­Ìõ¿î£ºissue #67£»Í£»úÐè Drain/Close ÔÚ³¬Ê±ÄÚ·µ»Ø£»ÒÑÈë¶ÓÈÎÎñÈÔÓÉд goroutine ´¦ÀíÍê¡£
- ʵ¼Ê×ö·¨£ºQueue Ôö¼Ó closing Ðźš£enqueue ÔÚ³Ö sendMu.RLock ʱ select ͬʱµÈ´ý q.ch¡¢closing Óë ctx.Done()£¬Í¨µÀÂúʱ²»ÔÙÎÞÏÞ×èÈû¡£Close ÏÈ Swap(closed) ²¢ close(closing) »½ÐÑÔÚ;·¢ËÍ·½ÊͷŶÁËø£¬ÔÙÔÚÐ´ËøÄÚ close(q.ch)£¬×îºóµÈ loop Í˳ö¡£²»ÏòÒÑ¹Ø±Õ channel ·¢ËÍ¡£
- Ô­Òò£ºÍ¨µÀÂúʱ²¢·¢ Do Õ¼×ŶÁËø¶ÂÔÚ·¢ËÍÉÏ£¬Close µÄÐ´ËøÄò»µ½£¬Í£»ú¿¨ËÀ£»Î´Ìá½»µÄÔÚ;дҲ»á¶ª¡£
- ±¸Ñ¡·½°¸£ºÈë¶Ó¸ÄΪ·Ç×èÈû£¬ÂúÔòÁ¢¼´ ErrBusy£¨·ñ¾ö£º¸Ä±ä±³Ñ¹ÓïÒ壬Õý³£¸ß·å»áÎóÉËÌá½»£©¡£
- Ó°Ï죺½ö internal/store/queue.go Óë²âÊÔ£»²»¸ÄÇ¨ÒÆ±àºÅ¡£
1. **客户端建群事务内复核创建者**
- 日期:2026-09-30
- 原条款:Gitea #71。`group.Create` 在 `Queue.Do` 里直接 `insertMemberTx` 群主,不跑 `endpointCheckTx`;停用/删除与建群抢在事务外对话密码窗口时,可插入已停用甚至刚删除的 `owner_id`。
- 实际做法:`Create` 同一写事务里,插入群与群主成员之前对 `actorID` 做与 `createAdmin` 相同的 `endpointCheckTx`;已停用 `endpoint_disabled`,不存在 `invalid_target`,且不插入群。不改 `emit`、不改迁移编号。
- 原因:成员侧已有事务内复核,群主侧缺对称校验,竞态会留下非法群主。
- 备选方案:事务外再查一次创建者(否决,无法覆盖密码窗口与写事务之间的竞态)。
- 影响:创建者在进入写事务前被停用/删除时建群失败且无新群行;与后台建群错误码对齐。
+9
View File
@@ -147,6 +147,15 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
var inserted []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
if e := endpointCheckTx(tx, actorID); e != nil {
if protoCode(e) == protocol.CodeInvalidTarget {
return errCode(protocol.CodeInvalidTarget, "owner not found")
}
if protoCode(e) == protocol.CodeEndpointDisabled {
return errCode(protocol.CodeEndpointDisabled, "owner disabled")
}
return e
}
var exists int
qErr := tx.QueryRow(`SELECT 1 FROM groups WHERE id = ?`, gid).Scan(&exists)
if qErr == nil {
+100
View File
@@ -604,6 +604,106 @@ func TestU02CreateDedupesMembers(t *testing.T) {
}
}
// raceTalk 在对话密码校验成功后执行一次 mutate,模拟事务外密码窗口里创建者被停用/删除。
type raceTalk struct {
inner group.TalkGate
mutate func()
once sync.Once
}
func (r *raceTalk) CheckTalkPasswordForJoin(ctx context.Context, actorID, targetID, talkPassword, remoteIP string) error {
err := r.inner.CheckTalkPasswordForJoin(ctx, actorID, targetID, talkPassword, remoteIP)
if err == nil && r.mutate != nil {
r.once.Do(r.mutate)
}
return err
}
func TestR307CreateRejectsActorDisabledOrDeletedBeforeWrite(t *testing.T) {
t.Parallel()
cases := []struct {
name string
code string
mutate func(ctx context.Context, t *testing.T, idApp *identity.App, db *store.DB)
}{
{
name: "disabled",
code: protocol.CodeEndpointDisabled,
mutate: func(ctx context.Context, t *testing.T, idApp *identity.App, _ *store.DB) {
t.Helper()
if err := idApp.Disable(ctx, "alice"); err != nil {
t.Fatal(err)
}
},
},
{
name: "deleted",
code: protocol.CodeInvalidTarget,
mutate: func(ctx context.Context, t *testing.T, idApp *identity.App, _ *store.DB) {
t.Helper()
if err := idApp.Delete(ctx, "alice"); err != nil {
t.Fatal(err)
}
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, err := store.Open(filepath.Join(t.TempDir(), "data"), "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
fixed := time.UnixMilli(1_700_000_000_000)
locks := auth.NewLoginLocks()
idApp := identity.New(identity.Config{
DB: db, Hash: auth.NewStubHashPool(), Locks: locks,
Sessions: auth.NewSessionTokens(),
Now: func() time.Time { return fixed },
})
gate := &raceTalk{inner: idApp}
gApp := group.New(group.Config{
DB: db, Talk: gate, MaxGroupMembers: 1000,
Now: func() time.Time { return fixed }, DefaultRemoteIP: "1.1.1.1",
})
ctx := context.Background()
insertEP(t, db, "alice", 1)
insertEP(t, db, "bob", 1)
if err := idApp.SelfSetTalkPassword(ctx, "bob", "secret"); err != nil {
t.Fatal(err)
}
gate.mutate = func() { tc.mutate(ctx, t, idApp, db) }
var before int
if qErr := db.Read.QueryRow(`SELECT COUNT(*) FROM groups`).Scan(&before); qErr != nil {
t.Fatal(qErr)
}
_, err = gApp.Create(ctx, "alice", &protocol.GroupCreate{
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "1",
ID: "g_r307_" + tc.name, Name: "竞态群",
Members: []protocol.GroupMemberIn{{ID: "bob", TalkPassword: "secret"}},
})
if protoCode(err) != tc.code {
t.Fatalf("want %s got %v", tc.code, err)
}
var after int
if qErr := db.Read.QueryRow(`SELECT COUNT(*) FROM groups`).Scan(&after); qErr != nil {
t.Fatal(qErr)
}
if after != before {
t.Fatalf("groups leaked: before=%d after=%d", before, after)
}
var exists int
qErr := db.Read.QueryRow(`SELECT 1 FROM groups WHERE id = ?`, "g_r307_"+tc.name).Scan(&exists)
if !errors.Is(qErr, sql.ErrNoRows) {
t.Fatalf("expected no group row, got exists=%d err=%v", exists, qErr)
}
})
}
}
func TestU02AdminCreateOwnerMustExistAndEnabled(t *testing.T) {
t.Parallel()
gApp, _, _, db, down := setup(t)
+9 -22
View File
@@ -36,11 +36,10 @@ type writeJob struct {
type Queue struct {
db *sql.DB
ch chan writeJob
done chan struct{}
closing chan struct{} // Close 时关闭,唤醒持读锁阻塞在发送上的 enqueue
closed atomic.Bool
sendMu sync.RWMutex
ch chan writeJob
done chan struct{}
closed atomic.Bool
sendMu sync.RWMutex
mu sync.Mutex
ready bool
@@ -53,16 +52,11 @@ type Queue struct {
// NewQueue 创建合并写入队列并启动写 goroutine。
func NewQueue(db *sql.DB) *Queue {
return newQueue(db, queueBuffSize)
}
func newQueue(db *sql.DB, buffSize int) *Queue {
q := &Queue{
db: db,
ch: make(chan writeJob, buffSize),
done: make(chan struct{}),
closing: make(chan struct{}),
ready: true,
db: db,
ch: make(chan writeJob, queueBuffSize),
done: make(chan struct{}),
ready: true,
}
go q.loop()
return q
@@ -121,15 +115,10 @@ func (q *Queue) enqueue(job writeJob) error {
return ErrQueueClosed
}
q.addPending(1)
// 通道满时不得只堵在发送上持有读锁:Close 需要写锁关闭 q.ch。
select {
case q.ch <- job:
q.sendMu.RUnlock()
return nil
case <-q.closing:
q.addPending(-1)
q.sendMu.RUnlock()
return ErrQueueClosed
case <-job.ctx.Done():
q.addPending(-1)
q.sendMu.RUnlock()
@@ -387,13 +376,11 @@ func (q *Queue) Drain(ctx context.Context) error {
}
// Close 关闭队列:不再接受新任务,并等待写 goroutine 处理完已入队任务后退出。
// 先关闭 closing 唤醒因通道满而阻塞的发送方并释放读锁,再在无发送者时关闭数据通道,
// 避免向已关闭 channel 发送而 panic,也避免与持读锁的 enqueue 死锁。
// 在写锁内关闭数据通道,避免并发 Do 向已关闭 channel 发送而 panic。
func (q *Queue) Close() error {
if q.closed.Swap(true) {
return nil
}
close(q.closing)
q.sendMu.Lock()
close(q.ch)
q.sendMu.Unlock()
-134
View File
@@ -289,137 +289,3 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.upd
}
}
}
// openSmallQueue 用小缓冲队列替换默认队列,便于测满通道时的 Close/Drain。
func openSmallQueue(t *testing.T, buf int) *DB {
t.Helper()
dir := t.TempDir()
db, err := Open(dir, "FULL")
if err != nil {
t.Fatal(err)
}
if err := db.Queue.Close(); err != nil {
t.Fatal(err)
}
db.Queue = newQueue(db.Write, buf)
return db
}
func TestQueueCloseUnblocksFullChannel(t *testing.T) {
t.Parallel()
const buf = 4
db := openSmallQueue(t, buf)
defer func() { _ = db.Close() }()
q := db.Queue
hold := make(chan struct{})
blockerStarted := make(chan struct{})
blockerErr := make(chan error, 1)
go func() {
blockerErr <- q.Do(context.Background(), func(tx *sql.Tx) error {
close(blockerStarted)
<-hold
return nil
})
}()
<-blockerStarted
ctx := context.Background()
var fillWG sync.WaitGroup
for i := 0; i < buf; i++ {
fillWG.Add(1)
go func() {
defer fillWG.Done()
_ = q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
}
deadline := time.Now().Add(2 * time.Second)
for q.Len() < buf+1 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
if q.Len() < buf+1 {
t.Fatalf("channel not full: len=%d", q.Len())
}
blockedErr := make(chan error, 1)
go func() {
blockedErr <- q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
// 等额外 Do 堵在 enqueue(pending 超过通道容量+正在执行的一条)。
deadline = time.Now().Add(2 * time.Second)
for q.Len() < buf+2 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
closeDone := make(chan error, 1)
go func() { closeDone <- q.Close() }()
select {
case err := <-blockedErr:
if !errors.Is(err, ErrQueueClosed) {
t.Fatalf("blocked Do: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close did not unblock full-channel enqueue")
}
close(hold)
select {
case err := <-closeDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close hung after writer released")
}
<-blockerErr
fillWG.Wait()
}
func TestQueueDrainTimeoutWhileWriterBlocked(t *testing.T) {
t.Parallel()
const buf = 4
db := openSmallQueue(t, buf)
defer func() { _ = db.Close() }()
q := db.Queue
hold := make(chan struct{})
started := make(chan struct{})
go func() {
_ = q.Do(context.Background(), func(tx *sql.Tx) error {
close(started)
<-hold
return nil
})
}()
<-started
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < buf; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
}
deadline := time.Now().Add(2 * time.Second)
for q.Len() < buf+1 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
drainCtx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
start := time.Now()
err := q.Drain(drainCtx)
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Drain err=%v want deadline", err)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("Drain took %s, should return on timeout", elapsed)
}
close(hold)
wg.Wait()
}