209 lines
6.7 KiB
Go
209 lines
6.7 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package bus
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
|
|
)
|
|
|
|
// LockFileName is the on-disk name of the bus single-instance lock. It lives
|
|
// inside the bus working directory
|
|
// (<ConfigDir>/events/<edition>/<source_kind>/<identity_hash>/).
|
|
const LockFileName = "bus.lock"
|
|
|
|
// ErrBusy is re-exported from lock for callers that only depend on bus.
|
|
var ErrBusy = lock.ErrBusy
|
|
|
|
var (
|
|
busTryAcquire = lock.TryAcquire
|
|
busSeek = func(f *os.File, offset int64, whence int) (int64, error) { return f.Seek(offset, whence) }
|
|
busReadAll = io.ReadAll
|
|
busProcessAlive = process.Alive
|
|
busTruncate = func(f *os.File, size int64) error { return f.Truncate(size) }
|
|
busFprintf = fmt.Fprintf
|
|
busSync = func(f *os.File) error { return f.Sync() }
|
|
)
|
|
|
|
// ErrStaleOwnerAlive indicates the PID stored in bus.lock points at a
|
|
// live process — there is already a bus running for this ClientID and we
|
|
// must not start another one. (This case is hit when the holder is still
|
|
// alive but its flock was somehow released; in practice flock + PID always
|
|
// agree, so this is mostly defensive.)
|
|
var ErrStaleOwnerAlive = errors.New("bus: lock file PID is alive but flock was released; assuming live owner")
|
|
|
|
// Lock represents a held bus.lock. Close releases the flock and removes the
|
|
// PID file, so a subsequent bus can acquire cleanly. A zero Lock is unusable.
|
|
type Lock struct {
|
|
inner *lock.File
|
|
}
|
|
|
|
// Acquire takes the bus lock at path and writes our PID into the file body.
|
|
//
|
|
// If the file already has a PID written by a previous run:
|
|
// 1. Try the flock first — if another process holds it, return ErrBusy
|
|
// (a live bus is running, abort).
|
|
// 2. flock acquired but file contains a PID → check if that PID is
|
|
// alive via process.Alive(). If alive → return ErrStaleOwnerAlive
|
|
// (defensive; release our flock first). If dead → take over (orphan
|
|
// cleanup) and overwrite PID with our own.
|
|
//
|
|
// On success the returned Lock owns an exclusive flock and a file body
|
|
// containing our PID. Concurrent competing processes will get ErrBusy.
|
|
func Acquire(path string) (*Lock, error) {
|
|
l, err := busTryAcquire(path)
|
|
if err != nil {
|
|
return nil, err // already wraps ErrBusy when busy
|
|
}
|
|
|
|
// flock acquired. Read existing PID (if any).
|
|
f := l.File()
|
|
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
|
|
_ = l.Close()
|
|
return nil, fmt.Errorf("bus: seek lock: %w", err)
|
|
}
|
|
old, err := busReadAll(f)
|
|
if err != nil {
|
|
_ = l.Close()
|
|
return nil, fmt.Errorf("bus: read lock: %w", err)
|
|
}
|
|
|
|
if pid := parsePID(old); pid > 0 && busProcessAlive(pid) {
|
|
// Defensive: flock returned us the lock, but the stored PID is
|
|
// alive. This shouldn't normally happen (the live process holds
|
|
// the flock), but it's possible across odd kernel/FS edge cases
|
|
// (NFS, container restarts). Release and refuse to start.
|
|
_ = l.Close()
|
|
return nil, ErrStaleOwnerAlive
|
|
}
|
|
|
|
// Orphan or first-ever acquisition. Rewrite the file with our PID.
|
|
if err := truncateAndWritePID(f, os.Getpid()); err != nil {
|
|
_ = l.Close()
|
|
return nil, fmt.Errorf("bus: write PID: %w", err)
|
|
}
|
|
return &Lock{inner: l}, nil
|
|
}
|
|
|
|
// ReadHolderPID returns the PID stored in path, or 0 if the file is missing
|
|
// or unreadable. Does NOT attempt to acquire the lock — useful for `event
|
|
// status` to display the holder without contention.
|
|
func ReadHolderPID(path string) int {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return parsePID(b)
|
|
}
|
|
|
|
// ValidateHolderOwnership reports whether pid still owns the exclusive lock at
|
|
// path. If the lock can be acquired, no daemon owns it: the PID payload is
|
|
// stale and is cleared while the probe holds the lock. The PID is read again
|
|
// after a busy result so a daemon replacement cannot be mistaken for the
|
|
// original owner.
|
|
//
|
|
// Callers use this immediately before sending a process-level termination
|
|
// fallback. A live PID by itself is not proof of ownership because operating
|
|
// systems may reuse PIDs after an unclean daemon exit.
|
|
func ValidateHolderOwnership(path string, pid int) (bool, error) {
|
|
if pid <= 0 || ReadHolderPID(path) != pid {
|
|
return false, nil
|
|
}
|
|
|
|
probe, err := busTryAcquire(path)
|
|
if err != nil {
|
|
if errors.Is(err, ErrBusy) {
|
|
return ReadHolderPID(path) == pid, nil
|
|
}
|
|
return false, fmt.Errorf("bus: verify lock owner: %w", err)
|
|
}
|
|
defer probe.Close()
|
|
|
|
// We own the lock, so the recorded PID cannot own it. Clear only the PID
|
|
// we validated above; a changed payload is left untouched defensively.
|
|
f := probe.File()
|
|
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
|
|
return false, fmt.Errorf("bus: seek stale lock: %w", err)
|
|
}
|
|
current, err := busReadAll(f)
|
|
if err != nil {
|
|
return false, fmt.Errorf("bus: read stale lock: %w", err)
|
|
}
|
|
if parsePID(current) == pid {
|
|
if err := truncateAndWritePID(f, 0); err != nil {
|
|
return false, fmt.Errorf("bus: clear stale PID: %w", err)
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// Close releases the flock and best-effort blanks the PID body so a stale
|
|
// reader (e.g. `event status` racing our shutdown) does not see our
|
|
// long-dead PID and try to signal it. The lock file itself is NOT removed
|
|
// — keeping it on disk avoids a race where a competing bus could acquire
|
|
// inode-on-create faster than our truncate.
|
|
func (l *Lock) Close() error {
|
|
if l == nil || l.inner == nil {
|
|
return nil
|
|
}
|
|
// Blank the body before releasing the lock.
|
|
f := l.inner.File()
|
|
_ = truncateAndWritePID(f, 0)
|
|
err := l.inner.Close()
|
|
l.inner = nil
|
|
return err
|
|
}
|
|
|
|
// HoldsPath returns the path the lock is held on.
|
|
func (l *Lock) HoldsPath() string {
|
|
if l == nil || l.inner == nil {
|
|
return ""
|
|
}
|
|
return l.inner.Path()
|
|
}
|
|
|
|
func truncateAndWritePID(f *os.File, pid int) error {
|
|
if err := busTruncate(f, 0); err != nil {
|
|
return err
|
|
}
|
|
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
if pid > 0 {
|
|
if _, err := busFprintf(f, "%d\n", pid); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return busSync(f)
|
|
}
|
|
|
|
func parsePID(b []byte) int {
|
|
s := strings.TrimSpace(string(b))
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n <= 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|