Coverage for little_loops / cli / loop / lifecycle.py: 0%

428 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

1"""ll-loop lifecycle subcommands: status, stop, resume.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import atexit 

7import json 

8import os 

9import signal 

10import time 

11from pathlib import Path 

12 

13from little_loops.cli.loop._helpers import ( 

14 load_loop, 

15 register_loop_signal_handlers, 

16 resolve_loop_path, 

17 run_background, 

18) 

19from little_loops.fsm.concurrency import _process_alive 

20from little_loops.fsm.persistence import ( 

21 LoopState, 

22 StatePersistence, 

23 _find_instances, 

24 _read_pid_file, 

25 _reconcile_stale_running, 

26) 

27from little_loops.logger import Logger 

28 

29 

30def _format_relative_time(seconds: float) -> str: 

31 """Format seconds as a human-readable relative time string (e.g., '3m ago'). 

32 

33 Delegates to the shared ``format_relative_time`` in ``cli.output``. 

34 """ 

35 from little_loops.cli.output import format_relative_time 

36 

37 return format_relative_time(seconds) 

38 

39 

40def _format_log_label(running_dir: Path, stem: str) -> str: 

41 """Return one of three Log: labels based on run-mode signals. 

42 

43 - log exists → path string (background run, normal) 

44 - no log, no pid → foreground run (output went to terminal) 

45 - no log, pid exists → background run whose log was deleted (something wrong) 

46 """ 

47 log_file = running_dir / f"{stem}.log" 

48 if log_file.exists(): 

49 return str(log_file) 

50 pid_file = running_dir / f"{stem}.pid" 

51 if pid_file.exists(): 

52 return f"(expected {log_file}, missing)" 

53 return "(foreground run — output went to terminal)" 

54 

55 

56def _get_events_info(running_dir: Path, stem: str) -> tuple[str | None, str | None]: 

57 """Return (events_file_str, formatted_detail_line) for the events.jsonl file. 

58 

59 Returns (None, None) if the file doesn't exist. 

60 Detail line format: 'Events: <path> (N events, last Xm ago)' 

