diff --git a/functions/serve-file-and-quit b/functions/serve-file-and-quit new file mode 100644 index 000000000..61ed2125e --- /dev/null +++ b/functions/serve-file-and-quit @@ -0,0 +1,155 @@ +#autoload + +# This function serves a specified file over HTTP 1.0 on localhost, at a random +# available port. It waits for a single GET request to the correct URL and then +# responds with the file contents. If any other request is received, it responds +# with a 404 Not Found. The server automatically shuts down after serving the +# file or after a specified timeout. +# +# Usage: +# serve-file-and-quit [timeout-secs] [Access-Control-Allow-Origin] +# Path to the file to be served (required) +# [timeout-secs] Number of seconds to wait for a connection before quitting (default: 30, max: 60) +# [Access-Control-Allow-Origin] Value for the Access-Control-Allow-Origin header (default: "null") +# +# If called from a subshell, it outputs the URL where the file can be accessed, and +# then closes stdout to avoid hanging while the server is listening. The caller can +# capture this output. +# +# It is intended for temporary sharing of files between local applications, +# such as opening a trace file in a web-based viewer (trace.ohmyz.sh). It is not +# meant for production use or serving files over the internet. +# +# It's a minimal implementation using zsh's built-in TCP capabilities, primarily +# to avoid external dependencies. +# +# Security considerations: +# +# - This server listens on 0.0.0.0 (all interfaces), so it may be accessible from +# other devices on the same network. I haven't found a way to bind only to localhost +# using zsh's ztcp module. +# - As the port is randomized, and the server is short-lived, the risk is mitigated, +# but not eliminated. An additional mitigation could be to use a random URL path segment. +# - It's not a proper HTTP server, so it uses very rudimentary request parsing and +# response generation. It currently does not support binary files, and probably +# never will. + +setopt localoptions localtraps + +zmodload zsh/net/tcp +zmodload zsh/zselect + +local file="$1" +local timeout_secs="${2:-30}" +local AccessControlAllowOrigin=${3:-"null"} + +if [[ -z "$file" ]]; then + print -ru2 "Usage: $0 [timeout-secs] [Access-Control-Allow-Origin]" + return 1 +fi + +if [[ ! -r "$file" ]]; then + print -ru2 "Error: file '$file' not found or not readable." + return 1 +fi + +if (( timeout_secs < 1 || timeout_secs > 60 )); then + print -ru2 "Error: timeout must be a positive integer between 1 and 60." + return 1 +fi + +local pathname="$(omz_urlencode -P "/${file:t}")" + +# 1. Get a random available port +local port=$(( RANDOM % 32767 + 1100 )) +while ! ztcp -l "$port" &>/dev/null; do + (( port++ )) + + if (( port > 65535 )); then + print -ru2 "Error: failed to start server: no available ports." + return 1 + fi +done +ztcp -c $REPLY # inmediately close the test connection + +# 2. Output file URL to stdout for caller to use +# Here the subcommand substitution captures the output and returns it to the caller +if [[ $ZSH_SUBSHELL -gt 0 ]]; then + echo "http://localhost:$port${pathname}" + exec >/dev/null # close stdout in the subshell to avoid hanging +fi + +# Start of asynchronous server block +{ + local response_body request_line listen_fd fd + + # 1. Start listening on the selected port + if ! ztcp -l $port; then + print -ru2 "Error: failed to start server on port $port." + return 1 + fi + listen_fd=$REPLY + + print -ru2 "Serving file '${file:t}' on http://localhost:$port${pathname} ..." + + # 2. Wait for a connection, with a timeout (in hundredths of a second) + while zselect -t $(( timeout_secs * 100 )) -r $listen_fd; do + # 3. Accept the connection + ztcp -a $listen_fd + fd=$REPLY + + # 4. Read the request line (the first line is usually enough to determine the path) + # Note: read up to 2048 bytes to avoid blocking indefinitely + sysread -s 2048 -i $fd request + local request_line="${${(f)request}[1]}" + + # 5. If the request is not for the expected pathname, respond with 404 Not Found + if [[ "${${(f)request}[1]}" != "GET ${pathname} HTTP/1."* ]]; then + local response_body="404 Not Found" + local http_response=$'HTTP/1.0 404 Not Found\r +Content-Type: text/plain\r +Content-Length: %d\r +\r +%s\r +' + >&$fd builtin printf -- "$http_response" \ + $(( ${#response_body} + 2 )) \ + "$response_body" + + # 5.1. Close the connection file descriptor and try again + ztcp -c $fd + continue + fi + + # 6. If the request matches the pathname, respond with the contents of the file + local response_body="$(<$file)" + local http_response=$'HTTP/1.0 200 OK\r +Access-Control-Allow-Origin: %s\r +Content-Type: text/plain;charset=utf-8\r +Content-Length: %d\r +\r +%s\r +' + >&$fd builtin printf -- "$http_response" \ + "$AccessControlAllowOrigin" \ + $(( ${#response_body} + 2 )) \ + "$response_body" + + # 6.1. Close the connection file descriptor and stop the loop + ztcp -c $fd + break + done + + return 0 +} always { + # Non-zero if error or timeout occurred + local ret=$? + + # 7. Close remaining file descriptors and shut down the server + print -ru2 "Server stopped." + + [[ -z "$fd" ]] || ztcp -c $fd 2>/dev/null + [[ -z "$listen_fd" ]] || ztcp -c $listen_fd 2>/dev/null + + return $ret +} &| diff --git a/hooks/zshenv b/hooks/zshenv new file mode 100644 index 000000000..38fd69526 --- /dev/null +++ b/hooks/zshenv @@ -0,0 +1,58 @@ +omzp:start() { + setopt localoptions localtraps + + # initialization + zmodload zsh/datetime + typeset -Ag __OMZP + __OMZP=( + PS4 "$PS4" + start "$EPOCHREALTIME" + outfile "${1:-$HOME}/${${SHELL:t}#-}.$EPOCHSECONDS.$$.zsh-trace.log" + ) + typeset -g PS4="+Z|%e|%D{%s.%9.}|%N|%x|%I> " + + # unload profiler on startup end + autoload -Uz add-zsh-hook + add-zsh-hook precmd omzp:stop + + # redirect debug output to profiler log file + exec 3>&2 2>${__OMZP[outfile]} + + # enable zsh debug mode + trap 'setopt xtrace noevallineno' EXIT +} + +# Force this function to be executed in noxtrace mode +emulate zsh +x -c ' +omzp:stop() { + setopt localoptions localtraps + trap "{ setopt noxtrace evallineno } 2>/dev/null; exec 2>&3 3>&-" EXIT + + # restore PS4 + typeset -g PS4="$__OMZP[PS4]" + unset "__OMZP[PS4]" + + # remove precmd function + add-zsh-hook -d precmd omzp:stop + unfunction omzp:stop + + local startup=$(( (${(%):-"%D{%s.%9.}"} - __OMZP[start]) * 1e3 )) + printf "%.3f ms – %s \n" "$startup" "${__OMZP[outfile]:t}" +}' + +# TODO: this is duplicated from init script, fix later +# Init $ZSH path +[[ -n "$ZSH" ]] || export ZSH="${${(%):-%x}:a:h:h}" + +# Set ZSH_CACHE_DIR to the path where cache files should be created +# or else we will use the default cache/ +[[ -n "$ZSH_CACHE_DIR" ]] || ZSH_CACHE_DIR="$ZSH/cache" + +# Make sure $ZSH_CACHE_DIR is writable, otherwise use a directory in $HOME +if [[ ! -w "$ZSH_CACHE_DIR" ]]; then + ZSH_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/oh-my-zsh" +fi + +# Set OMZ_TRACES directory +OMZ_TRACES="${OMZ_TRACES:-"$ZSH_CACHE_DIR/.traces"}" +! 'builtin' 'test' -f "${OMZ_TRACES}/.enabled" || omzp:start "$OMZ_TRACES" diff --git a/lib/cli.zsh b/lib/cli.zsh index 55938ba8a..4ffdc791b 100644 --- a/lib/cli.zsh +++ b/lib/cli.zsh @@ -30,6 +30,7 @@ function _omz { 'reload:Reload the current zsh session' 'shop:Open the Oh My Zsh shop' 'theme:Manage themes' + 'trace:Manage debug tracing' 'update:Update Oh My Zsh' 'version:Show the version' ) @@ -42,17 +43,26 @@ function _omz { refs=("${(@f)$(builtin cd -q "$ZSH"; command git for-each-ref --format="%(refname:short):%(subject)" refs/heads refs/tags)}") _describe 'command' refs ;; plugin) subcmds=( - 'disable:Disable plugin(s)' - 'enable:Enable plugin(s)' - 'info:Get plugin information' - 'list:List plugins' - 'load:Load plugin(s)' - ) + 'disable:Disable plugin(s)' + 'enable:Enable plugin(s)' + 'info:Get plugin information' + 'list:List plugins' + 'load:Load plugin(s)' + ) _describe 'command' subcmds ;; pr) subcmds=('clean:Delete all Pull Request branches' 'test:Test a Pull Request') _describe 'command' subcmds ;; theme) subcmds=('list:List themes' 'set:Set a theme in your .zshrc file' 'use:Load a theme') _describe 'command' subcmds ;; + trace) subcmds=( + 'clean:Delete all traces' + 'list:List traces' + 'off:Turn debug tracing off' + 'on:Turn debug tracing on' + 'toggle:Toggle debug tracing' + 'view:View trace in browser' + ) + _describe 'command' subcmds ;; esac elif (( CURRENT == 4 )); then case "${words[2]}::${words[3]}" in @@ -81,6 +91,12 @@ function _omz { local -aU themes themes=("$ZSH"/themes/*.zsh-theme(-.N:t:r) "$ZSH_CUSTOM"/**/*.zsh-theme(-.N:r:gs:"$ZSH_CUSTOM"/themes/:::gs:"$ZSH_CUSTOM"/:::)) _describe 'theme' themes ;; + trace::view) + local -a opts traces + traces=("${ZSH_CACHE_DIR}/.traces/"*.log(N:t)) + # opts=('-w:View in browser') + # _describe 'options' opts + _describe 'trace' traces ;; esac elif (( CURRENT > 4 )); then case "${words[2]}::${words[3]}" in @@ -105,6 +121,12 @@ function _omz { valid_plugins=(${valid_plugins:|args}) _describe 'plugin' valid_plugins ;; + trace::view) + local -a opts traces + # opts=('-w:View in browser') + traces=("${ZSH_CACHE_DIR}/.traces/"*.log(N:t)) + # _describe 'options' opts + _describe 'trace' traces ;; esac fi @@ -746,7 +768,7 @@ function _omz::theme { (( $# > 0 && $+functions[$0::$1] )) || { cat >&2 < [options] - +$#traces Available commands: list List all available Oh My Zsh themes @@ -882,6 +904,150 @@ function _omz::theme::use { [[ $1 = random ]] || unset RANDOM_THEME } + +function _omz::trace { + (( $# > 0 && $+functions[$0::$1] )) || { + cat >&2 < [options] + +Available commands: + + clean [-a] Delete old or all traces + list List traces + off Turn debug tracing off + on Turn debug tracing on + toggle Toggle debug tracing + view View trace in browser + +EOF + return 1 + } + + local command="$1" + shift + + $0::$command "$@" +} + +function _omz::trace::on { + 'builtin' ':' > "${OMZ_TRACES}/.enabled" + print -ru2 '[oh-my-zsh] tracing enabled' +} + +function _omz::trace::off { + 'command' 'rm' "${OMZ_TRACES}/.enabled" 2>/dev/null + print -ru2 '[oh-my-zsh] tracing disabled' +} + +function _omz::trace::toggle { + 'builtin' 'test' -f "${OMZ_TRACES}/.enabled" \ + && _omz::trace::off || _omz::trace::on +} + +function _omz::trace::clean { + if [[ -n "$1" && "$1" != "-a" ]]; then + echo >&2 "Usage: ${(j: :)${(s.::.)0#_}} [-a]" + return 1 + fi + + local -a traces + if [[ "$1" == "-a" ]]; then + traces=("${ZSH_CACHE_DIR}/.traces/"*.log(N)) + if (( ! $#traces )); then + _omz::log info "there are no traces to remove" + return + fi + else + traces=("${ZSH_CACHE_DIR}/.traces/"*.log(m+7N)) + + if (( ! $#traces )); then + _omz::log info "there are no traces older than 7 days" + return + fi + fi + + # Print found PR branches + print -l "Found these traces:" ${traces:t} + # Confirm before removing the branches + _omz::confirm "do you want remove them? [Y/n] " + # Only proceed if the answer is a valid yes option + [[ "$REPLY" != [yY$'\n'] ]] && return + + _omz::log info "removing trace files..." + LANG= command rm -v "${traces[@]}" +} + + +function _omz::trace::list { + traces=("${ZSH_CACHE_DIR}/.traces/"*.log(N:t)) + print -l -- ${(q-)traces} +} + +function _omz::trace::view { + if [[ -z "$1" || "$1" = (-h|--help) ]] \ + || [[ "$1" == "--no-web" && -z "$2" ]]; then + echo >&2 "Usage: ${(j: :)${(s.::.)0#_}} [--no-web] " + return 1 + fi + + # If --no-web, open locally + local in_web=1 + if [[ "$1" == "--no-web" ]]; then + in_web=0 + shift + fi + + # Get trace file + local trace="${OMZ_TRACES:-${ZSH_CACHE_DIR}/.traces}/$1" + + if [[ ! -f "$trace" ]]; then + _omz::log error "Trace file not found: $trace" + return 1 + fi + + # If not in web mode, just display the trace file + if (( ! in_web )); then + # Enrich the file display depending on the tools we have + # - bat: https://github.com/sharkdp/bat + # - less: typical pager command + case 1 in + ${+commands[bat]}) bat -l sh --style plain "$trace" ;; + ${+commands[less]}) less "$trace" ;; + *) cat "$trace" ;; + esac + return $? + fi + + # Load functions for web mode + autoload -Uz serve-file-and-quit omz_urlencode + local baseurl="https://trace.ohmyz.sh" + + # Serve the trace file and get its URL + local profileURL + profileURL="$(serve-file-and-quit "$trace" 30 "$baseurl" 2>/dev/null)" + + # If successful, open the trace viewer with the trace file URL + if [[ $? -eq 0 ]]; then + # Give the server some time to start + sleep 0.2 + + # -r and -P encode any special characters in the filename + local title="$(omz_urlencode -r -P "${trace:t}")" + + _omz::log info "Opening ${baseurl} with trace file: '$trace' ..." + open_command "${baseurl}/#profileURL=${profileURL}&title=${title}" + + return 0 + fi + + # Fallback: just open the trace viewer and let the user upload the file manually + _omz::log error "could not start HTTP server. Falling back to manual upload." + + _omz::log info "Opening ${baseurl} ... Please manually upload the trace file from this path:" + _omz::log info "$trace" + open_command "$baseurl" +} + function _omz::update { # Check if git command is available (( $+commands[git] )) || {