#!/bin/sh # Copyright (C) 2022-2023 Sean Whitton # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or (at # your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # emacsclient(1) wrapper to handle updating the environment of existing # daemons and choosing between installed and in-tree builds of Emacs. min_arg=1 may_start=true want_update=false want_installed=false want_eval=false want_version=false timeout= devel_running=false installed_running=false make= branch= gud_status= daemon_name= gdbmacs= args= devel_emacs="$HOME/src/emacs/primary/src/emacs" devel_emacsclient="$HOME/src/emacs/primary/lib-src/emacsclient" installed_emacs="$(command -v emacs)" installed_emacsclient="$(PATH=$(echo "$PATH" \ | sed -e "s#$HOME/src/dotfiles/bin:##") \ command -v emacsclient)" defer () { for arg do shift case "$arg" in '--spw/installed') ;; '--spw/no-start') may_start=false ;; '--spw/'*) echo >&2 "emacsclient: cannot handle $arg"; exit 1 ;; *) set -- "$@" "$arg" esac done $may_start && set -- -a "" "$@" if [ -n "$BASH_VERSION" ]; then exec -a emacsclient "$installed_emacsclient" "$@" else exec "$installed_emacsclient" "$@" fi } [ -n "$BASH_VERSION" ] \ || { command -v bash >/dev/null && exec bash "$0" "$@" || defer "$@"; } for cmd in flock inotifywait pwdx ss; do command -v $cmd >/dev/null || defer "$@" done # We have to do this to break up clustered short options. An unfortunate # consequence is maintaining a copy of all of emacsclient(1)'s long opts here. # To mitigate that somewhat, don't hard fail if we hit an unrecognised option. getopt=$(getopt -qas bash -n emacsclient -o "nqueHVtca:F:w:s:f:d:T:" \ -l "spw/installed,spw/no-start,spw/update-environment,\ no-wait,quiet,suppress-output,eval,help,version,tty,nw,\ create-frame,reuse-frame,alternate-editor:,frame-parameters:,socket-name:,\ server-file:,display:,parent-id:,timeout:,tramp:" -- "$@") [ $? -eq 0 ] || defer "$@" eval set -- "$getopt" get_listener () { local socket=$1 listener= listener_age= rematch_listener= rematch_age= # Find the youngest process listening on a socket of this name. while read -r line; do [[ $line =~ ,pid=([[:digit:]]+), ]] || continue rematch_listener=${BASH_REMATCH[1]} rematch_age="$(ps h -o etimes $rematch_listener)" [[ $rematch_age =~ ^[[:blank:]]*[[:digit:]]+[[:blank:]]*$ ]] \ || fail "couldn't find age of process $rematch_listener" if [ -z "$listener" ] || [ $rematch_age -lt $listener_age ]; then listener=$rematch_listener listener_age=$rematch_age fi done < <(ss -plx src "$socket") if [ -z "$listener" -a -e "$socket" ]; then # Nothing is listening: remove dangling socket. rm "$socket" else echo "$listener" fi } gud_status () { gud_status="$(exec -a emacsclient "$installed_emacsclient" -sgdbmacs -w2 \ --eval '(and (boundp '"'"'gud-comint-buffer) (get-buffer-process gud-comint-buffer) (string= "signal-received" gdb-inferior-status))')" return $? } maybe_notify () { echo >&2 "$1" $was_set && ! [ -t 2 ] \ && notify-send --urgency=low --expire-time=10000 "$1" } fail () { # emacsclient(1) exits with this exit code on all failures. maybe_notify "$1"; exit 1 } wait_inotifywait () { wait $inotifywait case $? in 0|2) return $? ;; *) fail "inotifywait(1) exited with code $?" ;; esac } spw_flock () { ( umask 077; mkdir -p "$locks_dir" ) eval "exec $1<>${locks_dir}$2" flock $([ -n "$timeout" ] && printf -- "--wait %s" "$timeout") "$1" \ || fail "couldn't lock starting Emacs daemon named $2" } pass_to_gdbmacs () { if $want_eval || $want_update; then # Only exit non-zero if we were expected to start the daemon. ! $may_start; exit else exec -a emacsclient "$installed_emacsclient" -sgdbmacs "$@" 3>&- 4>&- fi } egrep_negate () { echo -n "^([^${1:0:1}]" for (( i=1; i < ${#1}; i++ )); do echo -n "|(${1:0:$i}([^${1:$i:1}]|$))" done echo -n "|($1.+))" } for arg do shift case "$arg" in '--spw/installed') want_installed=true ;; '--spw/no-start') may_start=false ;; '--spw/update-environment') want_update=true ;; '-V'|'--version') want_version=true ;;& '-e'|'--eval') want_eval=true ;;& '-s'|'--socket-name') min_arg=3; daemon_name="$1" ;;& # We'll use the same timeout for some things we do in this script, # if the user has supplied a value. # Otherwise, we wait forever, just like emacsclient(1), for flock(1). # # For inotifywait(1) and our own --eval requests, we always supply # timeouts. This is because these requests can fail for reasons this # script can't detect, and we don't want to wait forever while holding # locks on starting up daemons, else no other invocation of this # script will be able to do anything without manual intervention. '-w'|'--timeout') timeout="$1" ;;& '--nw') set -- "$@" -nw ;; *) set -- "$@" "$arg" ;; esac done # -sgdbmacs implies --spw/installed, as a special case. [ "$daemon_name" = gdbmacs ] && want_installed=true # We set and export XDG_RUNTIME_DIR such that Emacs doesn't choose # "/tmp/emacs$(id -u)" as the socket dir. if [ -n "$XDG_RUNTIME_DIR" ]; then was_set=true else was_set=false export XDG_RUNTIME_DIR=/run/user/$(id -u) fi socket_dir="$XDG_RUNTIME_DIR/emacs/" locks_dir="$XDG_RUNTIME_DIR/spw_emacsclient/" $want_version || spw_flock 3 "${daemon_name:-server}" socket="$socket_dir${daemon_name:-server}" listener="$(get_listener $socket)" if [ -n "$listener" ]; then case "$(ps h -o exe $listener | sed -e 's/ (deleted)$//')" in "$devel_emacs") devel_running=true ;; "$(realpath $installed_emacs)") installed_running=true ;; *) fail "Unknown Emacs listening on $socket" ;; esac fi # Make it possible, with primary session, to quickly replace in-tree Emacs # with installed Emacs. See 'C-i E' Sway/i3 binding. if [ -z "$daemon_name" ] && $devel_running && $want_installed; then gud_status \ || fail "couldn't query gdbmacs for in-tree Emacs status; aborting" # If in-tree Emacs appears wedged, just delete the socket and start up an # installed daemon. The idea is that I'll only type C-i E if I know # that's not just wedged but crashed, and I want to keep that state in gdb # but get another primary daemon up in the meantime. if [ "$gud_status" = t ]; then rm "$socket" else inotifywait -qq --timeout "${timeout:-15}" -e delete_self "$socket" & inotifywait=$! kill "$listener" # Detach gdb so that Emacs can handle the SIGTERM. We also have to # quit gdb so that `spw/gdbmacs-attach' ignores `gdb-inferior-status'. if [ -n "${gdbmacs:=$(get_listener ${socket_dir}gdbmacs)}" ]; then \ ( exec -a emacsclient "$installed_emacsclient" -sgdbmacs -w2 \ --eval '(gdb-wait-for-pending (lambda () (gud-basic-call "detach") (gdb-wait-for-pending (lambda () (gud-basic-call "quit")))))' ) \ || fail "gdbmacs detach request failed" fi wait_inotifywait \ || fail "inotifywait(1) timed out waiting for socket deletion" fi listener= devel_running=false fi [ -z "$listener" ] && ! $may_start && ! $want_version && exit 0 # We start up an in-tree Emacs daemon only when the linked worktree exists and # there isn't a build still in progress. The latter means we avoid having to # consider whether or not a build is in progress when hitting 'C-i e'. # # We also examine $installed_running here to avoid having the in-tree # emacsclient(1) connect to the installed Emacs. if ! $want_installed && ! $installed_running && [ -x "$devel_emacsclient" ] \ && ( $devel_running \ || ! ( make="$(ps h -o pid -C make)" [ -n "$make" ] && echo "$make" | xargs pwdx \ | grep -q "$HOME/src/emacs/primary" ) ); then emacs=("$devel_emacs") emacsclient=("$devel_emacsclient") if [ -z "$daemon_name" ] && ! $want_version; then spw_flock 4 gdbmacs # If the primary session is known to be stopped, ask gdbmacs to # handle the request: it's probably C-i e or editing via EDITOR. # # Ignore a failure to obtain any information here, because one # likely cause is that the request times out because gdbmacs is # busy starting up Gnus. In that case we'd rather just try # sending the request to the primary session anyway. # # Ideally we could know that emacsclient exited non-zero only # because of a timeout, but currently that can be done only by # parsing emacsclient's output: see Emacs bug#60592. if [ -n "${gdbmacs:=$(get_listener ${socket_dir}gdbmacs)}" ] \ && gud_status && [ "$gud_status" = t ]; then maybe_notify "in-tree Emacs appears to be wedged" pass_to_gdbmacs "$@" fi fi else emacs=(-a emacs "$installed_emacs") emacsclient=(-a emacsclient "$installed_emacsclient") fi # Except in the case above where ss(1) is not available, this wrapper script # is responsible for starting up the daemon if it's not running. This is to # ensure that only the in-tree emacsclient(1) talks to the in-tree Emacs, and # only the installed emacsclient(1) talks to the installed Emacs. (We used to # patch the in-tree emacsclient.c to only execute the in-tree Emacs, instead # of handling it here.) if [ -z "$listener" ] && ! $want_version; then if [ -z "$daemon_name" -a "${emacs[0]}" = "$devel_emacs" ]; then # inotifywait(1) in Debian "bullseye" doesn't have --include. inotifywait -qq --timeout "${timeout:-15}" \ -e create "${socket_dir}" \ --exclude "$(egrep_negate ${socket_dir}server)" & inotifywait=$! # If gdbmacs fails to start main session, but we were able to # successfully submit the start request, have gdbmacs handle the # actual request too; again, probably C-i e or editing via EDITOR. ( exec -a emacsclient "$installed_emacsclient" -a '' \ -sgdbmacs -w2 --eval '(spw/gdbmacs-attach)' 3>&- 4>&- ) \ || fail "gdbmacs start request failed" if ! wait_inotifywait; then maybe_notify \ "timed out waiting for gdbmacs to start in-tree Emacs" pass_to_gdbmacs "$@" fi else ( exec "${emacs[@]}" --daemon"${daemon_name:+=$daemon_name}" 3>&- ) fi elif $want_update && ! $want_version; then # Facility to update a (running) daemon's env for a new login session. # This allows us to have the Emacs daemon survive restarts of the # graphical desktop environment, and to update Emacs on a remote host when # there is a new forwarded SSH agent socket. See 'upenv' in ~/.shinit. # # --spw/update-environment may be combined with a regular request or be # the only thing asked of this script. for var in DISPLAY WAYLAND_DISPLAY \ XAUTHORITY WINDOWID \ XDG_SESSION_TYPE XDG_CURRENT_DESKTOP \ SWAYSOCK I3SOCK \ SSH_ASKPASS SSH_CONNECTION \ SSH_AUTH_SOCK SSH_AGENT_PID; do eval isset=\${$var+x} if [ "$isset" = "x" ]; then eval val=\$$var args="$args"' "'$var'" "'$val'"' fi done [ -n "$args" ] \ && ( exec "${emacsclient[@]}" -s"${daemon_name:-server}" -w2 \ --eval "(spw/update-environment$args))" ) fi # emacsclient(1) requires argument(s) beyond just -s/--socket-name. # However, in the absence of any such arguments, if the daemon wasn't already # running, or if --spw/update-environment, this wrapper starts the daemon if # it wasn't already running, and exits zero. # # Note that if the daemon *was* already running and the only argument was # -s/--socket-name, we do exec emacsclient(1) and it'll immediately exit. [ "$#" -eq "$min_arg" ] && ( $want_update || [ -z "$listener" ] ) \ || exec "${emacsclient[@]}" "$@" 3>&- 4>&-