- On startup, daemon sweeps orphaned /tmp/jj-claude-*-files from pre-daemon hook versions and dead-PID tracking files from all repos - Cross-session file conflicts detected on post-edit and returned in response (logged as warnings, non-blocking) - session-start now returns inventory of all other active sessions with their tracked files, PIDs, and ages - Stale session reap reduced from 2h to 30min default (matches idle shutdown), configurable via JJ_HOOK_STALE_MIN env var - Debounce interval configurable via JJ_HOOK_DEBOUNCE_SEC env var (default 3s, max 30s) — previously documented but not implemented - handleEvent response changed from bare bool to structured JSON with ok/error/ sessions/conflicts fields (backward compatible — ok field still present) - 30 Go tests (up from 13): inventory, conflicts, env vars, legacy cleanup
671 lines
18 KiB
Go
671 lines
18 KiB
Go
// jj-commitd — debounced commit daemon for jj + Claude Code
|
|
//
|
|
// Listens on a Unix socket for session lifecycle events from the
|
|
// jj-hook.sh client script. Manages sessions, tracks edited files,
|
|
// and commits changes in debounced batches to avoid racing with
|
|
// rapid successive edits.
|
|
//
|
|
// Architecture:
|
|
// hook script (bash) --unix socket--> jj-commitd (this) --exec--> jj CLI
|
|
//
|
|
// Events (JSON over socket, one per line):
|
|
// {"event":"session-start","session_id":"abc123","repo_root":"/path","pid":1234}
|
|
// {"event":"post-edit","session_id":"abc123","file":"/path/to/file.ex"}
|
|
// {"event":"session-end","session_id":"abc123"}
|
|
// {"event":"shutdown"}
|
|
//
|
|
// The daemon auto-exits after all sessions end + an idle timeout.
|
|
//
|
|
// Environment variables:
|
|
// REPO_ROOT — repo root path (auto-detected from cwd if unset)
|
|
// JJ_HOOK_DEBOUNCE_SEC — debounce interval in seconds (default: 3, max: 30)
|
|
// JJ_HOOK_STALE_MIN — stale session reap threshold in minutes (default: 30)
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// ── Configuration ───────────────────────────────────────────────
|
|
|
|
const (
|
|
defaultDebounce = 3 * time.Second
|
|
quiescenceWindow = 500 * time.Millisecond // must be idle this long before committing
|
|
maxDebounce = 30 * time.Second // cap for adaptive/env debounce
|
|
idleShutdown = 30 * time.Minute
|
|
defaultStaleSessionAge = 30 * time.Minute // reduced from 2h — matches idle shutdown
|
|
maxLogSize = 512 * 1024 // 512KB
|
|
)
|
|
|
|
// debounceInterval returns the debounce duration, honoring JJ_HOOK_DEBOUNCE_SEC.
|
|
func debounceInterval() time.Duration {
|
|
if v := os.Getenv("JJ_HOOK_DEBOUNCE_SEC"); v != "" {
|
|
if sec, err := strconv.Atoi(v); err == nil && sec > 0 {
|
|
d := time.Duration(sec) * time.Second
|
|
if d > maxDebounce {
|
|
d = maxDebounce
|
|
}
|
|
return d
|
|
}
|
|
}
|
|
return defaultDebounce
|
|
}
|
|
|
|
// staleSessionAge returns the stale threshold, honoring JJ_HOOK_STALE_MIN.
|
|
func staleSessionAge() time.Duration {
|
|
if v := os.Getenv("JJ_HOOK_STALE_MIN"); v != "" {
|
|
if min, err := strconv.Atoi(v); err == nil && min > 0 {
|
|
return time.Duration(min) * time.Minute
|
|
}
|
|
}
|
|
return defaultStaleSessionAge
|
|
}
|
|
|
|
// ── Types ───────────────────────────────────────────────────────
|
|
|
|
type Event struct {
|
|
Event string `json:"event"`
|
|
SessionID string `json:"session_id"`
|
|
RepoRoot string `json:"repo_root"`
|
|
File string `json:"file"`
|
|
PID int `json:"pid"`
|
|
}
|
|
|
|
type Session struct {
|
|
ID string
|
|
RepoRoot string
|
|
PID int
|
|
Files map[string]bool // tracked files (deduped)
|
|
Dirty bool // has unseen edits since last commit
|
|
Timer *time.Timer // debounce timer
|
|
StartedAt time.Time
|
|
LastEditAt time.Time
|
|
}
|
|
|
|
type Daemon struct {
|
|
mu sync.Mutex
|
|
jjLock sync.Mutex // serializes all jj CLI operations
|
|
sessions map[string]*Session // keyed by session_id
|
|
listener net.Listener
|
|
logFile *os.File
|
|
done chan struct{}
|
|
}
|
|
|
|
// ── Daemon lifecycle ────────────────────────────────────────────
|
|
|
|
func main() {
|
|
repoRoot := os.Getenv("REPO_ROOT")
|
|
if repoRoot == "" {
|
|
// Try to detect from cwd
|
|
var err error
|
|
repoRoot, err = os.Getwd()
|
|
if err != nil {
|
|
log.Fatal("cannot determine repo root")
|
|
}
|
|
}
|
|
|
|
sockPath := socketPath(repoRoot)
|
|
|
|
// Clean up stale socket
|
|
if _, err := os.Stat(sockPath); err == nil {
|
|
// Try connecting — if it fails, the socket is stale
|
|
conn, err := net.DialTimeout("unix", sockPath, 100*time.Millisecond)
|
|
if err != nil {
|
|
os.Remove(sockPath)
|
|
} else {
|
|
// Another daemon is running
|
|
conn.Close()
|
|
fmt.Fprintf(os.Stderr, "jj-commitd: already running on %s\n", sockPath)
|
|
os.Exit(0)
|
|
}
|
|
}
|
|
|
|
d := &Daemon{
|
|
sessions: make(map[string]*Session),
|
|
done: make(chan struct{}),
|
|
}
|
|
|
|
// Open log file
|
|
logPath := fmt.Sprintf("/tmp/jj-commitd-%s.log", repoID(repoRoot))
|
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
if err == nil {
|
|
d.logFile = f
|
|
defer f.Close()
|
|
}
|
|
|
|
d.log("starting daemon repo=%s socket=%s debounce=%s stale=%s",
|
|
repoRoot, sockPath, debounceInterval(), staleSessionAge())
|
|
|
|
// Clean up legacy tracking files from pre-daemon hook versions
|
|
d.cleanLegacyTrackingFiles(repoRoot)
|
|
|
|
// Listen
|
|
listener, err := net.Listen("unix", sockPath)
|
|
if err != nil {
|
|
log.Fatalf("jj-commitd: listen: %v", err)
|
|
}
|
|
d.listener = listener
|
|
defer func() {
|
|
listener.Close()
|
|
os.Remove(sockPath)
|
|
}()
|
|
|
|
// Handle signals
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
<-sigCh
|
|
d.log("received signal, shutting down")
|
|
d.shutdown()
|
|
}()
|
|
|
|
// Idle timeout
|
|
go d.idleWatchdog()
|
|
|
|
// Accept loop
|
|
d.log("listening on %s", sockPath)
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-d.done:
|
|
return
|
|
default:
|
|
d.log("accept error: %v", err)
|
|
continue
|
|
}
|
|
}
|
|
go d.handleConn(conn)
|
|
}
|
|
}
|
|
|
|
// ── Connection handling ─────────────────────────────────────────
|
|
|
|
func (d *Daemon) handleConn(conn net.Conn) {
|
|
defer conn.Close()
|
|
scanner := bufio.NewScanner(conn)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var ev Event
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
d.log("bad json: %v", err)
|
|
fmt.Fprintf(conn, `{"ok":false,"error":"bad json"}`+"\n")
|
|
continue
|
|
}
|
|
|
|
resp := d.handleEvent(ev)
|
|
data, _ := json.Marshal(resp)
|
|
fmt.Fprintf(conn, "%s\n", data)
|
|
}
|
|
}
|
|
|
|
// Response is the JSON response sent back to the hook client.
|
|
type Response struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
Sessions []SessionInfo `json:"sessions,omitempty"` // populated on session-start
|
|
Conflicts []ConflictInfo `json:"conflicts,omitempty"` // populated on post-edit
|
|
}
|
|
|
|
// SessionInfo is returned on session-start so agents know who else is active.
|
|
type SessionInfo struct {
|
|
ID string `json:"id"`
|
|
PID int `json:"pid"`
|
|
Files []string `json:"files"`
|
|
Age string `json:"age"`
|
|
}
|
|
|
|
// ConflictInfo is returned on post-edit when a file is tracked by another session.
|
|
type ConflictInfo struct {
|
|
File string `json:"file"`
|
|
SessionID string `json:"session_id"`
|
|
PID int `json:"pid"`
|
|
}
|
|
|
|
func (d *Daemon) handleEvent(ev Event) Response {
|
|
switch ev.Event {
|
|
case "session-start":
|
|
return d.sessionStart(ev)
|
|
case "post-edit":
|
|
return d.postEdit(ev)
|
|
case "session-end":
|
|
ok := d.sessionEnd(ev)
|
|
return Response{OK: ok}
|
|
case "shutdown":
|
|
d.log("shutdown requested")
|
|
go func() { time.Sleep(100 * time.Millisecond); d.shutdown() }()
|
|
return Response{OK: true}
|
|
case "status":
|
|
d.logStatus()
|
|
return Response{OK: true}
|
|
default:
|
|
d.log("unknown event: %s", ev.Event)
|
|
return Response{OK: false, Error: "unknown event"}
|
|
}
|
|
}
|
|
|
|
// ── Session lifecycle ───────────────────────────────────────────
|
|
|
|
func (d *Daemon) sessionStart(ev Event) Response {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
if _, exists := d.sessions[ev.SessionID]; exists {
|
|
d.log("session %s already exists, resetting", ev.SessionID)
|
|
}
|
|
|
|
d.sessions[ev.SessionID] = &Session{
|
|
ID: ev.SessionID,
|
|
RepoRoot: ev.RepoRoot,
|
|
PID: ev.PID,
|
|
Files: make(map[string]bool),
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
// Build inventory of other active sessions
|
|
var others []SessionInfo
|
|
for id, sess := range d.sessions {
|
|
if id == ev.SessionID {
|
|
continue
|
|
}
|
|
files := make([]string, 0, len(sess.Files))
|
|
for f := range sess.Files {
|
|
files = append(files, f)
|
|
}
|
|
others = append(others, SessionInfo{
|
|
ID: id,
|
|
PID: sess.PID,
|
|
Files: files,
|
|
Age: time.Since(sess.StartedAt).Round(time.Second).String(),
|
|
})
|
|
}
|
|
|
|
d.log("session-start id=%s repo=%s pid=%d other_sessions=%d",
|
|
ev.SessionID, ev.RepoRoot, ev.PID, len(others))
|
|
return Response{OK: true, Sessions: others}
|
|
}
|
|
|
|
func (d *Daemon) postEdit(ev Event) Response {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
sess, ok := d.sessions[ev.SessionID]
|
|
if !ok {
|
|
// Auto-create session for robustness (hook may arrive before session-start in edge cases)
|
|
sess = &Session{
|
|
ID: ev.SessionID,
|
|
RepoRoot: ev.RepoRoot,
|
|
Files: make(map[string]bool),
|
|
StartedAt: time.Now(),
|
|
}
|
|
d.sessions[ev.SessionID] = sess
|
|
d.log("auto-created session %s for post-edit", ev.SessionID)
|
|
}
|
|
|
|
// Make path relative to repo root
|
|
relPath := ev.File
|
|
if sess.RepoRoot != "" && strings.HasPrefix(ev.File, sess.RepoRoot) {
|
|
relPath = strings.TrimPrefix(ev.File, sess.RepoRoot+"/")
|
|
}
|
|
|
|
// Check for cross-session conflicts before tracking
|
|
var conflicts []ConflictInfo
|
|
for id, other := range d.sessions {
|
|
if id == ev.SessionID {
|
|
continue
|
|
}
|
|
if other.Files[relPath] {
|
|
conflicts = append(conflicts, ConflictInfo{
|
|
File: relPath,
|
|
SessionID: id,
|
|
PID: other.PID,
|
|
})
|
|
d.log("conflict: file=%s session=%s also tracked by session=%s (pid=%d)",
|
|
relPath, ev.SessionID, id, other.PID)
|
|
}
|
|
}
|
|
|
|
sess.Files[relPath] = true
|
|
sess.Dirty = true
|
|
sess.LastEditAt = time.Now()
|
|
|
|
// Reset debounce timer
|
|
if sess.Timer != nil {
|
|
sess.Timer.Stop()
|
|
}
|
|
sess.Timer = time.AfterFunc(debounceInterval(), func() {
|
|
d.commitSession(ev.SessionID)
|
|
})
|
|
|
|
d.log("post-edit session=%s file=%s tracked=%d", ev.SessionID, relPath, len(sess.Files))
|
|
return Response{OK: true, Conflicts: conflicts}
|
|
}
|
|
|
|
func (d *Daemon) sessionEnd(ev Event) bool {
|
|
d.mu.Lock()
|
|
sess, ok := d.sessions[ev.SessionID]
|
|
if !ok {
|
|
d.mu.Unlock()
|
|
d.log("session-end for unknown session %s", ev.SessionID)
|
|
return false
|
|
}
|
|
|
|
// Stop debounce timer
|
|
if sess.Timer != nil {
|
|
sess.Timer.Stop()
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
// Final flush commit (synchronous — session-end should wait)
|
|
d.commitSession(ev.SessionID)
|
|
|
|
// Set bookmark (uses jjLock internally via d.jjLocked)
|
|
if sess.RepoRoot != "" {
|
|
d.jjLocked(sess.RepoRoot, "bookmark", "set", "wip/claude-"+ev.SessionID, "-r", "@-")
|
|
}
|
|
|
|
// Cleanup
|
|
d.mu.Lock()
|
|
delete(d.sessions, ev.SessionID)
|
|
d.mu.Unlock()
|
|
|
|
d.log("session-end id=%s files_tracked=%d", ev.SessionID, len(sess.Files))
|
|
return true
|
|
}
|
|
|
|
// ── Commit logic ────────────────────────────────────────────────
|
|
|
|
func (d *Daemon) commitSession(sessionID string) {
|
|
// Quiescence check: wait until no edits have arrived for quiescenceWindow.
|
|
// This prevents committing while a burst of edits (or a formatter) is still active.
|
|
for {
|
|
d.mu.Lock()
|
|
sess, ok := d.sessions[sessionID]
|
|
if !ok || !sess.Dirty {
|
|
d.mu.Unlock()
|
|
return
|
|
}
|
|
sinceLastEdit := time.Since(sess.LastEditAt)
|
|
d.mu.Unlock()
|
|
|
|
if sinceLastEdit >= quiescenceWindow {
|
|
break
|
|
}
|
|
// Not quiet yet — wait for the remainder of the quiescence window
|
|
time.Sleep(quiescenceWindow - sinceLastEdit)
|
|
}
|
|
|
|
d.mu.Lock()
|
|
sess, ok := d.sessions[sessionID]
|
|
if !ok || !sess.Dirty {
|
|
d.mu.Unlock()
|
|
return
|
|
}
|
|
sess.Dirty = false
|
|
|
|
// Snapshot the file list
|
|
files := make([]string, 0, len(sess.Files))
|
|
for f := range sess.Files {
|
|
files = append(files, f)
|
|
}
|
|
repoRoot := sess.RepoRoot
|
|
d.mu.Unlock()
|
|
|
|
if len(files) == 0 || repoRoot == "" {
|
|
return
|
|
}
|
|
|
|
// Serialize jj operations — prevents concurrent jj invocations from
|
|
// different sessions stepping on each other's working copy snapshots.
|
|
d.jjLock.Lock()
|
|
defer d.jjLock.Unlock()
|
|
|
|
// Single diff check for all files at once (one jj invocation, not N)
|
|
out, err := d.jjOutput(repoRoot, append([]string{"diff", "--summary", "--"}, files...)...)
|
|
if err != nil || strings.TrimSpace(out) == "" {
|
|
return
|
|
}
|
|
|
|
// Build commit message from tracked file basenames
|
|
names := make([]string, 0, len(files))
|
|
for _, f := range files {
|
|
names = append(names, filepath.Base(f))
|
|
}
|
|
// Deduplicate basenames (multiple paths may share a basename)
|
|
seen := make(map[string]bool)
|
|
uniqueNames := make([]string, 0, len(names))
|
|
for _, n := range names {
|
|
if !seen[n] {
|
|
seen[n] = true
|
|
uniqueNames = append(uniqueNames, n)
|
|
}
|
|
}
|
|
|
|
var summary string
|
|
if len(uniqueNames) <= 5 {
|
|
summary = strings.Join(uniqueNames, ", ")
|
|
} else {
|
|
summary = fmt.Sprintf("%s and %d more",
|
|
strings.Join(uniqueNames[:5], ", "), len(uniqueNames)-5)
|
|
}
|
|
msg := fmt.Sprintf("wip(claude:%s): %s", sessionID, summary)
|
|
|
|
// Commit all tracked files in one operation.
|
|
// jj commit with file args only commits changes to those specific files.
|
|
args := append([]string{"commit", "-m", msg, "--"}, files...)
|
|
if err := d.jj(repoRoot, args...); err != nil {
|
|
d.log("commit failed session=%s: %v", sessionID, err)
|
|
} else {
|
|
d.log("committed session=%s files=%d msg=%s", sessionID, len(files), summary)
|
|
}
|
|
}
|
|
|
|
// ── Legacy tracking file cleanup ────────────────────────────────
|
|
|
|
// cleanLegacyTrackingFiles removes orphaned /tmp/jj-claude-*-files left by
|
|
// the pre-daemon hook script. These files can cause stale session artifacts
|
|
// that confuse the fallback path if the daemon restarts.
|
|
func (d *Daemon) cleanLegacyTrackingFiles(repoRoot string) {
|
|
rid := repoID(repoRoot)
|
|
pattern := fmt.Sprintf("/tmp/jj-claude-%s-*-files", rid)
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil || len(matches) == 0 {
|
|
return
|
|
}
|
|
|
|
d.log("found %d legacy tracking files matching %s", len(matches), pattern)
|
|
for _, path := range matches {
|
|
if err := os.Remove(path); err != nil {
|
|
d.log("failed to remove legacy file %s: %v", path, err)
|
|
} else {
|
|
d.log("removed legacy tracking file: %s", path)
|
|
}
|
|
}
|
|
|
|
// Also clean up any PID sidecar files from the legacy system
|
|
pidPattern := fmt.Sprintf("/tmp/jj-claude-%s-*-pid", rid)
|
|
pidMatches, _ := filepath.Glob(pidPattern)
|
|
for _, path := range pidMatches {
|
|
os.Remove(path)
|
|
}
|
|
|
|
// Clean tracking files for ALL repos where the PID is dead (not just ours)
|
|
allPattern := "/tmp/jj-claude-*-files"
|
|
allMatches, _ := filepath.Glob(allPattern)
|
|
for _, path := range allMatches {
|
|
// Try to find corresponding PID file
|
|
pidPath := strings.TrimSuffix(path, "-files") + "-pid"
|
|
pidData, err := os.ReadFile(pidPath)
|
|
if err != nil {
|
|
// No PID file — orphan, remove
|
|
os.Remove(path)
|
|
d.log("removed orphan tracking file (no pid): %s", path)
|
|
continue
|
|
}
|
|
pid, err := strconv.Atoi(strings.TrimSpace(string(pidData)))
|
|
if err != nil {
|
|
os.Remove(path)
|
|
os.Remove(pidPath)
|
|
d.log("removed orphan tracking file (bad pid): %s", path)
|
|
continue
|
|
}
|
|
proc, err := os.FindProcess(pid)
|
|
if err != nil || proc.Signal(syscall.Signal(0)) != nil {
|
|
os.Remove(path)
|
|
os.Remove(pidPath)
|
|
d.log("removed dead-pid tracking file: %s (pid=%d)", path, pid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── jj helpers ──────────────────────────────────────────────────
|
|
|
|
// jjLocked runs a jj command while holding the jj lock.
|
|
// Use for one-off jj commands outside of commitSession (which holds its own lock).
|
|
func (d *Daemon) jjLocked(repoRoot string, args ...string) error {
|
|
d.jjLock.Lock()
|
|
defer d.jjLock.Unlock()
|
|
return d.jj(repoRoot, args...)
|
|
}
|
|
|
|
func (d *Daemon) jj(repoRoot string, args ...string) error {
|
|
cmd := exec.Command("jj", args...)
|
|
cmd.Dir = repoRoot
|
|
cmd.Env = os.Environ()
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
d.log("jj %s failed: %s %v", strings.Join(args, " "), string(out), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (d *Daemon) jjOutput(repoRoot string, args ...string) (string, error) {
|
|
cmd := exec.Command("jj", args...)
|
|
cmd.Dir = repoRoot
|
|
cmd.Env = os.Environ()
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
// ── Utility ─────────────────────────────────────────────────────
|
|
|
|
func (d *Daemon) log(format string, args ...any) {
|
|
msg := fmt.Sprintf(format, args...)
|
|
ts := time.Now().Format("15:04:05")
|
|
line := fmt.Sprintf("%s [commitd] %s\n", ts, msg)
|
|
|
|
if d.logFile != nil {
|
|
d.logFile.WriteString(line)
|
|
} else {
|
|
fmt.Fprint(os.Stderr, line)
|
|
}
|
|
}
|
|
|
|
func (d *Daemon) logStatus() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
d.log("status: %d active sessions", len(d.sessions))
|
|
for id, sess := range d.sessions {
|
|
age := time.Since(sess.StartedAt).Round(time.Second)
|
|
d.log(" session=%s files=%d dirty=%v pid=%d age=%s",
|
|
id, len(sess.Files), sess.Dirty, sess.PID, age)
|
|
}
|
|
}
|
|
|
|
func (d *Daemon) idleWatchdog() {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-d.done:
|
|
return
|
|
case <-ticker.C:
|
|
d.mu.Lock()
|
|
count := len(d.sessions)
|
|
d.mu.Unlock()
|
|
if count == 0 {
|
|
d.log("no active sessions, idle shutdown")
|
|
d.shutdown()
|
|
return
|
|
}
|
|
// Reap dead sessions
|
|
d.reapDead()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *Daemon) reapDead() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
maxAge := staleSessionAge()
|
|
|
|
for id, sess := range d.sessions {
|
|
if sess.PID > 0 {
|
|
// Check if PID is alive
|
|
proc, err := os.FindProcess(sess.PID)
|
|
if err != nil {
|
|
d.log("reaping dead session %s (pid=%d not found)", id, sess.PID)
|
|
if sess.Timer != nil {
|
|
sess.Timer.Stop()
|
|
}
|
|
delete(d.sessions, id)
|
|
continue
|
|
}
|
|
// On Unix, FindProcess always succeeds. Use kill -0 to check.
|
|
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
|
d.log("reaping dead session %s (pid=%d dead)", id, sess.PID)
|
|
if sess.Timer != nil {
|
|
sess.Timer.Stop()
|
|
}
|
|
delete(d.sessions, id)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Reap stale sessions
|
|
if time.Since(sess.StartedAt) > maxAge {
|
|
d.log("reaping stale session %s (age=%s threshold=%s)", id, time.Since(sess.StartedAt), maxAge)
|
|
if sess.Timer != nil {
|
|
sess.Timer.Stop()
|
|
}
|
|
delete(d.sessions, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *Daemon) shutdown() {
|
|
close(d.done)
|
|
if d.listener != nil {
|
|
d.listener.Close()
|
|
}
|
|
}
|
|
|
|
func socketPath(repoRoot string) string {
|
|
return fmt.Sprintf("/tmp/jj-commitd-%s.sock", repoID(repoRoot))
|
|
}
|
|
|
|
func repoID(repoRoot string) string {
|
|
h := sha256.Sum256([]byte(repoRoot))
|
|
return fmt.Sprintf("%x", h[:6]) // 12 hex chars, collision-safe for local repos
|
|
}
|