← Commit history

Fix remote control recovery after network outages; prepare 1.3.4

John Lauer ·66eefc8125 ·1mo ago ·parent 5978e53
7 files changed +307−47
.gitignoreadded+2
@@ -0,0 +1,2 @@+__pycache__/+*.pyc
README.md+6−1
@@ -81,9 +81,14 @@ app or claude.ai/code whenever you want. Nothing starts until you say yes: the next time you send a prompt in Claude Code or Codex, the AI offers it once, and you answer in chat. -- **ah** (Hydrogen desktop container): a cron watchdog restarts the server within 2 minutes.+- **ah** (Hydrogen desktop container): a cron watchdog checks every 2 minutes. - **hc** (Hydrogen Cloud): no cron exists there, so the prompt hook restarts it on your next chat. +After five starts within an hour, further starts are spaced at least 15 minutes apart.+The `failed` state is a cooldown: cron (or the next cloud-container prompt) retries+automatically when it expires. `claude-rc status` shows the retry time. Disabling or+declining still prevents all automatic starts. Concurrent watchdog calls share a lock.+ ``` claude-rc status      claude-rc enable      claude-rc disable      claude-rc logs ```
SKILL.md+14−7
@@ -121,16 +121,16 @@ claude-rc decline     # they said no: the hook never offers again sessions to their claude.ai account from any device; that is their call, not yours.  States: `unasked -> enabled | declined`, plus `disabled` (turned off later) and `failed`-(the server kept exiting; the hook reports that at most every 6 hours).+(a restart cooldown after repeated exits; the watchdog retries automatically).  ## ah vs hc: supervision differs, and you should say which the user has  - **ah** (Adom Hydrogen desktop container, `ADOM_DESKTOP_MODE=local`): cron exists, so-  `enable` registers `@reboot` and `*/2 * * * * claude-rc check`. The server comes back-  within two minutes even while the user is away -- this is what makes "open a thread from-  my phone whenever I want" actually true.+  `enable` registers `@reboot` and `*/2 * * * * claude-rc check`. An absent server is+  restarted on the next check, subject to the restart cooldown below. Checks continue+  while the user is away. - **hc** (Hydrogen Cloud): no cron and no systemd, so THE PROMPT HOOK IS THE WATCHDOG. On-  every chat submit it relaunches a dead server and tells you in one line. If it dies+  every chat submit it checks a dead server and relaunches when its cooldown allows. If it dies   while the user is away it stays down until their next prompt. Say so plainly rather than   promising ah behaviour on hc. @@ -164,7 +164,14 @@ used. Detection is `ADOM_DESKTOP_MODE=local` plus a live crond.   verb a no-op. - A restart is a NEW server. Sessions opened through the old one are still on disk and   resumable with `claude --resume <id>`.-- Crash-loop guard: five launches within an hour flips the state to `failed` rather than-  spinning. The usual cause is a signed-out account. `claude-rc enable` retries.+- Crash-loop guard: after five starts within an hour, subsequent attempts are spaced+  at least 15 minutes apart. `failed` means cooling down, not permanently stopped.+  `status` shows the retry time; cron or the next prompt retries after that time.+  Legacy failed state recovers through the same check, without another enable command.+  A live server is preserved, including a successful fifth start an older keeper+  incorrectly marked failed. Launch counts age out after an hour.+- The keeper uses `flock` to serialize state changes and launch decisions across cron+  and prompt hooks. The server closes the lock descriptor when launched. Explicit+  disabled/declined/unasked states are never re-enabled by watchdog checks. - It dies with the container. On ah the `@reboot` line brings it back; on hc the next   prompt does.
claude-rc+91−34
@@ -6,7 +6,7 @@ #   claude-rc hook      the UserPromptSubmit hook. Fast + silent in the common case. #                       Offers setup once (prints one line into the model's context), #                       and on hc (no cron) doubles as the watchdog.-#   claude-rc check     the watchdog: launch `claude rc` if it is not running.+#   claude-rc check     the watchdog: launch if absent, with cooldown after failures. #   claude-rc enable    record consent, register cron on ah, launch now. #   claude-rc decline   record "no thanks"; the hook never offers again. #   claude-rc disable   stop the server, drop cron, stop watching (re-enable any time).@@ -46,6 +46,12 @@ st_set() { # st_set KEY VALUE   { [ -f "$STATE" ] && grep -v "^$1=" "$STATE"; echo "$1=$2"; } > "$tmp" 2>/dev/null   mv "$tmp" "$STATE" }+st_del() {+  local tmp="$STATE.tmp.$$"+  [ -f "$STATE" ] || return 0+  grep -v "^$1=" "$STATE" > "$tmp" || true+  mv "$tmp" "$STATE"+} now() { date +%s; } state() { local s; s="$(st_get state)"; echo "${s:-unasked}"; } @@ -108,10 +114,7 @@ rotate_log() {   [ -f "$LOG" ] && [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt 5000000 ] && mv "$LOG" "$LOG.1"   return 0 }-launch() {-  cd "$PROJECT_DIR" || return 1-  rotate_log-  echo "[$(date -Is)] claude-rc: launching (name=$(rc_name), env=$(env_name))" >> "$LOG"+spawn_server() {   # STDIN MUST STAY OPEN. `claude rc` is a TUI that waits on a keypress ("space to show QR   # code"); with </dev/null it connects, reads EOF, and exits within a second (the log shows   # "Connected" five times, then the crash-loop guard marks it failed). A pipe that never@@ -122,48 +125,72 @@ launch() {   # binary on an Adom Hydrogen workspace) prints "Enable Remote Control? (y/n)" even when   # stdin is not a TTY and waits for the answer, so a silent pipe leaves it "running" but   # never connected. One "y" answers it; a build that does not ask ignores the byte.-  setsid nohup bash -c '{ printf "y\n"; sleep infinity; } | exec claude rc --name "$1"' _ "$(rc_name)" >> "$LOG" 2>&1 &-  # crash-loop guard: remember launch times, keep the last hour's worth+  # The daemon must not inherit the watchdog lock.+  setsid nohup bash -c '{ printf "y\n"; sleep infinity; } | exec claude rc --name "$1"' _ "$(rc_name)" 9>&- >> "$LOG" 2>&1 &+}+launch() {+  cd "$PROJECT_DIR" || return 1+  # Check the budget BEFORE starting. After five starts in an hour, permit+  # one probe every 15 minutes. A temporary outage must never latch us off.   local t; t="$(now)"   local hist; hist="$(st_get launches)"-  local kept=""; local x-  for x in ${hist//,/ }; do [ $((t - x)) -lt 3600 ] && kept="$kept,$x"; done-  kept="${kept#,}"; kept="${kept:+$kept,}$t"-  st_set launches "$kept"-  st_set last_launch "$t"-  local n; n=$(echo "$kept" | tr ',' '\n' | grep -c .)+  local kept=""; local x; local n=0; local latest=0+  for x in ${hist//,/ }; do+    [[ "$x" =~ ^[0-9]+$ ]] || continue+    if [ "$x" -le "$t" ] && [ $((t - x)) -lt 3600 ]; then+      kept="${kept:+$kept,}$x"; n=$((n + 1))+      [ "$x" -gt "$latest" ] && latest="$x"+    fi+  done   if [ "$n" -ge 5 ]; then-    st_set state failed-    st_set failed_at "$t"-    st_set fail_reason "$(tr -d '\r' < "$LOG" | grep -v '^\s*$' | grep -v 'claude-rc:' | tail -3 | tr '\n' ' ' | cut -c1-300)"-    echo "[$(date -Is)] claude-rc: $n launches within an hour; marking failed. Run 'claude-rc enable' to retry." >> "$LOG"-    return 2+    local retry_at=$((latest + 900))+    if [ "$t" -lt "$retry_at" ]; then+      if [ "$(state)" != failed ]; then+        st_set state failed+        st_set failed_at "$t"+        st_set fail_reason "$(tr -d '\r' < "$LOG" 2>/dev/null | grep -v '^\s*$' | grep -v 'claude-rc:' | tail -3 | tr '\n' ' ' | cut -c1-300)"+        echo "[$(date -Is)] claude-rc: restart budget exhausted; cooling down until epoch $retry_at, then retrying automatically." >> "$LOG"+      fi+      st_set retry_after "$retry_at"+      return 2+    fi   fi+  rotate_log+  echo "[$(date -Is)] claude-rc: launching (name=$(rc_name), env=$(env_name))" >> "$LOG"+  spawn_server+  st_set launches "${kept:+$kept,}$t"+  st_set last_launch "$t"+  st_set state enabled+  st_del retry_after   return 0 } check() {-  [ "$(state)" = "enabled" ] || return 0-  if [ -n "$(running_pid)" ]; then return 0; fi+  case "$(state)" in enabled|failed) ;; *) return 0 ;; esac+  if [ -n "$(running_pid)" ]; then+    # Older keepers marked the fifth attempt failed even if it stayed up.+    if [ "$(state)" = failed ]; then+      st_set state enabled+      st_del retry_after+    fi+    return 0+  fi   launch; return $? }  # ---- verbs --------------------------------------------------------------------- cmd_hook() {   case "$(state)" in-    enabled)+    enabled|failed)+      local result=0       if [ -z "$(running_pid)" ]; then-        if launch; then-          echo "adom: Claude Remote Control was not running on this container; relaunched it as '$(rc_name)'. It should reappear on claude.ai/code and the mobile app within a minute."-        else-          echo "adom: Claude Remote Control keeps exiting on this container and has been paused after repeated relaunches. Last output: $(st_get fail_reason). Tell the user; 'claude-rc logs' shows more and 'claude-rc enable' retries."+        launch || result=$?+        if [ "$result" -eq 0 ]; then+          echo "adom: Starting Claude Remote Control as '$(rc_name)'; 'claude-rc logs' confirms whether it connects."+        elif [ "$result" -ne 2 ]; then+          echo "adom: Claude Remote Control could not start in $PROJECT_DIR; 'claude-rc status' shows its prerequisites."         fi-      fi ;;-    failed)-      # remind at most every 6h-      local last; last="$(st_get fail_reported)"; local t; t="$(now)"-      if [ -z "$last" ] || [ $((t - last)) -gt 21600 ]; then-        st_set fail_reported "$t"-        echo "adom: Claude Remote Control is paused on this container after it kept exiting (last output: $(st_get fail_reason)). Mention it to the user once; 'claude-rc enable' retries, 'claude-rc disable' turns it off."+      else+        check       fi ;;     unasked)       prereqs_ok || exit 0@@ -175,6 +202,14 @@ cmd_hook() {       fi ;;     *) : ;;  # declined / disabled: silent   esac+  if [ "$(state)" = failed ]; then+    # Report a cooldown at most once every 6h; cron/the next prompt retries it.+    local last; last="$(st_get fail_reported)"; local t; t="$(now)"+    if [ -z "$last" ] || [ $((t - last)) -gt 21600 ]; then+      st_set fail_reported "$t"+      echo "adom: Claude Remote Control is cooling down after repeated exits; the watchdog will retry automatically after epoch $(st_get retry_after). Last output: $(st_get fail_reason)."+    fi+  fi   exit 0 } @@ -182,6 +217,7 @@ cmd_enable() {   st_set state enabled   st_set enabled_at "$(now)"   st_set launches ""+  st_del retry_after   st_set name "$(rc_name)"   local how="prompt-hook watchdog (hc: no cron here)"   if is_ah || has_cron; then@@ -219,14 +255,32 @@ cmd_status() {   echo "name:        $(rc_name)"   echo "project dir: $PROJECT_DIR"   echo "prereqs:     $(if prereqs_ok; then echo ok; else echo "missing (claude on PATH + workspace trust accepted in $PROJECT_DIR)"; fi)"-  [ "$(state)" = failed ] && echo "fail reason: $(st_get fail_reason)"+  if [ "$(state)" = failed ]; then+    echo "fail reason: $(st_get fail_reason)"+    local retry; retry="$(st_get retry_after)"+    if [[ "$retry" =~ ^[0-9]+$ ]]; then+      echo "auto retry:  $(date -d "@$retry" -Is) (next watchdog check after this time)"+    else+      echo "auto retry:  next watchdog check evaluates the previous launch budget"+    fi+  fi   echo "log:         $LOG"   [ -f "$LOG" ] && { echo "--- last lines:"; tr -d '\r' < "$LOG" | grep -v '^\s*$' | tail -4; }   return 0 } cmd_logs() { [ -f "$LOG" ] && tr -d '\r' < "$LOG" | grep -v '^\s*$' | tail -${1:-40}; } -case "${1:-status}" in+main() {+  # Cron and prompt hooks share state and a launch decision. Keep these atomic.+  case "${1:-status}" in+    hook|check|enable|decline|disable)+      exec 9>"$STATE_DIR/claude-rc.lock"+      if ! command -v flock >/dev/null 2>&1; then+        echo "claude-rc: flock is required for safe supervision" >&2; return 1+      fi+      flock -w 10 9 || return 1 ;;+  esac+  case "${1:-status}" in   hook)    cmd_hook ;;   check)   check ;;   enable)  cmd_enable ;;@@ -237,3 +291,6 @@ case "${1:-status}" in   -h|--help|help) sed -n 2,24p "$0" ;;   *) echo "claude-rc: unknown verb '$1' (hook|check|enable|decline|disable|status|logs)" >&2; exit 2 ;; esac+}++if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then main "$@"; fi
package.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "hook",-  "version": "1.3.3",+  "version": "1.3.4",   "type": "app",   "org": "adom",   "bundled": true,@@ -56,7 +56,7 @@       "mode": "0755"     }   ],-  "changelog": "1.3.2: claude-rc feeds the consent line on launch: Claude Code 2.1.251 (the VS Code extension's bundled binary on an Adom Hydrogen workspace) prints 'Enable Remote Control? (y/n)' even without a TTY and waits, so 1.3.1's silent pipe left the server running but never connected. Verified Connected on an Adom Hydrogen WSL2 workspace (ah, cron) and a cloud container (hc). 1.3.1: claude-rc keeps stdin open when launching `claude rc` (sleep infinity pipe). With </dev/null the server connected and exited within a second, five times, and the crash-loop guard marked it failed on every container. The watchdog's pgrep pattern also required a path prefix that a PATH launch never has, so it relaunched a live server and reported it as not running. Found on an Adom cloud container and an Adom Hydrogen WSL2 workspace, 2026-08-30. 1.3.0: add claude-rc, which keeps Claude Code Remote Control (`claude rc`) alive so a user can open new AI threads on their container from the Claude mobile app or claude.ai/code. It rides in adom/hook because it needs exactly what this package already owns: a UserPromptSubmit hook and a cron entry. Consent-first -- installing only wires the offer, and the AI asks the user once. Supervision differs by environment: a 2-minute cron watchdog on ah, and the prompt hook itself on hc, which has neither cron nor systemd. No dependency changes.",+  "changelog": "1.3.4: Claude Remote Control automatically recovers after network outages instead of remaining in failed state. After five starts in an hour, further attempts are spaced at least 15 minutes apart. Serialize watchdog calls to prevent duplicate launches, preserve running sessions and disabled/declined choices, and display the next retry time. Includes regression coverage for cooldowns, legacy failures, concurrent checks, and launch lock release.",   "discovery_pitch": "Use when adom packages are behind or an update failed, and when the user asks about Claude Remote Control -- opening AI threads on this container from their phone or claude.ai/code.",   "sample_prompts": [     {
page.json+3−3
@@ -1,6 +1,6 @@ {   "slug": "hook",-  "version": "1.3.3",+  "version": "1.3.4",   "type": "app",   "org": "adom",   "bundled": true,@@ -50,7 +50,7 @@     "name": "Kyle Bergstedt",     "email": "[email protected]"   },-  "changelog": "1.3.2: claude-rc feeds the consent line on launch: Claude Code 2.1.251 (the VS Code extension's bundled binary on an Adom Hydrogen workspace) prints 'Enable Remote Control? (y/n)' even without a TTY and waits, so 1.3.1's silent pipe left the server running but never connected. Verified Connected on an Adom Hydrogen WSL2 workspace (ah, cron) and a cloud container (hc). 1.3.1: claude-rc keeps stdin open when launching `claude rc` (sleep infinity pipe). With </dev/null the server connected and exited within a second, five times, and the crash-loop guard marked it failed on every container. The watchdog's pgrep pattern also required a path prefix that a PATH launch never has, so it relaunched a live server and reported it as not running. Found on an Adom cloud container and an Adom Hydrogen WSL2 workspace, 2026-08-30. 1.3.0: add claude-rc, which keeps Claude Code Remote Control (`claude rc`) alive so a user can open new AI threads on their container from the Claude mobile app or claude.ai/code. It rides in adom/hook because it needs exactly what this package already owns: a UserPromptSubmit hook and a cron entry. Consent-first -- installing only wires the offer, and the AI asks the user once. Supervision differs by environment: a 2-minute cron watchdog on ah, and the prompt hook itself on hc, which has neither cron nor systemd. No dependency changes.",+  "changelog": "1.3.4: Claude Remote Control automatically recovers after network outages instead of remaining in failed state. After five starts in an hour, further attempts are spaced at least 15 minutes apart. Serialize watchdog calls to prevent duplicate launches, preserve running sessions and disabled/declined choices, and display the next retry time. Includes regression coverage for cooldowns, legacy failures, concurrent checks, and launch lock release.",   "discovery_pitch": "Use when adom packages are behind or an update failed, and when the user asks about Claude Remote Control -- opening AI threads on this container from their phone or claude.ai/code.",   "sample_prompts": [     {@@ -78,4 +78,4 @@       "mode": "0755"     }   ]-}\ No newline at end of file+}
tests/test_claude_rc.pyadded+189
@@ -0,0 +1,189 @@+"""Exercise keeper state/locking with fake time and server, never real Claude."""+import os+from pathlib import Path+import subprocess+import signal+import sys+import tempfile+import time+import unittest+++SOURCE = Path(__file__).resolve().parents[1] / "claude-rc"+HARNESS = r'''+source "$RC_TEST_SOURCE"+STATE_DIR="$RC_TEST_DIR"+STATE="$STATE_DIR/state"+LOG="$STATE_DIR/log"+PROJECT_DIR="$STATE_DIR"+now() { cat "$STATE_DIR/time"; }+running_pid() { [ -f "$STATE_DIR/running" ] && echo 12345; }+spawn_server() {+  echo "$(now)" >> "$STATE_DIR/starts"+  if [ -f "$STATE_DIR/connect" ]; then+    sleep 0.1+    touch "$STATE_DIR/running"+  fi+}+main "$@"+'''+++class RecoveryTest(unittest.TestCase):+    def setUp(self):+        self.temp = tempfile.TemporaryDirectory(prefix="claude-rc-test-")+        self.addCleanup(self.temp.cleanup)+        self.root = Path(self.temp.name)+        self.env = dict(os.environ, RC_TEST_SOURCE=str(SOURCE), RC_TEST_DIR=str(self.root))+        self.env.pop("CLAUDE_RC_DISABLE", None)+        self.write_state(state="enabled")+        self.at(10000)++    def write_state(self, **state):+        (self.root / "state").write_text("".join(f"{k}={v}\n" for k, v in state.items()))++    def state(self):+        return dict(line.split("=", 1) for line in (self.root / "state").read_text().splitlines())++    def at(self, stamp):+        (self.root / "time").write_text(str(stamp))++    def run_keeper(self, verb="check", expected=0):+        p = subprocess.run(["bash", "-c", HARNESS, "test-keeper", verb],+                           env=self.env, capture_output=True, text=True, timeout=15)+        self.assertEqual(p.returncode, expected, p.stdout + p.stderr)+        return p.stdout++    def starts(self):+        p = self.root / "starts"+        return p.read_text().splitlines() if p.exists() else []++    def exhaust(self):+        for t in range(10000, 10500, 100):+            self.at(t)+            self.run_keeper()+        self.assertEqual(len(self.starts()), 5)+        self.assertEqual(self.state()["state"], "enabled")+        self.at(10500)+        self.run_keeper(expected=2)+        self.assertEqual(self.state()["state"], "failed")+        self.assertEqual(self.state()["retry_after"], "11300")++    def test_cooldown_retries_without_enable_and_limits_repeated_failure(self):+        self.exhaust()+        self.at(11299)+        self.run_keeper(expected=2)+        self.assertEqual(len(self.starts()), 5)+        self.at(11300)+        self.run_keeper()+        self.assertEqual(len(self.starts()), 6)+        self.at(11301)+        self.run_keeper(expected=2)+        self.assertEqual(self.state()["retry_after"], "12200")+        self.at(12200)+        self.run_keeper()+        self.assertEqual(len(self.starts()), 7)++    def test_reconnect_stays_up_without_duplicate(self):+        self.exhaust()+        self.at(11300)+        (self.root / "connect").touch()+        self.run_keeper()+        self.assertEqual(self.state()["state"], "enabled")+        for t in [11301, 11400, 14000]:+            self.at(t)+            self.run_keeper()+        self.assertEqual(len(self.starts()), 6)+        self.assertNotIn("retry_after", self.state())++    def test_old_failed_state_recovers(self):+        self.write_state(state="failed", failed_at=500,+                         launches="100,200,300,400,500", last_launch=500)+        self.run_keeper()+        self.assertEqual(self.state()["state"], "enabled")+        self.assertEqual(len(self.starts()), 1)++    def test_live_fifth_attempt_repairs_old_false_failure(self):+        self.write_state(state="failed", retry_after=20000)+        (self.root / "running").touch()+        self.run_keeper()+        self.assertEqual(self.state()["state"], "enabled")+        self.assertEqual(self.starts(), [])++    def test_disabled_declined_and_unasked_do_not_launch(self):+        for state in ["disabled", "declined", "unasked"]:+            with self.subTest(state=state):+                self.write_state(state=state)+                self.run_keeper()+                self.assertEqual(self.state()["state"], state)+                self.assertEqual(self.starts(), [])++    def test_prompt_hook_obeys_same_cooldown(self):+        self.exhaust()+        self.assertIn("cooling down", self.run_keeper("hook"))+        self.assertEqual(len(self.starts()), 5)+        self.assertEqual(self.run_keeper("hook"), "")+        self.at(11300)+        self.assertIn("Starting Claude Remote Control", self.run_keeper("hook"))+        self.assertEqual(len(self.starts()), 6)++    def test_concurrent_watchdogs_launch_once(self):+        (self.root / "connect").touch()+        processes = [subprocess.Popen(["bash", "-c", HARNESS, "test-keeper", "check"],+                                      env=self.env, stdout=subprocess.PIPE,+                                      stderr=subprocess.PIPE, text=True) for _ in range(8)]+        for p in processes:+            out, err = p.communicate(timeout=15)+            self.assertEqual(p.returncode, 0, out + err)+        self.assertEqual(len(self.starts()), 1)++    def test_clock_rollback_does_not_latch_cooldown(self):+        self.exhaust()+        self.at(9000)+        self.run_keeper()+        self.assertEqual(self.state()["state"], "enabled")++    def test_status_explains_automatic_retry(self):+        self.exhaust()+        self.assertIn("next watchdog check after this time", self.run_keeper("status"))++    def test_real_launch_pipeline_releases_watchdog_lock(self):+        # Use the real setsid/nohup/pipe code, substituting only the Claude binary.+        binary = self.root / "bin" / "claude"+        binary.parent.mkdir()+        binary.write_text(f"#!{sys.executable}\n" + r'''+import os, pathlib, time+p = pathlib.Path(os.environ["RC_TEST_DIR"])+(p / "child-lock").write_text(str(pathlib.Path("/proc/self/fd/9").exists()))+(p / "child-group").write_text(str(os.getpgrp()))+time.sleep(30)+''')+        binary.chmod(0o755)+        harness = HARNESS.split("spawn_server()", 1)[0] + r'''+export PATH="$STATE_DIR/bin:$PATH"+main check+'''+        group_file = self.root / "child-group"+        try:+            p = subprocess.run(["bash", "-c", harness], env=self.env,+                               capture_output=True, text=True, timeout=10)+            self.assertEqual(p.returncode, 0, p.stdout + p.stderr)+            deadline = time.monotonic() + 5+            while not group_file.exists() and time.monotonic() < deadline:+                time.sleep(0.02)+            self.assertTrue(group_file.exists(), "fake daemon did not start")+            self.assertEqual((self.root / "child-lock").read_text(), "False")+            p = subprocess.run(["flock", "-n", str(self.root / "claude-rc.lock"), "true"])+            self.assertEqual(p.returncode, 0, "daemon retained the watchdog lock")+        finally:+            if group_file.exists():+                group = int(group_file.read_text())+                if group != os.getpgrp():+                    try:+                        os.killpg(group, signal.SIGTERM)+                    except ProcessLookupError:+                        pass+++if __name__ == "__main__":+    unittest.main()