#!/usr/bin/env bash # ============================================================================= # Entrypoint for the 9Hits Viewer v6 + FeelingSurf Viewer + /health container. # # 9Hits side (we drive the viewer directly - we do NOT call the opaque # upstream /nh.sh anymore): # # * The viewer (nhviewer) is extracted from the image's baked-in tarball at # BUILD time (see Dockerfile) into /opt/9hits. No multi-minute bzip2 # extraction or download happens at container start (that stall was the # "Extracting 9hitsv6-linux64 ..." deadlock seen on weak free-tier CPUs). # * Xvfb provides a virtual display (:99 by default) - the v6 viewer needs # an X display even with --hide-browser=yes. Supervised: restarted if it # ever dies. (FeelingSurf uses its own :98.) # * Launch flow mirrors the official 9hits installer (9hitste/install): # 1. init pass: `nhviewer --exit-on-init` applies # access key / sessions / limits, then exits (bounded by a timeout); # 2. run pass: `nhviewer --auto-start --in-loop --render-to-terminal # [--reset-interval=...]` runs supervised under a pseudo-TTY # (run_pty.py) with a watchdog that restarts a wedged (silent AND # zero-CPU) viewer. # * Set DEFAULT_DL to download a different viewer build at container start. # # 9Hits stays OFF by default (NINEHITS_ENABLED=no) so a bare deploy never # surprises anyone. BUT both viewers can now share a 512 MB free instance # (e.g. Render free) via three cooperating layers: # # 1. LOW_MEMORY Chromium flags auto-applied on < 1 GB boxes # (--renderer-process-limit=1, --enable-low-end-device-mode, # --memory-model=low, V8 heap caps, caches off, no GPU process, ...) - # see NH_MEM_FLAGS / FS_MEM_FLAGS below. # 2. memguard.py - a memory guardian that watches total RSS against the # cgroup limit and restarts the HEAVIEST viewer when the pair would # otherwise exceed it, so the platform never OOM-kills the container. # 3. DUAL_VIEWER_MODE=time-slice / auto - if the box really cannot fit both # simultaneously, memguard alternates them (TIME_SLICE seconds each) so # only one Chromium is resident at a time (~50% uptime each, but NO OOM). # # Set NINEHITS_ENABLED=yes + DUAL_VIEWER_MODE=auto (as render.yaml / koyeb.yaml # already do) to run BOTH viewers on the free 512 MB plan. On >= 2 GB hosts # everything just runs concurrently and the guardian never intervenes. # # Health endpoint: GET /health on 0.0.0.0:$PORT (health_server.py). # Any extra positional arguments are forwarded to the nhviewer init pass. # ============================================================================= set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PORT="${PORT:-10000}" NH_DIR="${NH_DIR:-/opt/9hits}" NH_BIN="${NH_BIN:-$NH_DIR/nhviewer}" NH_DISPLAY="${NH_DISPLAY:-:99}" NH_RESOLUTION="${NH_RESOLUTION:-auto}" INIT_TIMEOUT="${INIT_TIMEOUT:-300}" NH_WATCHDOG="${NH_WATCHDOG:-yes}" NH_WATCHDOG_STUCK="${NH_WATCHDOG_STUCK:-600}" NH_RENDER_TO_TERMINAL="${NH_RENDER_TO_TERMINAL:-yes}" export PORT export DISPLAY="$NH_DISPLAY" export HOME="${HOME:-/root}" # glibc arena bloat control + aggressive heap return-to-OS - applies to both # Chromium-based viewers (and to Xvfb / python / bash). The trim threshold # makes glibc give freed pages back to the kernel instead of hoarding them. export MALLOC_ARENA_MAX="${MALLOC_ARENA_MAX:-2}" export MALLOC_TRIM_THRESHOLD_="${MALLOC_TRIM_THRESHOLD_:-65536}" export MALLOC_MMAP_THRESHOLD_="${MALLOC_MMAP_THRESHOLD_:-131072}" log() { printf '[start] %s\n' "$*"; } _yes() { case "${1:-}" in 1|yes|true|on) return 0 ;; *) return 1 ;; esac; } # ---------------------------------------------------------------- memory size # Prefer the cgroup limit (Render free = 512 MB) over /proc/meminfo, which in # containers often shows the HOST's RAM and would lie about what we can use. detect_mem_limit_mb() { local v="" path for path in /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory/memory.limit_in_bytes; do [ -f "$path" ] || continue v=$(cat "$path" 2>/dev/null || echo "") case "$v" in ""|max) v="" ;; *[!0-9]*) v="" ;; esac # Ignore absurd "no limit" values (>= 1 TiB) and fall through to MemTotal. if [ -n "$v" ] && [ "$v" -ge 1099511627776 ] 2>/dev/null; then v=""; fi [ -n "$v" ] && { echo $((v / 1024 / 1024)); return 0; } done local kb=0 [ -f /proc/meminfo ] && kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) echo $((kb / 1024)) } MEM_LIMIT_MB=$(detect_mem_limit_mb) export MEM_LIMIT_MB # --------------------------------------------------------- 512MB dual-viewer # Knobs that let BOTH 9Hits and FeelingSurf share one small instance without # the platform OOM-killing the container: # # DUAL_VIEWER_MODE # auto start both together; escalate to time-slice if RAM proves # too small (2+ over-limit restarts inside 10 min) # concurrent always run both; memguard restarts the heaviest viewer when # total RSS crosses MEMGUARD_HARD_PCT of the limit # time-slice alternate the viewers every TIME_SLICE seconds (one Chromium # resident at a time - guaranteed to fit 512 MB, ~50% uptime) # off legacy: no memguard, no slicing (two viewers may OOM small # plans - you were warned) # LOW_MEMORY # auto apply Chromium memory-shrinking flags only on < 1 GB boxes # balanced always apply the balanced flags off: never # extreme balanced + --single-process for BOTH viewers (~324 MB for # the pair - lets both run CONCURRENTLY in 512 MB). Per-viewer # toggles NH_SP / FS_SP (default yes) + crash auto-fallback: # if a viewer crash-loops 3x at startup, its SP flags are # dropped automatically so it keeps running (slower, safer). # FeelingSurf GL: FS_GL_MODE=swiftshader (upstream default) or # disable-gpu (only if swiftshader misbehaves under SP). # TIME_SLICE seconds per viewer turn in time-slice mode (default 1500=25m) DUAL_VIEWER_MODE="${DUAL_VIEWER_MODE:-auto}" TIME_SLICE="${TIME_SLICE:-1500}" LOW_MEMORY="${LOW_MEMORY:-auto}" CREATE_SWAP="${CREATE_SWAP:-}" # e.g. "256M"; auto-tried on small boxes NH_SP="${NH_SP:-yes}" # single-process for 9Hits in extreme mode FS_SP="${FS_SP:-yes}" # single-process for FeelingSurf in extreme FS_GL_MODE="${FS_GL_MODE:-swiftshader}" # swiftshader | disable-gpu export DUAL_VIEWER_MODE TIME_SLICE LOW_MEMORY CREATE_SWAP NH_SP FS_SP FS_GL_MODE # Decide whether the low-memory Chromium flag set applies. LOW_MEM_ON=0 case "$LOW_MEMORY" in off) : ;; balanced|extreme) LOW_MEM_ON=1 ;; auto) if [ "${MEM_LIMIT_MB:-0}" -gt 0 ] && [ "$MEM_LIMIT_MB" -lt 1024 ]; then LOW_MEM_ON=1 fi ;; esac if [ "$LOW_MEM_ON" -eq 1 ]; then log "LOW_MEMORY=$LOW_MEMORY: applying Chromium memory flags (detected ~${MEM_LIMIT_MB} MB limit)" else log "LOW_MEMORY=$LOW_MEMORY: memory flags off (detected ~${MEM_LIMIT_MB} MB limit)" fi # Chromium/Electron switches that meaningfully cut RSS for autosurf viewers # (the "balanced" set - measured with a real Chromium 149, see README): # --renderer-process-limit=1 one renderer for all sessions, not one each # --enable-low-end-device-mode Chromium's own low-RAM behaviour (aggressive # memory purging, smaller caches) # --memory-model=low same idea, newer Chromium # --js-flags=--max-old-space-size=64 cap V8 heaps in every renderer # --disk-cache-size/--media-cache-size keep the browser cache off RAM/disk # --disable-gpu (9Hits only) drop the separate GPU process (~40-80 MB); # FeelingSurf upstream NEEDS swiftshader GL, so # it keeps --use-gl=angle --use-angle=swiftshader # --disable-{extensions,sync,background-networking,component-extensions-...} # no extensions/background work in the viewer NH_MEM_FLAGS=() FS_MEM_FLAGS=() if [ "$LOW_MEM_ON" -eq 1 ]; then NH_MEM_FLAGS=( --disable-gpu --disable-dev-shm-usage --disable-extensions --disable-background-networking --disable-sync --disable-component-extensions-with-background-pages --renderer-process-limit=1 --enable-low-end-device-mode --memory-model=low --js-flags=--max-old-space-size=64 --disk-cache-size=1048576 --media-cache-size=1048576 --disable-features=Translate,BackForwardCache,MediaRouter,OptimizationHints ) # Note: --disable-dev-shm-usage --no-sandbox --use-gl=angle # --use-angle=swiftshader are the unconditional upstream base flags and are # already in feelingsurf-run.sh (upstream replaced --disable-gpu with # swiftshader to fix a 2.5.2 startup crash - never add --disable-gpu here # unless FS_GL_MODE=disable-gpu is set explicitly). FS_MEM_FLAGS=( --disable-extensions --disable-background-networking --disable-sync --disable-component-extensions-with-background-pages --renderer-process-limit=1 --enable-low-end-device-mode --memory-model=low --js-flags=--max-old-space-size=64 --disk-cache-size=1048576 --media-cache-size=1048576 --disable-features=Translate,BackForwardCache,MediaRouter,OptimizationHints ) fi # --- LOW_MEMORY=extreme: single-process mode for both viewers --------------- # Measured with a real Chromium 149 (2 tabs each, stable 60s+): # both --single-process --disable-gpu -> ~324 MB total # 9Hits single-process + FeelingSurf swiftshader -> ~458 MB total # Both fit Render free's 512 MB, so DUAL_VIEWER_MODE=concurrent finally works. # Chromium upstream labels --single-process as unsupported, so per-viewer # toggles + the crash auto-fallback below make it safe: a viewer that # crash-loops 3x at startup is relaunched WITHOUT its SP flags. NH_EXTREME_FLAGS=() FS_EXTREME_FLAGS=() if [ "$LOW_MEMORY" = "extreme" ]; then log "LOW_MEMORY=extreme: enabling single-process mode (NH_SP=$NH_SP FS_SP=$FS_SP, FS_GL_MODE=$FS_GL_MODE)" if _yes "$NH_SP"; then NH_EXTREME_FLAGS+=(--single-process --in-process-gpu) fi if _yes "$FS_SP"; then FS_EXTREME_FLAGS+=(--single-process --in-process-gpu) fi case "$FS_GL_MODE" in disable-gpu) log "FS_GL_MODE=disable-gpu: replacing swiftshader with --disable-gpu for FeelingSurf" ;; *) FS_GL_MODE=swiftshader ;; esac fi # FeelingSurf display resolution: 1280x720 on small boxes saves a few MB of # Xvfb framebuffer + renderer surface memory (1920x1080 on normal hosts). FS_RESOLUTION="${FS_RESOLUTION:-}" if [ -z "$FS_RESOLUTION" ]; then if [ "$LOW_MEM_ON" -eq 1 ]; then FS_RESOLUTION=1280x720x24; else FS_RESOLUTION=1920x1080x24; fi fi export FS_RESOLUTION export FS_MEM_FLAGS="${FS_MEM_FLAGS[*]}" export FS_EXTRA_FLAGS="${FS_EXTRA_FLAGS:-}" # Crash-streak tracker: used by both supervisors to auto-drop single-process # flags after 3 quick restarts (SP is the risky knob; a viewer that crashes # under SP should fall back to the balanced set instead of loop-forever). crash_streak() { local file="/tmp/$1.crash" now last start now=$(date +%s 2>/dev/null || echo 0) last=$(cat "$file" 2>/dev/null || echo 0) start=$(cat "/tmp/$1.start" 2>/dev/null || echo 0) if [ "$start" -gt 0 ] && [ $((now - start)) -lt 90 ]; then echo $((last + 1)) > "$file" else echo 1 > "$file" fi echo "$(date +%s 2>/dev/null || echo 0)" > "/tmp/$1.start" cat "$file" } # Best-effort swap for hosts that allow it (Docker --privileged, real VMs). # On Render/free Docker swapon lacks CAP_SYS_ADMIN and this quietly no-ops. maybe_create_swap() { local size="${1:-256M}" f="/tmp/9hits_swap" command -v swapon >/dev/null 2>&1 || { log "NOTE: swapon not available - skipping swap"; return 0; } if ! fallocate -l "$size" "$f" 2>/dev/null; then local mb="${size%[a-zA-Z]*}" dd if=/dev/zero of="$f" bs=1M count="${mb:-256}" 2>/dev/null || return 0 fi chmod 600 "$f" if mkswap "$f" >/dev/null 2>&1 && swapon "$f" 2>/dev/null; then log "swap enabled: $size at $f (helps Chromium survive peaks)" else rm -f "$f" log "NOTE: swap ($size) requested but swapon is not permitted on this host" fi } if [ -n "$CREATE_SWAP" ]; then maybe_create_swap "$CREATE_SWAP" elif [ "${MEM_LIMIT_MB:-0}" -gt 0 ] && [ "$MEM_LIMIT_MB" -lt 1024 ]; then log "small instance (~${MEM_LIMIT_MB} MB): trying a 256M swap (best-effort)" maybe_create_swap "256M" fi # Time-slice turn gate: in time-slice mode a viewer only launches when # memguard.py has marked it as the active one (/tmp/active_viewer). Fail-open: # if memguard is missing or its heartbeat (/tmp/memguard.alive) is stale, the # viewer runs freely so a dead guardian can never deadlock the container. wait_for_turn() { local name="$1" tries=0 active="" age=0 case "$DUAL_VIEWER_MODE" in concurrent|off) return 0 ;; esac while :; do if [ ! -f /tmp/active_viewer ] || [ ! -f /tmp/memguard.alive ]; then tries=$((tries + 1)) if [ "$tries" -ge 3 ]; then log "memguard turn file not present - running $name anyway (fail-open)" return 0 fi sleep 3 & wait $! continue fi if [ -f /tmp/memguard.alive ]; then age=$(($(date +%s 2>/dev/null || echo 0) - $(stat -c %Y /tmp/memguard.alive 2>/dev/null || echo 0))) if [ "$age" -gt "${MEMGUARD_DEAD_AFTER:-90}" ]; then log "memguard heartbeat stale (${age}s) - running $name anyway (fail-open)" return 0 fi fi active=$(cat /tmp/active_viewer 2>/dev/null || echo both) case "$active" in both|"$name") return 0 ;; esac sleep 5 & wait $! done } # Script-level knob used by BOTH viewers (must be set before the 9Hits gate). # RESTART_DELAY was an nh.sh knob; here it is an alias for the supervisor delay. SUPERVISOR_DELAY="${SUPERVISOR_DELAY:-${RESTART_DELAY:-10}}" # --------------------------------- 9Hits on/off toggle (default: OFF) -------- NINEHITS_ENABLED="${NINEHITS_ENABLED:-no}" if _yes "$NINEHITS_ENABLED"; then NINEHITS_ENABLED=yes log "9Hits enabled (NINEHITS_ENABLED=yes)" else NINEHITS_ENABLED=no fi export NINEHITS_ENABLED if _yes "$NINEHITS_ENABLED"; then # ------------------------------------------------ fetch proxy list if requested if [ -n "${BULK_ADD_PROXY_LIST_URL:-}" ] && [ -z "${BULK_ADD_PROXY_LIST:-}" ]; then fetch_err_file=$(mktemp 2>/dev/null || echo "/tmp/fetch_proxy_err.$$") if fetch_out=$(python3 "$SCRIPT_DIR/fetch_proxy_list.py" "$BULK_ADD_PROXY_LIST_URL" 2>"$fetch_err_file"); then BULK_ADD_PROXY_LIST="$fetch_out" BULK_ADD_PROXY_TYPE="${BULK_ADD_PROXY_TYPE:-socks5}" proxy_count=$(awk -F'|' '{print NF}' <<< "$BULK_ADD_PROXY_LIST") log "fetched $proxy_count proxies from BULK_ADD_PROXY_LIST_URL" else err_msg=$(cat "$fetch_err_file" 2>/dev/null || true) [ -z "$err_msg" ] && err_msg="$fetch_out" [ -z "$err_msg" ] && err_msg="unknown error" log "WARNING: failed to fetch proxy list from BULK_ADD_PROXY_LIST_URL ($err_msg)" fi rm -f "$fetch_err_file" fi # ---------------------------------------------------------------- arguments # These are consumed by the nhviewer INIT pass (nhviewer --exit-on-init). NH_ARGS=() kv() { [ -n "${2:-}" ] && NH_ARGS+=("--$1=$2"); } flag() { case "${!1:-}" in 1|yes|true|on) NH_ARGS+=("--$2") ;; esac } kv access-key "${ACCESS_KEY:-}" kv allow-popups "${ALLOW_POPUPS:-}" kv allow-adult "${ALLOW_ADULT:-}" kv allow-crypto "${ALLOW_CRYPTO:-}" kv hide-browser "${HIDE_BROWSER:-}" kv ex-proxy-sessions "${EX_PROXY_SESSIONS:-}" kv ex-proxy-url "${EX_PROXY_URL:-}" kv bulk-add-proxy-list "${BULK_ADD_PROXY_LIST:-}" kv bulk-add-proxy-type "${BULK_ADD_PROXY_TYPE:-}" kv session-note "${SESSION_NOTE:-}" kv note "${NOTE:-}" kv cache-path "${CACHE_PATH:-}" kv hide-columns "${HIDE_COLUMNS:-}" flag SYSTEM_SESSION system-session flag CLEAR_ALL_SESSIONS clear-all-sessions # Cap the browser disk cache by default (the viewer's own default is unlimited), # exactly like the official installer does (200 MB unless CACHE_LIMIT is set). NH_ARGS+=("--cache-limit=${CACHE_LIMIT:-209715200}") # LOW_MEMORY Chromium flags (only when this is a small box). Applied BEFORE # EXTRA_ARGS so user-supplied flags can override them (last one wins). NH_ARGS+=("${NH_MEM_FLAGS[@]}") # EXTRA_ARGS: raw space-separated extra viewer flags appended as-is. if [ -n "${EXTRA_ARGS:-}" ]; then read -r -a extra_args <<< "$EXTRA_ARGS" NH_ARGS+=("${extra_args[@]}") fi # Extra positional args (Render start command / docker run args). NH_ARGS+=("$@") # -------------------------------------------- script-level (consumed locally) # INSTALL_DIR: the viewer is baked into the image at $NH_DIR at build time. if [ -n "${INSTALL_DIR:-}" ] && [ "$INSTALL_DIR" != "$NH_DIR" ]; then log "NOTE: --install-dir is ignored; the viewer is baked into the image at $NH_DIR (override with NH_DIR at your own risk)." fi # RE_INSTALL only made sense when the viewer was extracted at runtime. if _yes "${RE_INSTALL:-}" && [ -z "${DEFAULT_DL:-}" ]; then log "NOTE: RE_INSTALL is a no-op now (the viewer is extracted at build time). Use DEFAULT_DL= to swap builds." fi if [ -z "${ACCESS_KEY:-}" ]; then log "WARNING: ACCESS_KEY is not set - the viewer will report 'User not found!'" fi # ---------------------------------------------------------- pool-closed check if [[ "${EX_PROXY_SESSIONS:-0}" =~ ^[1-9][0-9]*$ ]] && [ -z "${EX_PROXY_URL:-}" ] && [ -z "${BULK_ADD_PROXY_LIST:-}" ]; then log "WARNING: EX_PROXY_SESSIONS is set (${EX_PROXY_SESSIONS}) but EX_PROXY_URL and BULK_ADD_PROXY_LIST are empty. The 9Hits public pool is CLOSED! Every pool session will fail with 'Pool error: The public pool is closed!'. Configure your own pool at https://dash.9hits.com/pool (EX_PROXY_URL) or use BULK_ADD_PROXY_LIST / BULK_ADD_PROXY_LIST_URL." fi # ----------------------------------------------------------- RAM sanity check est_sessions=0 if [[ "${EX_PROXY_SESSIONS:-0}" =~ ^[1-9][0-9]*$ ]]; then est_sessions=$((est_sessions + EX_PROXY_SESSIONS)) fi if [ -n "${BULK_ADD_PROXY_LIST:-}" ]; then bulk_proxies=$(awk -F'|' '{print NF}' <<< "$BULK_ADD_PROXY_LIST") est_sessions=$((est_sessions + bulk_proxies)) fi _yes "${SYSTEM_SESSION:-}" && est_sessions=$((est_sessions + 1)) mem_total_mb=0 if [ -f /proc/meminfo ]; then mem_total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) mem_total_mb=$((mem_total_kb / 1024)) fi if [ "$mem_total_mb" -gt 0 ] && [ "$mem_total_mb" -lt 1024 ] && [ "$est_sessions" -ge 4 ]; then log "WARNING: detected ${mem_total_mb} MB RAM with ~${est_sessions} estimated sessions. Instances with < 1024 MB RAM (e.g. Render free tier 512 MB) may experience OOM kills with 4+ sessions. Consider lowering the session count (5-6 is recommended maximum) or using a bigger instance." fi if [ "$mem_total_mb" -gt 0 ] && [ "$mem_total_mb" -lt 2048 ]; then log "NOTE: 9Hits v6 officially recommends >= 2 GB RAM per instance; this box has ~${mem_total_mb} MB. If sessions crash-loop, disable FeelingSurf (FEELINGSURF_ENABLED=no), run fewer sessions, or use a bigger instance." fi # ------------------------------------------------------- redacted arg echo display_args() { local out=() a for a in "${NH_ARGS[@]}"; do case "$a" in --access-key=*) out+=("--access-key=****") ;; --bulk-add-proxy-list=*) out+=("--bulk-add-proxy-list=****") ;; *) out+=("$a") ;; esac done echo "${out[*]}" } # --------------------------------------------- best-effort /dev/shm enlargen # The v6 viewer's Chromium loves a big /dev/shm (upstream recommends running # with --shm-size=2g). Render & friends don't expose that knob, so try to # remount it ourselves; no-op (with a log line) where not permitted. fix_shm() { local cur cur=$(df -m /dev/shm 2>/dev/null | awk 'NR==2 {print $2}') if [ -n "${cur:-}" ] && [ "$cur" -lt 512 ]; then if mount -o remount,size=1g /dev/shm 2>/dev/null; then log "enlarged /dev/shm to 1g" else log "NOTE: /dev/shm is only ${cur:-?} MB and cannot be resized here (no CAP_SYS_ADMIN). Where supported, run with --shm-size=2g (already set in docker-compose.yml)." fi fi } # ------------------------------------------------------------- Xvfb display pick_resolution() { if [ "$NH_RESOLUTION" != "auto" ]; then echo "$NH_RESOLUTION"; return fi # On tiny instances shrink the framebuffer too (a few MB + renderer cost). if [ "$LOW_MEM_ON" -eq 1 ]; then echo "1280x720x24"; return fi local cores cores=$(nproc 2>/dev/null || echo 1) if [ "$cores" -ge 4 ] && [ "$mem_total_mb" -ge 4000 ]; then echo "2560x1440x24" else echo "1920x1080x24" fi } xvfb_supervisor() { trap 'log "Xvfb supervisor stopped"; exit 0' TERM INT if ! command -v Xvfb >/dev/null 2>&1; then # Runtime without our Docker image: wait quietly instead of crash-looping. log "Xvfb not installed on this runtime - 9Hits display supervisor disabled" while :; do sleep 300 & wait $!; done fi local dnum="${NH_DISPLAY#:}"; dnum="${dnum%%.*}" local res; res=$(pick_resolution) while :; do rm -f "/tmp/.X${dnum}-lock" "/tmp/.X11-unix/X${dnum}" 2>/dev/null || true log "starting Xvfb $NH_DISPLAY ($res) for 9Hits" Xvfb "$NH_DISPLAY" -screen 0 "$res" -nolisten tcp & local xpid=$! echo "$xpid" > /tmp/xvfb.pid wait "$xpid" local code=$? rm -f /tmp/xvfb.pid log "Xvfb exited (code $code) - restarting in 3s" sleep 3 & wait $! done } wait_display() { local i for i in $(seq 1 60); do if command -v xdpyinfo >/dev/null 2>&1; then xdpyinfo -display "$NH_DISPLAY" >/dev/null 2>&1 && return 0 else [ -S "/tmp/.X11-unix/X${NH_DISPLAY#:}" ] && return 0 fi sleep 0.5 done log "WARNING: X display $NH_DISPLAY not ready after 30s (continuing anyway)" return 1 } # --------------------------------------------------------------------- VNC vnc_supervisor() { trap 'log "VNC supervisor stopped"; exit 0' TERM INT local port="${VNC_PORT:-5901}" auth_args=() if [ -n "${VNC_PW:-}" ]; then mkdir -p "$HOME/.x11vnc" x11vnc -storepasswd "$VNC_PW" "$HOME/.x11vnc/passwd" >/dev/null 2>&1 chmod 600 "$HOME/.x11vnc/passwd" auth_args=(-rfbauth "$HOME/.x11vnc/passwd") elif _yes "${NO_VNC_PW:-}"; then auth_args=(-nopw) log "WARNING: VNC has NO password (--no-vnc-pw). Anyone can watch/control the viewer display." else VNC_PW=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom 2>/dev/null | head -c 8) mkdir -p "$HOME/.x11vnc" x11vnc -storepasswd "$VNC_PW" "$HOME/.x11vnc/passwd" >/dev/null 2>&1 chmod 600 "$HOME/.x11vnc/passwd" auth_args=(-rfbauth "$HOME/.x11vnc/passwd") log "VNC auto-generated password: $VNC_PW (set your own with VNC_PW=...)" fi while :; do log "starting x11vnc on port $port (mirroring $NH_DISPLAY)" x11vnc -display "$NH_DISPLAY" -rfbport "$port" -forever -shared -noxdamage -quiet "${auth_args[@]}" & local vpid=$! wait "$vpid" log "x11vnc exited (code $?) - restarting in 3s" sleep 3 & wait $! done } # ------------------------------------ optional runtime viewer re-download # Old nh.sh --default-dl semantics: fetch a different viewer build at start # (e.g. to test a newer build without rebuilding the image). maybe_update_viewer() { [ -n "${DEFAULT_DL:-}" ] || return 0 local target="/tmp/nhviewer-download" src="" sub="" log "DEFAULT_DL set - downloading viewer from $DEFAULT_DL" if wget -q --tries=3 --timeout=60 -O "$target" "$DEFAULT_DL"; then rm -rf /tmp/nh.new mkdir -p /tmp/nh.new if tar -xf "$target" -C /tmp/nh.new 2>/dev/null; then # locate nhviewer: either at the root or inside one wrapper directory if [ -f /tmp/nh.new/nhviewer ]; then src=/tmp/nh.new else sub=$(find /tmp/nh.new -mindepth 1 -maxdepth 1 -type d | head -n 1) if [ -n "$sub" ] && [ -f "$sub/nhviewer" ]; then src="$sub" fi fi fi fi rm -f "$target" if [ -z "$src" ]; then log "WARNING: DEFAULT_DL download/extract failed - keeping the baked-in viewer" rm -rf /tmp/nh.new return 1 fi rm -rf "${NH_DIR}.old" if [ -d "$NH_DIR" ]; then mv "$NH_DIR" "${NH_DIR}.old" fi if [ "$src" != /tmp/nh.new ]; then mv "$src" "$NH_DIR" rm -rf /tmp/nh.new else mv /tmp/nh.new "$NH_DIR" fi chmod -R a+rwX "$NH_DIR" chmod +x "$NH_BIN" 2>/dev/null || true rm -rf "${NH_DIR}.old" log "viewer replaced from DEFAULT_DL" } # ----------------------------------------------------------------- viewer # Init pass: apply cmdline settings/sessions to the persisted config and exit. # Bounded by INIT_TIMEOUT so a hung API call can never wedge the container. run_init_pass() { log "init pass: nhviewer $(display_args) --exit-on-init" ( cd "$NH_DIR" 2>/dev/null || exit 1 timeout --kill-after=15s "${INIT_TIMEOUT}s" \ "$NH_BIN" "${NH_ARGS[@]}" --exit-on-init ) } nh_supervisor() { trap 'log "9Hits supervisor stopped"; exit 0' TERM INT maybe_update_viewer if [ ! -x "$NH_BIN" ]; then # e.g. Hugging Face Gradio Spaces: the app runs in HF's own Python # runtime and never builds our Docker image, so the viewer binary # simply does not exist. Wait quietly instead of hot restart-looping. log "ERROR: $NH_BIN is missing - 9Hits cannot start on this runtime (use the Docker image). Checking again every 5 min." while [ ! -x "$NH_BIN" ]; do echo "down" > /tmp/viewer.state sleep 300 & wait $! done fi wait_display || true local stuck=0 _yes "$NH_WATCHDOG" && stuck="$NH_WATCHDOG_STUCK" NH_SP_ON="$NH_SP" # recomputed per launch (auto-fallback may disable it) while :; do # In time-slice mode wait until memguard.py gives us the active turn. wait_for_turn ninehits wait_display || true local run_args=(--auto-start --in-loop) _yes "$NH_RENDER_TO_TERMINAL" && run_args+=(--render-to-terminal) [ -n "${RESET_INTERVAL:-}" ] && run_args+=("--reset-interval=${RESET_INTERVAL}") # The run pass is the LONG-LIVED instance, so the LOW_MEMORY Chromium # flags must be here too (the init pass instance exits immediately). run_args+=("${NH_MEM_FLAGS[@]}") # Single-process (extreme mode): apply to the run pass only, with the # crash auto-fallback (3 quick crashes -> drop SP permanently). if [ "$LOW_MEMORY" = "extreme" ] && _yes "$NH_SP_ON"; then if [ -f /tmp/viewer.restarts ]; then local streak streak=$(crash_streak viewer) if [ "$streak" -ge 3 ]; then NH_SP_ON=no log "9Hits crashed $streak times at startup - disabling single-process for 9Hits (balanced flags only)" fi fi if _yes "$NH_SP_ON"; then run_args+=("${NH_EXTREME_FLAGS[@]}") fi fi # NH_RUN_EXTRA_ARGS: run-pass-only raw flags (init pass uses EXTRA_ARGS). if [ -n "${NH_RUN_EXTRA_ARGS:-}" ]; then read -r -a _run_extra <<< "$NH_RUN_EXTRA_ARGS" run_args+=("${_run_extra[@]}") fi echo "init" > /tmp/viewer.state local attempt rc init_ok=0 for attempt in 1 2 3; do run_init_pass rc=$? if [ "$rc" -eq 0 ]; then init_ok=1 break fi log "WARNING: 9Hits init pass failed/timed out (attempt $attempt/3, code $rc) - retrying in ${SUPERVISOR_DELAY}s" sleep "${SUPERVISOR_DELAY}" & wait $! wait_display || true done [ "$init_ok" -ne 1 ] && log "WARNING: init pass failed 3x - launching run pass anyway (will re-init on next restart)" # The init pass can take minutes; re-check the turn so a long init never # leaks the run pass into the other viewer's time-slice. wait_for_turn ninehits echo "run" > /tmp/viewer.state touch /tmp/viewer.lastoutput 2>/dev/null || true log "launching 9Hits viewer: nhviewer ${run_args[*]} (config: $(display_args))" ( cd "$NH_DIR" 2>/dev/null || exit 1 exec python3 "$SCRIPT_DIR/run_pty.py" \ --heartbeat-file /tmp/viewer.lastoutput \ --watchdog-stuck "$stuck" \ -- "$NH_BIN" "${run_args[@]}" ) & local vpid=$! echo "$vpid" > /tmp/viewer.pid wait "$vpid" local code=$? rm -f /tmp/viewer.pid echo "down" > /tmp/viewer.state local restarts=0 [ -f /tmp/viewer.restarts ] && restarts=$(cat /tmp/viewer.restarts 2>/dev/null || echo 0) echo $((restarts + 1)) > /tmp/viewer.restarts log "9Hits viewer exited (code $code) - restarting in ${SUPERVISOR_DELAY}s" # `wait` is interruptible by the TERM/INT trap above; a plain `sleep` # would delay shutdown. sleep "${SUPERVISOR_DELAY}" & wait $! done } fix_shm xvfb_supervisor & export XVFB_SUPERVISOR_PID=$! nh_supervisor & export SUPERVISOR_PID=$! VNC_SUPERVISOR_PID=0 if _yes "${VNC:-}" || [ -n "${VNC_PW:-}" ] || _yes "${NO_VNC_PW:-}"; then vnc_supervisor & VNC_SUPERVISOR_PID=$! fi export VNC_SUPERVISOR_PID else # 9Hits is OFF by default so the container fits in 512 MB (two Chromium # viewers OOM the free plan). Skip the entire 9Hits stack: the nh / Xvfb / # VNC supervisors, fix_shm, the proxy-list fetch, and the ACCESS_KEY / # pool / RAM warnings. Advertise the disabled state to the health server. log "9Hits disabled (NINEHITS_ENABLED=no) - running FeelingSurf-only + /health; set NINEHITS_ENABLED=yes to run both viewers (LOW_MEMORY + memguard keep them inside small plans)" export SUPERVISOR_PID=0 XVFB_SUPERVISOR_PID=0 VNC_SUPERVISOR_PID=0 fi # FeelingSurf runs in this same container/deployment. Keep it under a separate # supervisor so either viewer can restart without taking down the other one. feelingsurf_supervisor() { trap 'log "FeelingSurf supervisor stopped"; exit 0' TERM INT FS_SP_ON="$FS_SP" # auto-fallback may disable it while :; do # In time-slice mode wait until memguard.py gives us the active turn. wait_for_turn feelingsurf # Extreme mode: single-process, with crash auto-fallback (3 quick crashes # -> relaunch on the balanced flag set instead of loop-forever). if [ "$LOW_MEMORY" = "extreme" ] && _yes "$FS_SP_ON"; then if [ -f /tmp/feelingsurf.restarts ]; then local streak streak=$(crash_streak feelingsurf) if [ "$streak" -ge 3 ]; then FS_SP_ON=no log "FeelingSurf crashed $streak times at startup - disabling single-process for FeelingSurf (balanced flags only)" fi fi fi export FS_SP="$FS_SP_ON" log "launching FeelingSurf viewer (FS_SP=$FS_SP_ON FS_GL_MODE=$FS_GL_MODE)" if [ "$(id -u)" = "0" ] && id fsviewer >/dev/null 2>&1; then runuser -u fsviewer -- "$SCRIPT_DIR/feelingsurf-run.sh" & else # Non-root runtime (e.g. Hugging Face Gradio Spaces): run directly. "$SCRIPT_DIR/feelingsurf-run.sh" & fi local fpid=$! echo "$fpid" > /tmp/feelingsurf.pid wait "$fpid" local code=$? rm -f /tmp/feelingsurf.pid local restarts=0 [ -f /tmp/feelingsurf.restarts ] && restarts=$(cat /tmp/feelingsurf.restarts 2>/dev/null || echo 0) echo $((restarts + 1)) > /tmp/feelingsurf.restarts log "FeelingSurf exited (code $code) - restarting in ${SUPERVISOR_DELAY}s" sleep "${SUPERVISOR_DELAY}" & wait $! done } case "${FEELINGSURF_ENABLED:-yes}" in 0|no|false|off) export FEELINGSURF_SUPERVISOR_PID=0 log "FeelingSurf disabled by FEELINGSURF_ENABLED=${FEELINGSURF_ENABLED}" ;; *) if [ ! -x /usr/bin/FeelingSurfViewer ]; then # Runtime without our Docker image (e.g. HF Gradio Spaces): skip quietly. export FEELINGSURF_ENABLED=no FEELINGSURF_SUPERVISOR_PID=0 log "FeelingSurf binary not installed on this runtime - disabling FeelingSurf" else if [ -z "${access_token:-${ACCESS_TOKEN:-}}" ]; then log "WARNING: ACCESS_TOKEN is not set - FeelingSurf cannot authenticate" fi feelingsurf_supervisor & export FEELINGSURF_SUPERVISOR_PID=$! fi ;; esac # If both viewers ended up disabled (explicitly or because the binaries are # absent), make it obvious that only /health will run. if ! _yes "$NINEHITS_ENABLED" && [ "${FEELINGSURF_SUPERVISOR_PID:-0}" -eq 0 ]; then log "WARNING: both viewers are disabled (NINEHITS_ENABLED=no and FEELINGSURF_ENABLED=no/absent) - only /health will run" fi if _yes "$NINEHITS_ENABLED"; then log "9Hits supervisor pid=$SUPERVISOR_PID (Xvfb $NH_DISPLAY pid=$XVFB_SUPERVISOR_PID)" [ "$VNC_SUPERVISOR_PID" -gt 1 ] && log "VNC supervisor pid=$VNC_SUPERVISOR_PID (port ${VNC_PORT:-5901})" fi [ "${FEELINGSURF_SUPERVISOR_PID:-0}" -gt 1 ] && log "FeelingSurf supervisor pid=$FEELINGSURF_SUPERVISOR_PID" # Memory guardian + time-slice scheduler. It watches total RSS against the # cgroup limit and (a) restarts the heaviest viewer before the platform OOMs # the container, or (b) in time-slice mode alternates the two viewers. The # supervisors' wait_for_turn() gates fail open if this ever dies. if [ "$DUAL_VIEWER_MODE" != "off" ] && [ -f "$SCRIPT_DIR/memguard.py" ]; then python3 "$SCRIPT_DIR/memguard.py" & export MEMGUARD_PID=$! log "memguard started (DUAL_VIEWER_MODE=$DUAL_VIEWER_MODE, TIME_SLICE=${TIME_SLICE}s, pid=$MEMGUARD_PID)" else export MEMGUARD_PID=0 log "memguard disabled (DUAL_VIEWER_MODE=$DUAL_VIEWER_MODE) - no OOM protection; both viewers may not fit small plans" fi log "combined health endpoint on 0.0.0.0:$PORT (GET /health)" exec python3 "$SCRIPT_DIR/health_server.py"