61 """ 

62 events_file = running_dir / f"{stem}.events.jsonl" 

63 if not events_file.exists(): 

64 return None, None 

65 try: 

66 lines = [ln for ln in events_file.read_text().splitlines() if ln.strip()] 

67 count = len(lines) 

68 detail = f"({count} events)" 

69 if lines: 

70 try: 

71 last_entry = json.loads(lines[-1]) 

72 last_ts = last_entry.get("ts") 

73 if last_ts is not None: 

74 from datetime import UTC, datetime 

75 

76 dt = datetime.fromisoformat(last_ts) 

77 age = (datetime.now(tz=UTC) - dt).total_seconds() 

78 detail = f"({count} events, last {_format_relative_time(age)})" 

79 except (json.JSONDecodeError, AttributeError, ValueError, OverflowError): 

80 pass 

81 return str(events_file), f"Events: {events_file} {detail}" 

82 except OSError: 

83 return str(events_file), f"Events: {events_file}" 

84 

85 

86def _kill_with_timeout(pid: int, label: str, logger: Logger) -> None: 

87 """Send SIGTERM to pid; escalate to SIGKILL after 10 s if still alive.""" 

88 os.kill(pid, signal.SIGTERM) 

89 for _ in range(10): 

90 time.sleep(1) 

91 if not _process_alive(pid): 

92 return 

93 try: 

94 os.kill(pid, signal.SIGKILL) 

95 logger.warning(f"Sent SIGKILL to {label} (PID: {pid})") 

96 except OSError: 

97 pass 

98 

99 

100def _status_single( 

101 instance_id: str | None, 

102 state: LoopState, 

103 loop_name: str, 

104 running_dir: Path, 

105 args: argparse.Namespace | None, 

106) -> int: 

107 """Render status for one instance (human-readable or JSON).""" 

108 from little_loops.cli.output import print_json 

109 

110 stem = instance_id or loop_name 

111 persistence = StatePersistence(loop_name, running_dir.parent, instance_id=instance_id) 

112 state = _reconcile_stale_running(state, persistence, running_dir, stem) 

113 

114 pid_file = running_dir / f"{stem}.pid" 

115 pid = _read_pid_file(pid_file) 

116 pid_source: str | None = "pid_file" if pid is not None else None 

117 

118 if pid is None: 

119 lock_file_path = running_dir / f"{stem}.lock" 

120 if lock_file_path.exists(): 

121 try: 

122 with open(lock_file_path) as _lf: 

123 lock_data = json.load(_lf) 

124 pid = lock_data.get("pid") 

125 if pid is not None: 

126 pid_source = "lock_file" 

127 except (json.JSONDecodeError, KeyError, OSError): 

128 pass 

129 

130 log_file = running_dir / f"{stem}.log" 

131 log_file_str: str | None = None 

132 log_updated_ago: str | None = None 

133 last_event: str | None = None 

134 if log_file.exists(): 

135 log_file_str = str(log_file) 

136 age_seconds = time.time() - log_file.stat().st_mtime 

137 log_updated_ago = _format_relative_time(age_seconds) 

138 try: 

139 lines = log_file.read_text().splitlines() 

140 non_empty = [ln for ln in lines if ln.strip()] 

141 last_event = non_empty[-1] if non_empty else None 

142 except OSError: 

143 last_event = None 

144 

145 events_file_str, events_detail_line = _get_events_info(running_dir, stem) 

146 

147 if getattr(args, "json", False): 

148 d = state.to_dict() 

149 d["pid"] = pid 

150 d["pid_source"] = pid_source 

151 d["log_file"] = log_file_str 

152 d["log_updated_ago"] = log_updated_ago 

153 d["last_event"] = last_event 

154 d["events_file"] = events_file_str 

155 print_json(d) 

156 return 0 

157 

158 print(f"Loop: {state.loop_name}") 

159 print(f"Status: {state.status}") 

160 print(f"Current state: {state.current_state}") 

161 print(f"Iteration: {state.iteration}") 

162 print(f"Started: {state.started_at}") 

163 print(f"Updated: {state.updated_at}") 

164 

165 if pid is not None: 

166 if _process_alive(pid): 

167 print(f"PID: {pid} (running)") 

168 else: 

169 print(f"PID: {pid} (not running - stale PID file)") 

170 

171 log_label = _format_log_label(running_dir, stem) 

172 print(f"Log: {log_label}") 

173 if log_file_str is not None: 

174 print(f"Log updated: {log_updated_ago}") 

175 if last_event: 

176 print(f"Last event: {last_event}") 

177 

178 if events_detail_line is not None: 

179 print(events_detail_line) 

180 

181 if state.continuation_prompt: 

182 prompt_preview = state.continuation_prompt[:200] 

183 if len(state.continuation_prompt) > 200: 

184 prompt_preview += "..." 

185 print(f"Continuation context: {prompt_preview}") 

186 return 0 

187 

188 

189def cmd_status( 

190 loop_name: str, 

191 loops_dir: Path, 

192 logger: Logger, 

193 args: argparse.Namespace | None = None, 

194) -> int: 

195 """Show loop status.""" 

196 from little_loops.cli.output import print_json 

197 

198 running_dir = loops_dir / ".running" 

199 instances = _find_instances(loop_name, running_dir) 

200 

201 if not instances: 

202 logger.error(f"No state found for: {loop_name}") 

203 return 1 

204 

205 if len(instances) == 1: 

206 instance_id, state = instances[0] 

207 return _status_single(instance_id, state, loop_name, running_dir, args) 

208 

209 # Multiple instances: aggregate display 

210 if getattr(args, "json", False): 

211 result_list = [] 

212 for instance_id, state in instances: 

213 stem = instance_id or loop_name 

214 persistence = StatePersistence(loop_name, loops_dir, instance_id=instance_id) 

215 state = _reconcile_stale_running(state, persistence, running_dir, stem) 

216 pid_file = running_dir / f"{stem}.pid" 

217 pid = _read_pid_file(pid_file) 

218 pid_source: str | None = "pid_file" if pid is not None else None 

219 if pid is None: 

220 lock_file_path = running_dir / f"{stem}.lock" 

221 if lock_file_path.exists(): 

222 try: 

223 with open(lock_file_path) as _lf: 

224 lock_data = json.load(_lf) 

225 pid = lock_data.get("pid") 

226 if pid is not None: 

227 pid_source = "lock_file" 

228 except (json.JSONDecodeError, KeyError, OSError): 

229 pass 

230 log_file = running_dir / f"{stem}.log" 

231 d = state.to_dict() 

232 d["instance_id"] = instance_id 

233 d["pid"] = pid 

234 d["pid_source"] = pid_source 

235 if log_file.exists(): 

236 d["log_file"] = str(log_file) 

237 d["log_updated_ago"] = _format_relative_time(time.time() - log_file.stat().st_mtime) 

238 else: 

239 d["log_file"] = None 

240 d["log_updated_ago"] = None 

241 events_file_str, _ = _get_events_info(running_dir, stem) 

242 d["events_file"] = events_file_str 

243 result_list.append(d) 

244 print_json(result_list) 

245 return 0 

246 

247 print(f"{len(instances)} instances of '{loop_name}':") 

248 for i, (instance_id, state) in enumerate(instances, 1): 

249 stem = instance_id or loop_name 

250 persistence = StatePersistence(loop_name, loops_dir, instance_id=instance_id) 

251 state = _reconcile_stale_running(state, persistence, running_dir, stem) 

252 pid_file = running_dir / f"{stem}.pid" 

253 pid = _read_pid_file(pid_file) 

254 if pid is None: 

255 lock_file_path = running_dir / f"{stem}.lock" 

256 if lock_file_path.exists(): 

257 try: 

258 with open(lock_file_path) as _lf: 

259 lock_data = json.load(_lf) 

260 pid = lock_data.get("pid") 

261 except (json.JSONDecodeError, KeyError, OSError): 

262 pass 

263 log_file = running_dir / f"{stem}.log" 

264 

265 print() 

266 print(f"[{i}] {stem}") 

267 print(f" Status: {state.status}") 

268 print(f" Current state: {state.current_state}") 

269 print(f" Iteration: {state.iteration}") 

270 if pid is not None: 

271 if _process_alive(pid): 

272 print(f" PID: {pid} (running)") 

273 else: 

274 print(f" PID: {pid} (not running - stale PID file)") 

275 log_label = _format_log_label(running_dir, stem) 

276 print(f" Log: {log_label}") 

277 if log_file.exists(): 

278 age_seconds = time.time() - log_file.stat().st_mtime 

279 print(f" Log updated: {_format_relative_time(age_seconds)}") 

280 _, events_detail_line = _get_events_info(running_dir, stem) 

281 if events_detail_line is not None: 

282 print(f" {events_detail_line}") 

283 return 0 

284 

285 

286def cmd_stop( 

287 loop_name: str, 

288 loops_dir: Path, 

289 logger: Logger, 

290) -> int: 

291 """Stop a running loop.""" 

292 from little_loops.fsm.persistence import StatePersistence # noqa: PLC0415 

293 

294 running_dir = loops_dir / ".running" 

295 instances = _find_instances(loop_name, running_dir) 

296 

297 if not instances: 

298 logger.error(f"No state found for: {loop_name}") 

299 return 1 

300 

301 running_instances = [(iid, s) for iid, s in instances if s.status == "running"] 

302 

303 if not running_instances: 

304 # Secondary check: an orphaned lock-file with a live PID blocks scope 

305 # acquisition even when state is not "running". Kill the holder and release. 

306 for instance_id, state in instances: 

307 stem = instance_id or loop_name 

308 lock_file = running_dir / f"{stem}.lock" 

309 if lock_file.exists(): 

310 lock_pid: int | None = None 

311 try: 

312 with open(lock_file) as _lf: 

313 lock_data = json.load(_lf) 

314 lock_pid = lock_data.get("pid") 

315 except (json.JSONDecodeError, KeyError, OSError): 

316 pass 

317 if lock_pid and _process_alive(lock_pid): 

318 logger.warning( 

319 f"Loop state is '{state.status}' but lock file holds live PID {lock_pid}. " 

320 "Killing orphaned lock holder..." 

321 ) 

322 _kill_with_timeout(lock_pid, stem, logger) 

323 lock_file.unlink(missing_ok=True) 

324 logger.success(f"Released orphaned scope lock for {stem}") 

325 return 0 

326 elif lock_pid is not None: 

327 # Dead PID: stale lock file, just remove it 

328 lock_file.unlink(missing_ok=True) 

329 logger.info(f"Removed stale lock file for {stem}") 

330 return 0 

331 _, state = instances[0] 

332 logger.error(f"Loop not running: {loop_name} (status: {state.status})") 

333 return 1 

334 

335 for instance_id, state in running_instances: 

336 persistence = StatePersistence(loop_name, loops_dir, instance_id=instance_id) 

337 stem = instance_id or loop_name 

338 pid_file = running_dir / f"{stem}.pid" 

339 pid = _read_pid_file(pid_file) 

340 

341 if pid is not None: 

342 if _process_alive(pid): 

343 _kill_with_timeout(pid, stem, logger) 

344 state.status = "interrupted" 

345 persistence.save_state(state) 

346 pid_file.unlink(missing_ok=True) 

347 logger.success(f"Stopped {stem} (PID: {pid})") 

348 else: 

349 # Process already exited: preserve its final status, only clean up PID file 

350 logger.info(f"Process {pid} not running, cleaning up PID file") 

351 pid_file.unlink(missing_ok=True) 

352 else: 

353 # No PID file: no background process tracked, update state only 

354 state.status = "interrupted" 

355 persistence.save_state(state) 

356 logger.success(f"Marked {stem} as interrupted") 

357 

358 return 0 

359 

360 

361def cmd_resume( 

362 loop_name: str, 

363 args: argparse.Namespace, 

364 loops_dir: Path, 

365 logger: Logger, 

366) -> int: 

367 """Resume an interrupted loop.""" 

368 import os 

369 

370 from little_loops.fsm.persistence import RESUMABLE_STATUSES, PersistentExecutor 

371 

372 running_dir = loops_dir / ".running" 

373 running_dir.mkdir(parents=True, exist_ok=True) 

374 

375 # Discover all instances and resolve to a single resumable one. 

376 instances = _find_instances(loop_name, running_dir) 

377 resumable = [(iid, s) for iid, s in instances if s.status in RESUMABLE_STATUSES] 

378 

379 explicit_instance_id = getattr(args, "instance_id", None) 

380 if explicit_instance_id: 

381 filtered: list[tuple[str | None, LoopState]] = [ 

382 (iid, s) for iid, s in resumable if iid == explicit_instance_id 

383 ] 

384 if not filtered: 

385 print( 

386 f"Instance '{explicit_instance_id}' not found among resumable instances of '{loop_name}'." 

387 ) 

388 if resumable: 

389 print("Resumable instances:") 

390 for iid, _ in resumable: 

391 print(f" {iid or loop_name}") 

392 return 1 

393 resumable = filtered 

394 elif len(resumable) > 1: 

395 # Auto-select the most recent instance by sorted filename (matches cmd_monitor()). 

396 selected_id = resumable[-1][0] 

397 print(f"Auto-selected latest instance: {selected_id}") 

398 resumable = [resumable[-1]] 

399 

400 # Use discovered instance_id (or fall back to args / None for no-state case) 

401 if resumable: 

402 instance_id: str | None = resumable[0][0] 

403 state_for_display = resumable[0][1] 

404 else: 

405 instance_id = None 

406 state_for_display = None 

407 

408 # Background mode: spawn detached process and return 

409 if getattr(args, "background", False): 

410 if instance_id is None: 

411 print(f"No resumable instances of '{loop_name}'.") 

412 return 1 

413 return run_background( 

414 loop_name, args, loops_dir, subcommand="resume", instance_id=instance_id 

415 ) 

416 

417 # Register PID file for all foreground runs so cmd_stop can send SIGTERM (BUG-639). 

418 # Background-spawned processes (foreground_internal=True) have their PID written by the 

419 # parent in run_background(); plain foreground runs must write their own PID here. 

420 if instance_id is None: 

421 instance_id = getattr(args, "instance_id", None) 

422 

423 pid_file = running_dir / f"{instance_id or loop_name}.pid" 

424 foreground_pid_file: Path | None = pid_file 

425 

426 if not getattr(args, "foreground_internal", False): 

427 pid_file.parent.mkdir(parents=True, exist_ok=True) 

428 pid_file.write_text(str(os.getpid())) 

429 

430 def _cleanup_pid() -> None: 

431 pid_file.unlink(missing_ok=True) 

432 

433 atexit.register(_cleanup_pid) 

434 

435 try: 

436 fsm = load_loop(loop_name, loops_dir, logger) 

437 except FileNotFoundError as e: 

438 logger.error(str(e)) 

439 return 1 

440 except ValueError as e: 

441 logger.error(f"Validation error: {e}") 

442 return 1 

443 

444 if getattr(args, "baseline", False): 

445 logger.error( 

446 "--baseline is not supported with resume. Start a fresh run with 'll-loop run'." 

447 ) 

448 return 1 

449 

450 try: 

451 loop_path = resolve_loop_path(loop_name, loops_dir) 

452 except FileNotFoundError: 

453 loop_path = None 

454 

455 for kv in getattr(args, "context", None) or []: 

456 if "=" not in kv: 

457 raise SystemExit(f"Invalid --context format: {kv!r} (expected KEY=VALUE)") 

458 key, _, value = kv.partition("=") 

459 fsm.context[key.strip()] = value.strip() 

460 

461 # Re-inject run_dir using the same instance_id as the original run so resumed 

462 # loops write artifacts to the same directory they started with. 

463 if "run_dir" not in fsm.context and instance_id is not None: 

464 fsm.context["run_dir"] = str(loops_dir / "runs" / instance_id) + "/" 

465 

466 if getattr(args, "delay", None) is not None: 

467 fsm.backoff = args.delay 

468 

469 # Apply YAML loop config env-var overrides (CLI flags below overwrite these) 

470 if fsm.config is not None and isinstance(fsm.config.handoff_threshold, int): 

471 os.environ["LL_HANDOFF_THRESHOLD"] = str(fsm.config.handoff_threshold) 

472 

473 if getattr(args, "handoff_threshold", None) is not None: 

474 if not (1 <= args.handoff_threshold <= 100): 

475 raise SystemExit("--handoff-threshold must be between 1 and 100") 

476 os.environ["LL_HANDOFF_THRESHOLD"] = str(args.handoff_threshold) 

477 

478 # Show context if resuming from a handoff 

479 if state_for_display and state_for_display.status == "awaiting_continuation": 

480 print(f"Resuming from context handoff (iteration {state_for_display.iteration})...") 

481 if state_for_display.continuation_prompt: 

482 prompt_preview = state_for_display.continuation_prompt[:500] 

483 if len(state_for_display.continuation_prompt) > 500: 

484 prompt_preview += "..." 

485 print(f"Context: {prompt_preview}") 

486 print() 

487 

488 from little_loops.config import BRConfig 

489 from little_loops.design_tokens import load_design_tokens, render_as_prompt_context 

490 from little_loops.extension import wire_extensions 

491 from little_loops.fsm.rate_limit_circuit import RateLimitCircuit 

492 from little_loops.transport import wire_transports 

493 

494 config = BRConfig(Path.cwd()) 

495 

496 if "design_tokens_context" not in fsm.context: 

497 _tokens = load_design_tokens(config) 

498 fsm.context["design_tokens_context"] = render_as_prompt_context(_tokens) if _tokens else "" 

499 

500 circuit = ( 

501 RateLimitCircuit(Path(config.commands.rate_limits.circuit_breaker_path)) 

502 if config.commands.rate_limits.circuit_breaker_enabled 

503 else None 

504 ) 

505 executor = PersistentExecutor( 

506 fsm, loops_dir=loops_dir, circuit=circuit, instance_id=instance_id 

507 ) 

508 

509 # Register signal handlers for graceful shutdown (same as cmd_run) 

510 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

511 

512 wire_extensions(executor.event_bus, config.extensions, executor=executor) 

513 wire_transports(executor.event_bus, config.events) 

514 

515 # Route through run_foreground (BUG-1645) so the resume path shares the 

516 # display-callback wiring that cmd_run gets via run_foreground. Without this 

517 # the FSM event bus has no subscriber on a resumed run, leaving the terminal 

518 # silent for the entire duration (no per-iteration lines, no FSM diagram). 

519 from little_loops.cli.loop._helpers import run_foreground 

520 

521 _edge_label_colors = config.cli.colors.fsm_edge_labels.to_dict() 

522 _highlight_color = config.cli.colors.fsm_active_state 

523 _badges = config.loops.glyphs.to_dict() 

524 

525 try: 

526 return run_foreground( 

527 executor, 

528 fsm, 

529 args, 

530 highlight_color=_highlight_color, 

531 edge_label_colors=_edge_label_colors, 

532 badges=_badges, 

533 mode="resume", 

534 instance_id=instance_id, 

535 running_dir=running_dir, 

536 loop_path=loop_path, 

537 model=fsm.llm.model, 

538 ) 

539 finally: 

540 executor.close_transports() 

541 

542 

543def _print_last_state(state: LoopState) -> None: 

544 """Print last-known state (used when not actively tailing).""" 

545 print(f"Loop: {state.loop_name}") 

546 print(f"Status: {state.status}") 

547 print(f"Current state: {state.current_state}") 

548 print(f"Iteration: {state.iteration}") 

549 

550 

551def cmd_monitor(args: argparse.Namespace, loops_dir: Path) -> int: 

552 """Attach to a running loop and render its FSM state in realtime. 

