Skip to content

Commit a7c1ebc

Browse files
authored
Merge pull request SeemSeam#103 from daniellee2015/fix/daemon-lifecycle-clean
fix: enhance daemon lifecycle management and multi-instance safety
2 parents e6513a2 + bce44e4 commit a7c1ebc

18 files changed

Lines changed: 791 additions & 275 deletions

bin/ask

Lines changed: 157 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ Examples:
2222
from __future__ import annotations
2323

2424
import os
25-
import shutil
2625
import subprocess
2726
import sys
2827
import tempfile
29-
import time
3028
from datetime import datetime
3129
from pathlib import Path
3230

@@ -100,34 +98,90 @@ def _use_unified_daemon() -> bool:
10098
return True # Default to unified daemon
10199

102100

101+
def _maybe_start_unified_daemon() -> bool:
102+
"""Try to start unified askd daemon if not running."""
103+
import shutil
104+
import time
105+
import sys
106+
from askd_runtime import state_file_path
107+
from askd.daemon import ping_daemon
108+
109+
# Check if already running
110+
state_file = state_file_path("askd.json")
111+
if ping_daemon(timeout_s=0.5, state_file=state_file):
112+
return True
113+
114+
# Find askd binary
115+
candidates: list[str] = []
116+
local = (Path(__file__).resolve().parent / "askd")
117+
if local.exists():
118+
candidates.append(str(local))
119+
found = shutil.which("askd")
120+
if found:
121+
candidates.append(found)
122+
if not candidates:
123+
return False
124+
125+
# Prepare command with cross-platform handling
126+
entry = candidates[0]
127+
lower = entry.lower()
128+
if lower.endswith((".cmd", ".bat", ".exe")):
129+
argv = [entry]
130+
else:
131+
argv = [sys.executable, entry]
132+
133+
# Start daemon in background with platform-specific flags
134+
try:
135+
kwargs = {
136+
"stdin": subprocess.DEVNULL,
137+
"stdout": subprocess.DEVNULL,
138+
"stderr": subprocess.DEVNULL,
139+
"close_fds": True,
140+
}
141+
if os.name == "nt":
142+
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
143+
else:
144+
kwargs["start_new_session"] = True
145+
subprocess.Popen(argv, **kwargs)
146+
except Exception:
147+
return False
148+
149+
# Wait for daemon to be ready
150+
deadline = time.time() + 2.0
151+
while time.time() < deadline:
152+
if ping_daemon(timeout_s=0.2, state_file=state_file):
153+
return True
154+
time.sleep(0.1)
155+
156+
return False
157+
158+
103159
def _send_via_unified_daemon(
104160
provider: str,
105161
message: str,
106162
timeout: float,
107163
no_wrap: bool,
108164
caller: str,
109165
) -> int:
110-
"""Send request via unified askd daemon."""
166+
"""Send request via unified askd daemon with auto-start retry."""
111167
import json
112168
import socket
113169

114170
from askd_runtime import state_file_path
115171
import askd_rpc
116172

117-
ready_timeout = min(timeout, 2.0) if timeout and timeout > 0 else 2.0
118-
if not _ensure_unified_daemon_ready(timeout_s=ready_timeout):
119-
print("[ERROR] Unified askd daemon not running", file=sys.stderr)
120-
print("Start it with `askd` (or enable autostart via CCB_ASKD_AUTOSTART=1).", file=sys.stderr)
121-
return EXIT_ERROR
122-
123173
# Use CCB_RUN_DIR (set by CCB startup) to locate the state file.
124174
# This already contains the correct project-specific path.
125175
state_file = state_file_path("askd.json")
126176

127177
state = askd_rpc.read_state(state_file)
128178
if not state:
129-
print("[ERROR] Unified askd daemon not running", file=sys.stderr)
130-
return EXIT_ERROR
179+
# Try to start daemon and retry once
180+
if _maybe_start_unified_daemon():
181+
state = askd_rpc.read_state(state_file)
182+
if not state:
183+
print("[ERROR] Unified askd daemon not running", file=sys.stderr)
184+
return EXIT_ERROR
131185

