// 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 // (/events////). 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 }