553 

554 Read-only attach: tails ``<stem>.events.jsonl`` and forwards events to a 

555 ``StateFeedRenderer``. Ctrl-C detaches without sending any signal to the 

556 loop process (FEAT-1764). 

557 """ 

558 loop_name = args.loop 

559 running_dir = loops_dir / ".running" 

560 instances = _find_instances(loop_name, running_dir) 

561 

562 if not instances: 

563 print(f"No instances of '{loop_name}' found") 

564 return 1 

565 

566 instance_id, state = instances[-1] 

567 stem = instance_id or loop_name 

568 

569 pid = _read_pid_file(running_dir / f"{stem}.pid") 

570 if pid is None: 

571 pid = getattr(state, "pid", None) 

572 

573 if pid is None or not _process_alive(pid): 

574 _print_last_state(state) 

575 return 0 

576 

577 # Fabricate missing Namespace attrs that StateFeedRenderer reads. 

578 for attr, default in ( 

579 ("quiet", False), 

580 ("verbose", False), 

581 ("show_diagrams", None), 

582 ("clear", True), 

583 ("diagram_edge_labels", None), 

584 ("diagram_state_detail", None), 

585 ("diagram_scope", None), 

586 ("follow", False), 

587 ): 

588 if not hasattr(args, attr): 

589 setattr(args, attr, default) 

590 

591 try: 

592 fsm = load_loop(loop_name, loops_dir, Logger()) 

593 except (FileNotFoundError, ValueError) as e: 

594 print(f"Cannot load loop '{loop_name}': {e}") 

595 return 1 

596 

597 try: 

598 loop_path = resolve_loop_path(loop_name, loops_dir) 

599 except FileNotFoundError: 

600 loop_path = None 

601 

602 # Late import: tests patch StateFeedRenderer at its module-of-origin 

603 # (little_loops.cli.loop._helpers.StateFeedRenderer); using a function-local 

604 # import ensures the patch takes effect at call time. 

605 from little_loops.cli.loop._helpers import ( 

606 StateFeedRenderer, 

607 _install_sigwinch_handler, 

608 _restore_sigwinch_handler, 

609 ) 

610 

611 renderer = StateFeedRenderer( 

612 fsm, args, loops_dir=loops_dir, loop_path=loop_path, model=fsm.llm.model 

613 ) 

614 

615 events_file = running_dir / f"{stem}.events.jsonl" 

616 

617 if not events_file.exists(): 

618 _print_last_state(state) 

619 return 0 

620 

621 sigwinch_installed = False 

622 if renderer.in_pinned_mode: 

623 _install_sigwinch_handler() 

624 sigwinch_installed = True 

625 

626 try: 

627 with open(events_file, encoding="utf-8") as ev_f: 

628 ev_f.seek(0, 2) 

629 while True: 

630 progressed = False 

631 try: 

632 line = ev_f.readline() 

633 except FileNotFoundError: 

634 break 

635 if line: 

636 progressed = True 

637 stripped = line.strip() 

638 if stripped: 

639 try: 

640 event = json.loads(stripped) 

641 except json.JSONDecodeError: 

642 event = None 

643 if event is not None: 

644 renderer.handle_event(event) 

645 if not progressed: 

646 if not _process_alive(pid): 

647 break 

648 if not events_file.exists(): 

649 break 

650 time.sleep(0.1) 

651 except KeyboardInterrupt: 

652 return 0 

653 finally: 

654 if sigwinch_installed: 

655 _restore_sigwinch_handler() 

656 

657 return 0