132186
host = state.get("connect_host") or state.get("host") or "127.0.0.1"
133187
port = int(state.get("port") or 0)
@@ -164,31 +218,74 @@ def _send_via_unified_daemon(
164218
req["email_msg_id"] = os.environ.get("CCB_EMAIL_MSG_ID", "")
165219
req["email_from"] = os.environ.get("CCB_EMAIL_FROM", "")
166220

167-
try:
168-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
169-
sock.settimeout(timeout + 10 if timeout > 0 else 3610)
170-
sock.connect((host, port))
171-
sock.sendall((json.dumps(req) + "\n").encode("utf-8"))
172-
173-
data = b""
174-
while True:
175-
chunk = sock.recv(4096)
176-
if not chunk:
177-
break
178-
data += chunk
179-
if b"\n" in data:
180-
break
181-
182-
sock.close()
183-
resp = json.loads(data.decode("utf-8").strip())
184-
exit_code = int(resp.get("exit_code") or 0)
185-
reply = resp.get("reply") or ""
186-
if reply:
187-
print(reply)
188-
return exit_code
189-
except Exception as e:
190-
print(f"[ERROR] {e}", file=sys.stderr)
191-
return EXIT_ERROR
221+
# Try to send request, with one retry on connection failure only
222+
request_sent = False
223+
for attempt in range(2):
224+
sock = None
225+
try:
226+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
227+
sock.settimeout(timeout + 10 if timeout > 0 else 3610)
228+
sock.connect((host, port))
229+
230+
# Mark that connection succeeded - no retry after this point
231+
request_sent = True
232+
233+
sock.sendall((json.dumps(req) + "\n").encode("utf-8"))
234+
235+
data = b""
236+
while True:
237+
chunk = sock.recv(4096)
238+
if not chunk:
239+
break
240+
data += chunk
241+
if b"\n" in data:
242+
break
243+
244+
resp = json.loads(data.decode("utf-8").strip())
245+
exit_code = int(resp.get("exit_code") or 0)
246+
reply = resp.get("reply") or ""
247+
if reply:
248+
print(reply)
249+
return exit_code
250+
except (ConnectionRefusedError, ConnectionResetError) as e:
251+
# Only retry if connection failed before request was sent
252+
if attempt == 0 and not request_sent:
253+
if _maybe_start_unified_daemon():
254+
# Re-read state for new daemon
255+
state = askd_rpc.read_state(state_file)
256+
if state:
257+
host = state.get("connect_host") or state.get("host") or "127.0.0.1"
258+
port = int(state.get("port") or 0)
259+
token = state.get("token") or ""
260+
req["token"] = token
261+
continue # Retry with new connection info
262+
print(f"[ERROR] {e}", file=sys.stderr)
263+
return EXIT_ERROR
264+
except OSError as e:
265+
# For other OS errors, only retry if connection not yet established
266+
if attempt == 0 and not request_sent:
267+
if _maybe_start_unified_daemon():
268+
state = askd_rpc.read_state(state_file)
269+
if state:
270+
host = state.get("connect_host") or state.get("host") or "127.0.0.1"
271+
port = int(state.get("port") or 0)
272+
token = state.get("token") or ""
273+
req["token"] = token
274+
continue
275+
print(f"[ERROR] {e}", file=sys.stderr)
276+
return EXIT_ERROR
277+
except Exception as e:
278+
print(f"[ERROR] {e}", file=sys.stderr)
279+
return EXIT_ERROR
280+
finally:
281+
# Always close socket to prevent leaks
282+
if sock:
283+
try:
284+
sock.close()
285+
except Exception:
286+
pass
287+
288+
return EXIT_ERROR
192289

193290

194291
def _env_bool(name: str, default: bool = False) -> bool:
@@ -198,96 +295,6 @@ def _env_bool(name: str, default: bool = False) -> bool:
198295
return val not in ("0", "false", "no", "off")
199296

200297

201-
def _is_pid_alive(pid: int) -> bool:
202-
if pid <= 0:
203-
return False
204-
try:
205-
os.kill(pid, 0)
206-
return True
207-
except OSError:
208-
return False
209-
except Exception:
210-
return True
211-
212-
213-
def _askd_start_argv() -> list[str] | None:
214-
local = script_dir / "askd"
215-
candidates: list[str] = []
216-
if local.exists():
217-
candidates.append(str(local))
218-
found = shutil.which("askd")
219-
if found:
220-
candidates.append(found)
221-
if not candidates:
222-
return None
223-
224-
entry = candidates[0]
225-
lower = entry.lower()
226-
if lower.endswith((".cmd", ".bat", ".exe")):
227-
return [entry]
228-
return [sys.executable, entry]
229-
230-
231-
def _ensure_unified_daemon_ready(timeout_s: float = 2.0) -> bool:
232-
if not _use_unified_daemon():
233-
return True
234-
235-
from askd_runtime import state_file_path
236-
import askd_rpc
237-
238-
state_file = state_file_path("askd.json")
239-
try:
240-
if askd_rpc.ping_daemon("ask", 0.2, state_file):
241-
return True
242-
except Exception:
243-
pass
244-
245-
if not _env_bool("CCB_ASKD_AUTOSTART", True):
246-
return False
247-
248-
argv = _askd_start_argv()
249-
if not argv:
250-
return False
251-
252-
env = os.environ.copy()
253-
parent_raw = (env.get("CCB_PARENT_PID") or "").strip()
254-
if parent_raw:
255-
try:
256-
parent_pid = int(parent_raw)
257-
except Exception:
258-
parent_pid = 0
259-
if parent_pid <= 0 or not _is_pid_alive(parent_pid):
260-
env.pop("CCB_PARENT_PID", None)
261-
env.pop("CCB_MANAGED", None)
262-
263-
kwargs = {
264-
"stdin": subprocess.DEVNULL,
265-
"stdout": subprocess.DEVNULL,
266-
"stderr": subprocess.DEVNULL,
267-
"close_fds": True,
268-
"env": env,
269-
}
270-
if os.name == "nt":
271-
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
272-
else:
273-
kwargs["start_new_session"] = True
274-
275-
try:
276-
subprocess.Popen(argv, **kwargs)
277-
except Exception:
278-
return False
279-
280-
deadline = time.time() + max(0.2, float(timeout_s))
281-
while time.time() < deadline:
282-
try:
283-
if askd_rpc.ping_daemon("ask", 0.2, state_file):
284-
return True
285-
except Exception:
286-
pass
287-
time.sleep(0.1)
288-
return False
289-
290-
291298
def _default_foreground() -> bool:
292299
# Allow explicit override
293300
if _env_bool("CCB_ASK_BACKGROUND", False):
@@ -394,10 +401,15 @@ def main(argv: list[str]) -> int:
394401
return EXIT_ERROR
395402

396403
# Notify mode: sync send, no wait for reply (used for hook notifications)
397-
# MUST be checked before unified daemon path to avoid full request-response cycle
398-
# which would cause reply-to-self loops via notify_completion -> ask -> daemon -> notify_completion
399404
if notify_mode:
400405
_require_caller()
406+
if _use_unified_daemon():
407+
# TODO: Add fire-and-forget RPC mode for unified daemon
408+
# For now, disable unified mode for notify and use legacy path
409+
print("[WARN] Notify mode not yet supported with unified daemon, using legacy", file=sys.stderr)
410+
# Fall through to legacy path below
411+
412+
# Legacy daemon path for notify mode
401413
cmd = [daemon_cmd, "--sync"]
402414
if no_wrap:
403415
cmd.append("--no-wrap")
@@ -416,33 +428,25 @@ def main(argv: list[str]) -> int:
416428
print(f"[ERROR] {e}", file=sys.stderr)
417429
return EXIT_ERROR
418430

419-
# Use unified daemon if enabled (default: True)
420-
if _use_unified_daemon():
421-
caller = _require_caller()
422-
return _send_via_unified_daemon(provider, message, timeout, no_wrap, caller)
423-
424-
# Foreground mode: run provider directly (avoid background cleanup in managed envs)
431+
# Foreground mode: run provider directly via unified daemon
425432
if foreground_mode:
426-
cmd = [daemon_cmd, "--sync", "--timeout", str(timeout)]
427-
if no_wrap and provider == "claude":
428-
cmd.append("--no-wrap")
429-
env = os.environ.copy()
430-
env["CCB_CALLER"] = _require_caller()
431-
try:
432-
result = subprocess.run(cmd, input=message, text=True, env=env)
433-
return result.returncode
434-
except Exception as e:
435-
print(f"[ERROR] {e}", file=sys.stderr)
436-
return EXIT_ERROR
437-
438-
# Default async mode: background task via nohup, using unified askd daemon
439-
if _use_unified_daemon():
440-
ready_timeout = min(timeout, 2.0) if timeout and timeout > 0 else 2.0
441-
if not _ensure_unified_daemon_ready(timeout_s=ready_timeout):
442-
print("[ERROR] Unified askd daemon not running", file=sys.stderr)
443-
print("Start it with `askd` (or enable autostart via CCB_ASKD_AUTOSTART=1).", file=sys.stderr)
444-
return EXIT_ERROR
433+
if _use_unified_daemon():
434+
caller = _require_caller()
435+
return _send_via_unified_daemon(provider, message, timeout, no_wrap, caller)
436+
else:
437+
cmd = [daemon_cmd, "--sync", "--timeout", str(timeout)]
438+
if no_wrap and provider == "claude":
439+
cmd.append("--no-wrap")
440+
env = os.environ.copy()
441+
env["CCB_CALLER"] = _require_caller()
442+
try:
443+
result = subprocess.run(cmd, input=message, text=True, env=env)
444+
return result.returncode
445+
except Exception as e:
446+
print(f"[ERROR] {e}", file=sys.stderr)
447+
return EXIT_ERROR
445448

449+
# Default async mode: background task via nohup
446450
task_id = make_task_id()
447451
log_dir = Path(tempfile.gettempdir()) / "ccb-tasks"
448452
log_dir.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)