Compare commits
23
Commits
d8b687f4e4
...
31ffe8564b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ffe8564b | ||
|
|
cf8b3ee20f | ||
|
|
91befe91f1 | ||
|
|
ab74531b63 | ||
|
|
0e4717d7ed | ||
|
|
7da90f51e1 | ||
|
|
117622e144 | ||
|
|
a26c10e8c6 | ||
|
|
65caf4577b | ||
|
|
21eeaa5bf0 | ||
|
|
d279c93e13 | ||
|
|
439b9b2d8b | ||
|
|
6dcefc127d | ||
|
|
aff7f511e8 | ||
|
|
44ac1e6216 | ||
|
|
fc34c165df | ||
|
|
5f13b4015b | ||
|
|
f99868fb7c | ||
|
|
f5f228f7ae | ||
|
|
3eacfd26e8 | ||
|
|
1a4ba0d192 | ||
|
|
b12b303780 | ||
|
|
79853138fb |
@@ -25,6 +25,36 @@ requests, which are linked.
|
||||
The release pull request that merges `development` into `main` replaces this
|
||||
heading with the date it merges, and tags `main` with the same date.
|
||||
|
||||
### New
|
||||
|
||||
- **Every session has a name**, in both front doors' listings and as the tmux
|
||||
window name, so `choose-window` and the status line can tell a dozen
|
||||
sessions of one project apart. It is the project directory by default and
|
||||
`dejima --name <text>` when you choose one; a name already in use takes the
|
||||
first free `-N` rather than refusing the launch.
|
||||
- **The session list says which sessions want you.** `waiting` and `working`
|
||||
where every row used to say `attachable`, from the session's own report
|
||||
rather than from the notification markers, so it works with notifications
|
||||
switched off.
|
||||
- **The picker is three columns** - name, state, age - where it was five. On a
|
||||
phone the old row wrapped and put half of each session under the next
|
||||
session's number. The tmux label and the container id stay in `--list`.
|
||||
- **`dejima-attach --watch`**, the picker redrawn on a timer: one window for
|
||||
which sessions are working and which are waiting.
|
||||
- **`dejima-attach --count-waiting`** for a tmux status line - the same
|
||||
question as one number, and the one path that asks Docker nothing.
|
||||
**`--counts`** answers for every state in the same pass, so two numbers on a
|
||||
status bar cost one invocation rather than two, and **`--state-dir`** says
|
||||
where the registry and the reported states are for a reader that is not
|
||||
dejima.
|
||||
- **Inside tmux the picker switches your client** to the session's own window
|
||||
instead of attaching. tmux refuses to nest, so on your own machine the
|
||||
picker could list every session and reach none of them.
|
||||
- **The web front door's SESS button no longer reloads the page.** It opens a
|
||||
new WebSocket instead, which is all ttyd needs to run a different command,
|
||||
so the half a megabyte of inlined terminal is not fetched and re-run to
|
||||
reach the picker. On a phone that was most of the wait.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The GitHub mirrors carry a rewritten history.** AI co-author trailers are
|
||||
@@ -34,6 +64,12 @@ heading with the date it merges, and tags `main` with the same date.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The session listing is five times faster on a host running a dozen
|
||||
sessions**, which is the wait between pressing SESS on the web front door
|
||||
and the picker drawing. It asked Docker once per session and tmux once per
|
||||
session, and forked a subshell per row for each of four lookups; it now asks
|
||||
Docker twice and tmux once for the whole listing, whatever that holds.
|
||||
Measured against an eighteen-session fixture: 2259ms before, 468ms after.
|
||||
- **The credential proxy refuses an oversized request body before inviting
|
||||
it.** It answered `100 Continue` and only then read the declared length, so
|
||||
whether a session saw the `413` or only the `100` was timing. The cap itself
|
||||
|
||||
@@ -320,7 +320,9 @@ table of what differs, and what the mode gives up.
|
||||
session. It gets your terminal back rather than pausing the agent - the
|
||||
container keeps running - so for a session you want to leave working and pick
|
||||
up elsewhere, run dejima inside tmux and use
|
||||
[`dejima-attach`](docs/remote-access.md).
|
||||
[`dejima-attach`](docs/remote-access.md). Every session names its tmux window
|
||||
after the project, or after `--name <text>`, with a `-2` on the second one -
|
||||
which is what tells a dozen parallel sessions of one project apart.
|
||||
→ [why that takes doing](docs/configuration.md#suspending-a-session)
|
||||
|
||||
## Sign in on the host first
|
||||
|
||||
+179
-3
@@ -57,8 +57,8 @@ Options (each overrides the config file for one launch only):
|
||||
--workflow <name> what the agent is told to do with its work
|
||||
--cmd <command> run this instead of the harness's own command
|
||||
--identity <name> which [identity.<name>] supplies the credentials
|
||||
-i, --include <tool> add an optional tool to this launch (repeatable);
|
||||
see docs/tools.md for what dejima ships
|
||||
-i, --include <tool> add an optional tool (repeatable); see docs/tools.md
|
||||
--name <text> what this session is for, in the picker and in tmux
|
||||
--backend <name> the selected harness's model API, model and context
|
||||
--model <id> budget, each refused for a harness with no such
|
||||
--context-window <n> setting
|
||||
@@ -284,10 +284,140 @@ NO_PAUSE=0
|
||||
# file that silently decided it would make the next launch's answer to "where
|
||||
# is my work" depend on a line nobody reread.
|
||||
DEJIMA_ISOLATED=0
|
||||
# What this session is for, in the operator's own words. Both front doors
|
||||
# identify a session by the basename of its workspace, which answers "which
|
||||
# project" and nothing else - and the case that needs answering is a dozen
|
||||
# sessions against ONE project, where every row reads the same. Also becomes
|
||||
# the tmux window name, which is what makes `choose-window` and the status
|
||||
# line legible without either of them knowing dejima exists.
|
||||
#
|
||||
# A flag and no config key: a name shared by every launch of a project is the
|
||||
# ambiguity this exists to remove, so a file is the one place it cannot
|
||||
# usefully live.
|
||||
DEJIMA_SESSION_NAME=""
|
||||
# What _session_label() settled on, which is what both the tmux window and the
|
||||
# registry entry get. Bound here as well so that a path reaching the registry
|
||||
# without having passed the assignment would write an empty name rather than
|
||||
# abort under set -u.
|
||||
DEJIMA_SESSION_LABEL=""
|
||||
# The pane whose window this launch renamed, for cleanup() to hand back. Bound
|
||||
# here rather than beside the rename itself, which is below `trap cleanup
|
||||
# EXIT`: the trap dereferences this, and every name it dereferences has to be
|
||||
# bound before the trap can fire.
|
||||
DEJIMA_TMUX_RENAMED=""
|
||||
# Whether the operator wrote `--`, so an isolated launch can tell a project
|
||||
# path it must refuse from harness arguments it must pass through. Both arrive
|
||||
# here as positionals once the loop breaks.
|
||||
DEJIMA_ARGS_ONLY=0
|
||||
# Refused rather than sanitized, like the workspace and tmux label this sits
|
||||
# beside in the registry. A newline would add a key=value line to a flat entry
|
||||
# that dejima-attach parses last-match-wins, and a tab would move a column in
|
||||
# the listing that splits on them; the rest are control characters heading for
|
||||
# a terminal and a tmux window name. The cap is the picker: it pads every
|
||||
# column to the widest value, so one long name indents all of them off the
|
||||
# right edge of a phone.
|
||||
_set_session_name() {
|
||||
case "$1" in
|
||||
*[[:cntrl:]]*)
|
||||
echo "--name must not contain a newline, a tab or any other control character" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
if [ "${#1}" -gt 32 ]; then
|
||||
echo "--name is ${#1} characters; 32 is the most the session picker can show" >&2
|
||||
exit 1
|
||||
fi
|
||||
DEJIMA_SESSION_NAME="$1"
|
||||
}
|
||||
|
||||
# Every session label already in use, one per line. Two sources, because each
|
||||
# is blind to a case the other sees: a session started outside tmux has no
|
||||
# window, and a launch with remote access off writes no registry entry.
|
||||
#
|
||||
# This launch's own window is excluded. It may already carry the name - from
|
||||
# automatic-rename having guessed the directory, or from a previous session in
|
||||
# the same window - and a window conflicting with itself would push every
|
||||
# launch to a suffix.
|
||||
_taken_session_labels() {
|
||||
local dir file pid name self=""
|
||||
if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then
|
||||
self="$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}' 2>/dev/null || true)"
|
||||
tmux list-windows -a -F '#{window_id} #{window_name}' 2>/dev/null |
|
||||
while read -r id name; do
|
||||
[ "$id" = "$self" ] || printf '%s\n' "$name"
|
||||
done
|
||||
fi
|
||||
dir="$(remote_state_dir)/sessions"
|
||||
[ -d "$dir" ] || return 0
|
||||
for file in "$dir"/*; do
|
||||
[ -f "$file" ] || continue
|
||||
# Parsed, never sourced, like every other read of these files.
|
||||
name="$(sed -n 's/^name=//p' "$file" | head -1)"
|
||||
[ -n "$name" ] || continue
|
||||
pid="$(sed -n 's/^pid=//p' "$file" | head -1)"
|
||||
# An entry whose launcher is gone is holding no name: the exit trap
|
||||
# removes the entry, but a session killed outright leaves it behind,
|
||||
# and the suffixes would then climb past names nothing is using. An
|
||||
# entry with no pid= at all predates the field and is trusted.
|
||||
case "$pid" in
|
||||
''|*[!0-9]*) ;;
|
||||
*) kill -0 "$pid" 2>/dev/null || continue ;;
|
||||
esac
|
||||
printf '%s\n' "$name"
|
||||
done
|
||||
}
|
||||
|
||||
# The name this session is known by, decided once and used for both the tmux
|
||||
# window and the registry entry: --name when the operator typed one, the
|
||||
# project directory otherwise.
|
||||
#
|
||||
# A derived name that is already in use gets the first free `-N` rather than
|
||||
# refusing the launch. Several sessions against one project is the ordinary
|
||||
# case here - it is the case the name exists for - so a launch that stopped
|
||||
# over a collision would be a worse answer than two rows that agree. The
|
||||
# FIRST free suffix, so a number freed by a session ending is used again
|
||||
# instead of the count climbing for the life of the host.
|
||||
#
|
||||
# Nothing reserves the name, so two launches in the same instant can still
|
||||
# land on one. The window is small, and what it costs is a pair of rows that
|
||||
# read alike - which is where this started, not a failure.
|
||||
#
|
||||
# An explicit --name is taken verbatim, collision or not. The operator typed
|
||||
# it for this launch, and quietly handing back something else is worse than
|
||||
# the ambiguity it would be avoiding.
|
||||
_session_label() {
|
||||
local base taken candidate n
|
||||
if [ -n "$DEJIMA_SESSION_NAME" ]; then
|
||||
printf '%s' "$DEJIMA_SESSION_NAME"
|
||||
return 0
|
||||
fi
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
# What the registry records for this mode anyway: the workspace is
|
||||
# dejima's own scratch path, so its basename names nothing.
|
||||
base=isolated
|
||||
else
|
||||
base="$(basename "$WORKSPACE_DIR")"
|
||||
fi
|
||||
# Stripped and truncated where _set_session_name refuses. The difference is
|
||||
# who chose the value: a directory name was not chosen for this, and a
|
||||
# launch that stopped because a path was long or held an odd byte would be
|
||||
# refusing over something it can fix itself.
|
||||
base="${base//[[:cntrl:]]/}"
|
||||
[ -n "$base" ] || base=session
|
||||
base="${base:0:32}"
|
||||
taken="$(_taken_session_labels)"
|
||||
candidate="$base"
|
||||
n=1
|
||||
# Bounded, so a host with a hundred sessions of one project cannot spin
|
||||
# here. Past the bound the bare name is used, which is the same outcome as
|
||||
# the race above.
|
||||
while [ "$n" -lt 100 ] && grep -qxF -- "$candidate" <<< "$taken"; do
|
||||
n=$((n + 1))
|
||||
# Room for the suffix, measured rather than assumed: a project at the
|
||||
# cap leaves none.
|
||||
candidate="${base:0:$(( 32 - ${#n} - 1 ))}-$n"
|
||||
done
|
||||
printf '%s' "$candidate"
|
||||
}
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) DEJIMA_ARGS_ONLY=1; shift; break ;;
|
||||
@@ -308,6 +438,7 @@ while [ $# -gt 0 ]; do
|
||||
# for the launch they run by hand tomorrow.
|
||||
--no-pause) NO_PAUSE=1; shift ;;
|
||||
--isolated) DEJIMA_ISOLATED=1; shift ;;
|
||||
--name) _need_value --name "${2:-}"; _set_session_name "$2"; shift 2 ;;
|
||||
--no-log) _set_override logging.loki_url ""
|
||||
_set_override logging.conversation off
|
||||
NO_LOG=1; shift ;;
|
||||
@@ -1804,6 +1935,14 @@ cleanup() {
|
||||
# run that was killed rather than exited put neither back.
|
||||
printf '\033[?25h' > /dev/tty 2>/dev/null || true
|
||||
fi
|
||||
# Hand the window's name back to tmux. rename-window turned
|
||||
# automatic-rename off for it, so without this the window keeps this
|
||||
# session's name for whatever the operator does in it next - and the name
|
||||
# of a sandbox that has exited is worse than `docker` was, because it reads
|
||||
# as a session still there.
|
||||
if [ -n "$DEJIMA_TMUX_RENAMED" ]; then
|
||||
tmux set-option -w -t "$DEJIMA_TMUX_RENAMED" automatic-rename on 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
|
||||
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
|
||||
wait "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
|
||||
@@ -2023,6 +2162,31 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Name the tmux window this launch is sitting in, so that tmux's own
|
||||
# navigation is legible: `choose-window`, the status line and the window list
|
||||
# all show a window name, and what they showed for a dozen sandboxes was a
|
||||
# dozen copies of whatever automatic-rename made of `docker`. Nothing in tmux
|
||||
# has to know dejima exists for this to work, which is why it is worth more
|
||||
# than any picker.
|
||||
#
|
||||
# --name when given, the project otherwise. An operator who renamed the window
|
||||
# by hand is left alone: tmux turns automatic-rename off for a window on
|
||||
# rename, so the option still being on is the evidence that the current name is
|
||||
# tmux's guess rather than their choice. An explicit --name overrides that,
|
||||
# because it was typed for this launch.
|
||||
#
|
||||
# Installed below the trap so the restore in cleanup() is already armed, and
|
||||
# every call is guarded: a tmux that refuses must not end a launch.
|
||||
DEJIMA_SESSION_LABEL="$(_session_label)"
|
||||
if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then
|
||||
if [ -n "$DEJIMA_SESSION_NAME" ] ||
|
||||
[ "$(tmux display-message -p -t "$TMUX_PANE" '#{automatic-rename}' 2>/dev/null || true)" = 1 ]; then
|
||||
if tmux rename-window -t "$TMUX_PANE" "$DEJIMA_SESSION_LABEL" 2>/dev/null; then
|
||||
DEJIMA_TMUX_RENAMED="$TMUX_PANE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Git commits inside the sandbox need a user.name/user.email - carry over a
|
||||
# copy of the host's global gitconfig, read-write: entrypoint.sh's
|
||||
# `gh auth setup-git` (what makes GitHub read-only from in here - see
|
||||
@@ -3461,8 +3625,13 @@ if remote_enabled; then
|
||||
mkdir -p -m 700 "$DEJIMA_STATE_DIR/sessions" "$DEJIMA_STATE_DIR/notify"
|
||||
DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
|
||||
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
|
||||
# level/ is where the hook records which state the session is in, as
|
||||
# opposed to the edge markers beside it that the watcher consumes. Created
|
||||
# here rather than by the hook for the same reason the parent is: the host
|
||||
# owns this tree's shape, and a session that could create the directory
|
||||
# could create it as something else.
|
||||
# shellcheck disable=SC2174
|
||||
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
|
||||
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR" "$DEJIMA_NOTIFY_DIR/level"
|
||||
|
||||
# Verify what was just created, because everything downstream assumes it.
|
||||
# A leaf left behind root-owned by an earlier container restart (see
|
||||
@@ -3541,6 +3710,13 @@ if remote_enabled; then
|
||||
# startup banner makes, and for the same reason. What tells two
|
||||
# concurrent isolated sessions apart is the run id beside it, which
|
||||
# that listing already prints.
|
||||
# First of the lines, so that a value carrying a newline would land
|
||||
# ahead of every real key rather than after it: the parser is
|
||||
# last-match-wins, so an injected `pane=` here loses to the one below.
|
||||
# _set_session_name refuses the newline outright, which is what makes
|
||||
# that independent of this ordering; both, because the field is the
|
||||
# only one an operator types.
|
||||
printf 'name=%s\n' "$DEJIMA_SESSION_LABEL"
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
printf 'workspace=isolated\n'
|
||||
else
|
||||
|
||||
+380
-61
@@ -19,12 +19,21 @@ case "${1:-}" in
|
||||
cat <<'DEJIMA_ATTACH_HELP'
|
||||
dejima-attach - list live dejima sessions and attach to one
|
||||
|
||||
Usage: dejima-attach [run-id | --list]
|
||||
Usage: dejima-attach [run-id | --watch | --list | --count-waiting]
|
||||
|
||||
(no argument) numbered picker of live sessions; attaches to the choice
|
||||
run-id attach to one directly, which is what a notification's deep
|
||||
link and ttyd's --url-arg both produce
|
||||
--watch the same picker, redrawn every few seconds: which sessions
|
||||
are working and which are waiting for you, in one window
|
||||
--list one tab-separated row per live session, for scripting
|
||||
--count-waiting how many are waiting for you, for a tmux status line
|
||||
--counts one line of key=value for all of them, counted in one pass
|
||||
--state-dir where the registry and the reported states live
|
||||
|
||||
Inside tmux the picker switches this client to the session's own window
|
||||
instead of attaching, so it is a window switcher there and a front door
|
||||
everywhere else.
|
||||
|
||||
This is the shared core behind both remote front doors: it runs as a forced SSH
|
||||
command and as ttyd's command, so what an SSH key can reach and what the web
|
||||
@@ -72,16 +81,42 @@ source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||
# this - see tui_clear.
|
||||
banner() { tui_clear; tui_banner 'live sessions'; }
|
||||
|
||||
# Coloured by meaning, not by name: attachable is the one an operator wants to
|
||||
# find, and stale/no-tmux are the two that will refuse if picked.
|
||||
# Coloured by meaning, not by name: waiting is the one an operator is looking
|
||||
# for, working is the one they can leave alone, and stale/no-tmux are the two
|
||||
# that will refuse if picked.
|
||||
STATE_COLOR=""
|
||||
state_color() {
|
||||
case "$1" in
|
||||
attachable) printf '%s' "$C_OK" ;;
|
||||
stale) printf '%s' "$C_ERR" ;;
|
||||
*) printf '%s' "$C_WARN" ;;
|
||||
working|attachable) STATE_COLOR="$C_OK" ;;
|
||||
stale) STATE_COLOR="$C_ERR" ;;
|
||||
*) STATE_COLOR="$C_WARN" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# What the session last said about itself, from the level directory
|
||||
# hooks/dejima-notify.sh maintains. Empty for a session that has said nothing
|
||||
# yet, for a harness that reports nothing, and for a launch from before the
|
||||
# directory existed - all three render as the plain reachability state.
|
||||
#
|
||||
# The enum is iterated rather than the directory read, so a name the session
|
||||
# invented is never looked at. `done` comes before `running` deliberately: the
|
||||
# hook clears the siblings before writing, so holding both means a session has
|
||||
# contradicted itself, and the answer that sends an operator to look is the
|
||||
# safe one. -L as well as -e because a dangling symlink is a file a session can
|
||||
# plant as easily as an empty one, and it is still the session reporting on
|
||||
# itself either way.
|
||||
SESSION_LEVEL=""
|
||||
session_level() {
|
||||
local dir="$STATE_DIR/notify/$1/level" event
|
||||
SESSION_LEVEL=""
|
||||
for event in 'done' waiting running; do
|
||||
if [ -e "$dir/$event" ] || [ -L "$dir/$event" ]; then
|
||||
SESSION_LEVEL="$event"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Only for a registry entry written before pid= existed, i.e. a session that
|
||||
# outlived an upgrade: how long after its own started= it may still have no
|
||||
# container before the prune treats it as gone. Sized against a cold first
|
||||
@@ -96,6 +131,7 @@ PRE_PID_STARTUP_GRACE=900
|
||||
read_entry() {
|
||||
local file="$1" line
|
||||
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""; entry_pid=""
|
||||
entry_name=""
|
||||
[ -r "$file" ] || return 1
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
@@ -104,6 +140,7 @@ read_entry() {
|
||||
label=*) entry_label="${line#*=}" ;;
|
||||
started=*) entry_started="${line#*=}" ;;
|
||||
pid=*) entry_pid="${line#*=}" ;;
|
||||
name=*) entry_name="${line#*=}" ;;
|
||||
esac
|
||||
done < "$file" || return 1
|
||||
# pid reaches `kill`, which takes a job spec as well as a number: "%1" or
|
||||
@@ -122,36 +159,97 @@ read_entry() {
|
||||
esac
|
||||
}
|
||||
|
||||
# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
|
||||
# as env precisely so this correlation is possible without the container
|
||||
# having any write access to the registry.
|
||||
# Every running sandbox's container, keyed by the run id it reports.
|
||||
# bin/dejima passes DEJIMA_RUN_ID in as env precisely so this correlation is
|
||||
# possible without the container having any write access to the registry.
|
||||
#
|
||||
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
|
||||
# the caller prunes on the former, and pruning on the latter would permanently
|
||||
# unregister every running sandbox the moment dockerd happens to be restarting.
|
||||
container_for() {
|
||||
local run_id="$1" names name env unasked=""
|
||||
# Built once per listing rather than once per session: this used to run
|
||||
# `docker ps` and then inspect containers one by one until it found the match,
|
||||
# for every entry in the registry in turn, so a dozen sessions cost a dozen
|
||||
# `docker ps` calls and up to a hundred and forty-four inspects. The listing
|
||||
# is what both front doors open on, and that is time an operator waits.
|
||||
#
|
||||
# CONTAINER_MAP_UNASKED is what keeps "no such container" (1) and "couldn't
|
||||
# ask" (2) apart downstream: the caller prunes on the former, and pruning on
|
||||
# the latter would permanently unregister every running sandbox the moment
|
||||
# dockerd happened to be restarting. One unanswerable inspect anywhere makes
|
||||
# every miss a 2, which is the same reach the per-session loop had.
|
||||
declare -A CONTAINER_BY_RUN=()
|
||||
CONTAINER_MAP_UNASKED=""
|
||||
load_container_map() {
|
||||
local names line id name
|
||||
CONTAINER_BY_RUN=()
|
||||
CONTAINER_MAP_UNASKED=""
|
||||
names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
|
||||
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
|
||||
for name in $names; do
|
||||
# Captured rather than piped straight into grep: a pipeline reports
|
||||
# only grep's status, which would turn "inspect couldn't answer for
|
||||
# this container" into "this isn't the one" - and one such container
|
||||
# anywhere in the list would prune an unrelated live session.
|
||||
env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
|
||||
|| { unasked=1; continue; }
|
||||
if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
|
||||
printf '%s' "$name"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
[ -z "$unasked" ] || return 2
|
||||
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" \
|
||||
|| { CONTAINER_MAP_UNASKED=1; return 0; }
|
||||
[ -n "$names" ] || return 0
|
||||
# One inspect for every container rather than one each. Two round trips to
|
||||
# the daemon where there were one plus one per container, which on a host
|
||||
# running a dozen sessions was most of the wait before the picker drew.
|
||||
#
|
||||
# Both values as JSON, which is what makes one line per container a
|
||||
# property rather than a hope: a newline inside an environment value is
|
||||
# escaped rather than printed, so a line cannot be split and the name
|
||||
# cannot drift away from the environment beside it. A missing container
|
||||
# takes its line with it instead of shifting the rest.
|
||||
#
|
||||
# Read with shell string operations rather than a JSON parser: the only
|
||||
# question here is which element STARTS with DEJIMA_RUN_ID=, and an
|
||||
# unescaped quote is exactly where an element starts. A value that merely
|
||||
# contained that text would carry a \" there and not match.
|
||||
local -a name_list=()
|
||||
mapfile -t name_list <<< "$names"
|
||||
local out
|
||||
# A container that went away between the two calls makes inspect exit
|
||||
# nonzero while still answering for the others. Unverified rather than
|
||||
# gone, which is the same answer one failed inspect gave when there was one
|
||||
# per container: pruning on that unregisters a live session for good.
|
||||
out="$(docker inspect --format '{{json .Name}} {{json .Config.Env}}' \
|
||||
"${name_list[@]}" 2>/dev/null)" || CONTAINER_MAP_UNASKED=1
|
||||
while IFS= read -r line; do
|
||||
case "$line" in *'"DEJIMA_RUN_ID='*) ;; *) continue ;; esac
|
||||
id="${line#*\"DEJIMA_RUN_ID=}"
|
||||
id="${id%%\"*}"
|
||||
valid_run_id "$id" || continue
|
||||
# {{json .Name}} is a quoted "/name"; docker ps gave the same names
|
||||
# without the slash, and the registry is keyed by neither.
|
||||
name="${line%% *}"
|
||||
name="${name#\"}"; name="${name%\"}"; name="${name#/}"
|
||||
CONTAINER_BY_RUN["$id"]="$name"
|
||||
done <<< "$out"
|
||||
}
|
||||
|
||||
# The container reporting this run id, out of the map above. The answer comes
|
||||
# back in a variable rather than on stdout, like the two readers below: every
|
||||
# one of these is called once per session, and `$(...)` is a forked subshell
|
||||
# per call - which on a host running a dozen sessions was more of the wait than
|
||||
# the work.
|
||||
CONTAINER_NAME=""
|
||||
container_for() {
|
||||
CONTAINER_NAME="${CONTAINER_BY_RUN[$1]:-}"
|
||||
[ -z "$CONTAINER_NAME" ] || return 0
|
||||
[ -z "$CONTAINER_MAP_UNASKED" ] || return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Every pane on the server, asked once. This was a `tmux list-panes` and a
|
||||
# grep per session, so a dozen sessions meant a dozen round trips to the tmux
|
||||
# server and two dozen processes, for one answer that does not change while a
|
||||
# listing is being built.
|
||||
# Every caller of pane_alive loads this first: list_sessions and count_waiting
|
||||
# once for the listing they are building, and attach() again at the moment it
|
||||
# matters. A path that forgets reads an empty list and calls every session
|
||||
# stale, which is loud rather than subtle - it is what the attach tests catch.
|
||||
PANE_LIST=""
|
||||
load_pane_list() {
|
||||
PANE_LIST=$'\n'"$(tmux list-panes -a -F '#{pane_id}' 2>/dev/null || true)"$'\n'
|
||||
}
|
||||
|
||||
pane_alive() {
|
||||
[ -n "$1" ] || return 1
|
||||
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
|
||||
case "$PANE_LIST" in *$'\n'"$1"$'\n'*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Does session $1 actually hold pane $2? The only question that separates the
|
||||
@@ -197,16 +295,31 @@ launcher_alive() {
|
||||
# no matching container - and old enough that it should have had one - is
|
||||
# pruned outright; one whose pane is gone is shown as stale rather than
|
||||
# offered for attachment.
|
||||
#
|
||||
# Oldest first, by start time, which is the one order that holds still while an
|
||||
# operator is reading it: the numbers in the picker are how a session is
|
||||
# selected, so a list that re-sorted itself by state - the tempting order for a
|
||||
# live view - would renumber the rows between reading one and typing it. A new
|
||||
# session appears at the end and changes nothing above it.
|
||||
list_sessions() {
|
||||
local file run_id container state now rc warned=""
|
||||
# The sort key is prepended by the loop and dropped here, so the columns
|
||||
# the picker and --list read are unchanged.
|
||||
list_sessions_unsorted | sort -n | cut -f2-
|
||||
}
|
||||
|
||||
list_sessions_unsorted() {
|
||||
local file run_id container state project now rc warned=""
|
||||
[ -d "$SESSIONS_DIR" ] || return 0
|
||||
now="$(date +%s)"
|
||||
load_container_map
|
||||
load_pane_list
|
||||
for file in "$SESSIONS_DIR"/*; do
|
||||
[ -f "$file" ] || continue
|
||||
run_id="$(basename "$file")"
|
||||
valid_run_id "$run_id" || continue
|
||||
rc=0
|
||||
container="$(container_for "$run_id")" || rc=$?
|
||||
container_for "$run_id" || rc=$?
|
||||
container="$CONTAINER_NAME"
|
||||
if [ "$rc" -eq 1 ] && launcher_alive "$file" "$now"; then
|
||||
# Registered, but bin/dejima is still building - "not started yet"
|
||||
# is a third answer, and pruning on it would unregister a session
|
||||
@@ -249,12 +362,102 @@ list_sessions() {
|
||||
else
|
||||
state="stale"
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
|
||||
"$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
|
||||
# One column for two questions, because they do not overlap: a session
|
||||
# that cannot be attached to has nothing useful to say about whether it
|
||||
# wants attention, and for every session that CAN be attached to,
|
||||
# "attachable" is the answer to a question nobody asked. The words are
|
||||
# distinct, so the column stays unambiguous while the row stays narrow
|
||||
# enough for a phone.
|
||||
# ${x##*/} rather than basename, which is a process per row.
|
||||
project="${entry_workspace##*/}"
|
||||
[ -n "$project" ] || project="$entry_workspace"
|
||||
if [ "$state" = attachable ]; then
|
||||
session_level "$run_id"
|
||||
# `done` and `waiting` are the harness events the hook reports -
|
||||
# a turn ended, and something was asked for. One state to an
|
||||
# operator, who does the same thing about either.
|
||||
case "$SESSION_LEVEL" in
|
||||
done|waiting) state="waiting" ;;
|
||||
running) state="working" ;;
|
||||
esac
|
||||
fi
|
||||
# The name is second, before the columns that were already here, so a
|
||||
# row reads id-name-project rather than ending on the one field an
|
||||
# operator scans for. It is empty for a session launched without
|
||||
# --name, which is what the picker's fallback to the project is for.
|
||||
printf '%d\t%s\t%s\t%s\t%s\t%dm\t%s\t%s\n' \
|
||||
"$(( 10#$entry_started ))" \
|
||||
"$run_id" "$entry_name" "${entry_label:-?}" "$project" \
|
||||
"$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
|
||||
done
|
||||
}
|
||||
|
||||
# What every live session is doing, counted in one pass, for a status line to
|
||||
# interpolate. Registry, panes and levels only: no docker, and nothing pruned.
|
||||
#
|
||||
# A status line re-runs this every few seconds, where --list is read once by a
|
||||
# person, so the one question that costs a round trip to the daemon per
|
||||
# container is the one question this does not ask. What that gives up is
|
||||
# noticing a session whose container died without taking bin/dejima with it -
|
||||
# counted here, shown as gone by every listing - and telling a session whose
|
||||
# image is still building from one that is simply quiet; both read as
|
||||
# `attachable`. The pane list is one tmux call for all of them, because the
|
||||
# alternative is one per session.
|
||||
#
|
||||
# The words are the picker's, so that a number on a status bar and a row on a
|
||||
# screen say the same thing about the same session rather than two vocabularies
|
||||
# meeting in an operator's head.
|
||||
declare -A SESSION_COUNTS=()
|
||||
count_states() {
|
||||
local file run_id now state
|
||||
SESSION_COUNTS=([waiting]=0 [working]=0 [attachable]=0 [stale]=0 [no-tmux]=0 [total]=0)
|
||||
[ -d "$SESSIONS_DIR" ] || return 0
|
||||
now="$(date +%s)"
|
||||
load_pane_list
|
||||
for file in "$SESSIONS_DIR"/*; do
|
||||
[ -f "$file" ] || continue
|
||||
run_id="$(basename "$file")"
|
||||
valid_run_id "$run_id" || continue
|
||||
# A session whose launcher is gone is one the exit trap did not get to
|
||||
# remove. Not counted at all rather than counted as broken: the listing
|
||||
# is where an operator goes to find out about those, and a status bar
|
||||
# that never reaches zero stops being read.
|
||||
launcher_alive "$file" "$now" || continue
|
||||
read_entry "$file" || continue
|
||||
if [ -z "$entry_pane" ]; then
|
||||
state=no-tmux
|
||||
elif ! pane_alive "$entry_pane"; then
|
||||
state=stale
|
||||
else
|
||||
session_level "$run_id"
|
||||
case "$SESSION_LEVEL" in
|
||||
done|waiting) state=waiting ;;
|
||||
running) state=working ;;
|
||||
*) state=attachable ;;
|
||||
esac
|
||||
fi
|
||||
SESSION_COUNTS[$state]=$(( SESSION_COUNTS[$state] + 1 ))
|
||||
SESSION_COUNTS[total]=$(( SESSION_COUNTS[total] + 1 ))
|
||||
done
|
||||
}
|
||||
|
||||
count_waiting() {
|
||||
count_states
|
||||
printf '%s\n' "${SESSION_COUNTS[waiting]}"
|
||||
}
|
||||
|
||||
# One line of key=value, in the order a reader cares about them. A line rather
|
||||
# than a number because two numbers cost two invocations otherwise, and this
|
||||
# one is run on a timer.
|
||||
counts_line() {
|
||||
local key out=""
|
||||
count_states
|
||||
for key in waiting working attachable stale no-tmux total; do
|
||||
out="$out$key=${SESSION_COUNTS[$key]} "
|
||||
done
|
||||
printf '%s\n' "${out% }"
|
||||
}
|
||||
|
||||
# Attaches through a GROUPED session rather than the operator's own. Grouped
|
||||
# sessions share windows but track their own current window, so a phone can
|
||||
# sit on a different sandbox than the desktop, and destroy-unattached disposes
|
||||
@@ -278,6 +481,9 @@ attach() {
|
||||
echo "Session $run_id was not started from tmux - nothing to attach to." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Freshly, not from whatever a listing left behind: the pane may have gone
|
||||
# away in the seconds between an operator reading the row and picking it.
|
||||
load_pane_list
|
||||
if ! pane_alive "$entry_pane"; then
|
||||
echo "Session $run_id is stale - its tmux pane is gone." >&2
|
||||
exit 1
|
||||
@@ -306,6 +512,20 @@ attach() {
|
||||
echo "Session $run_id is stale - its tmux pane went away." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Already inside tmux: move this client rather than attaching. Everything
|
||||
# below is written for a client that has no tmux of its own - a phone, an
|
||||
# ssh forced command - and tmux refuses to nest a session inside one of its
|
||||
# own panes, so on the operator's own machine the picker could list
|
||||
# sessions and then fail to reach any of them. Switching is also the
|
||||
# cheaper thing by a long way: no throwaway session to create, verify and
|
||||
# reap, and the window the operator lands on is the real one.
|
||||
#
|
||||
# switch-client rather than select-window, so a sandbox launched from some
|
||||
# other tmux session is reachable too and not just a window of this one.
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
tmux switch-client -t "=$base:$win"
|
||||
return 0
|
||||
fi
|
||||
if tmux has-session -t "=$group" 2>/dev/null; then
|
||||
# Verified exactly like the session created below, and for the same
|
||||
# reason: nothing here created this one, so carrying the name is not
|
||||
@@ -366,51 +586,150 @@ attach() {
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
# An age a glance can read: minutes for the first hour, then hours, then days.
|
||||
# `--list` keeps the minutes it has always printed, because a number is what a
|
||||
# script wants; three characters is what lets the row fit the phone, and "2h"
|
||||
# is what tells an operator a session has been working since before lunch
|
||||
# where "137m" is a sum to do first.
|
||||
AGE_SHORT=""
|
||||
age_short() { # <minutes, as --list prints them>
|
||||
local m="${1%m}"
|
||||
if [ "$m" -lt 60 ]; then AGE_SHORT="${m}m"
|
||||
elif [ "$m" -lt 2880 ]; then AGE_SHORT="$(( m / 60 ))h"
|
||||
else AGE_SHORT="$(( m / 1440 ))d"
|
||||
fi
|
||||
}
|
||||
|
||||
# Draws the list and reads a choice. With a timeout it returns 1 when nothing
|
||||
# was typed, which is what --watch loops on.
|
||||
picker() {
|
||||
local -a ids=() labels=() projects=() ages=() states=() containers=()
|
||||
local i choice run_id label project age state container
|
||||
local w_label=0 w_project=0 w_age=0 w_state=0
|
||||
# Split on tabs only, so a space in a project name stays part of one field.
|
||||
# The columns are measured on the way in because they are padded to the
|
||||
# widest value rather than to a guess: a project name is a directory name,
|
||||
# and a fixed width either truncates a long one or indents every short one
|
||||
# off the right edge of a phone.
|
||||
while IFS=$'\t' read -r run_id label project age state container; do
|
||||
ids+=("$run_id"); labels+=("$label"); projects+=("$project")
|
||||
ages+=("$age"); states+=("$state"); containers+=("$container")
|
||||
[ "${#label}" -le "$w_label" ] || w_label="${#label}"
|
||||
[ "${#project}" -le "$w_project" ] || w_project="${#project}"
|
||||
[ "${#age}" -le "$w_age" ] || w_age="${#age}"
|
||||
local timeout="${1:-}"
|
||||
local -a ids=() names=() ages=() states=()
|
||||
local i choice run_id name label project age state container
|
||||
local w_name=0 w_state=0
|
||||
# Three columns, because the front door this is read through is a phone.
|
||||
# The tmux label and the container id are in `--list` and not here: the
|
||||
# label is a coordinate an operator no longer has to act on, since picking
|
||||
# a session goes to it, and the container id shares a prefix with every
|
||||
# other one, so a column of them says nothing. Carrying all five made a row
|
||||
# about eighty columns wide, which wrapped onto a second line and put the
|
||||
# state and the age under the NEXT session's number.
|
||||
#
|
||||
# The name is first, against the index, because those two are what an
|
||||
# operator matches up: the number is only useful next to the thing it
|
||||
# selects. It is measured on the way in rather than padded to a guess, and
|
||||
# capped, since the padding decides where the state sits.
|
||||
# Split on a unit separator, not on the tab the rows are built with. A tab
|
||||
# is IFS whitespace, so a RUN of them collapses into one delimiter: a row
|
||||
# whose name is empty - every entry written before that field existed -
|
||||
# loses the field entirely and shifts the five after it along. What that
|
||||
# looked like was the tmux label sitting where the name goes, the state
|
||||
# sitting in the age column, and the row coloured from the container id,
|
||||
# so a stale session was neither red nor labelled stale where it was read.
|
||||
while IFS=$'\037' read -r run_id name label project age state container; do
|
||||
# The project stands in for an entry written before the name existed.
|
||||
[ -n "$name" ] || name="$project"
|
||||
# The two answers the container column carried that were not
|
||||
# decoration, where they are read rather than in a column of their
|
||||
# own: a session whose image is still building is not an ordinary
|
||||
# attachable one, and "?" is already said once on stderr for the whole
|
||||
# listing when docker could not be asked at all.
|
||||
[ "$container" != starting ] || state=starting
|
||||
ids+=("$run_id"); names+=("$name"); states+=("$state")
|
||||
age_short "$age"
|
||||
ages+=("$AGE_SHORT")
|
||||
[ "${#name}" -le "$w_name" ] || w_name="${#name}"
|
||||
[ "${#state}" -le "$w_state" ] || w_state="${#state}"
|
||||
done < <(list_sessions)
|
||||
done < <(list_sessions | tr '\t' '\037')
|
||||
banner
|
||||
if [ "${#ids[@]}" -eq 0 ]; then
|
||||
printf ' %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
|
||||
# A live view waits for one to appear, where the one-shot picker has
|
||||
# its answer and is done. Launching a session is exactly when an
|
||||
# operator is watching this, so exiting here would close the window
|
||||
# they opened to watch it in.
|
||||
[ -z "$timeout" ] || { sleep "$timeout"; return 1; }
|
||||
exit 0
|
||||
fi
|
||||
# The container column stays, and last, because it is not decoration: it
|
||||
# reads "starting" for a session bin/dejima is still building and "?" when
|
||||
# docker could not be asked at all, and dropping it would make either one
|
||||
# look like an ordinary attachable session.
|
||||
# The name column takes whatever the terminal has left after the fixed
|
||||
# parts, rather than a width picked here. A fixed one is wrong in both
|
||||
# directions at once: a desktop has room for every name and would cut them
|
||||
# anyway, and a narrow phone would still overflow and wrap, which is the
|
||||
# failure this layout exists to avoid. 80 when there is no terminal to ask,
|
||||
# which is the width of a pipe by convention.
|
||||
#
|
||||
# 15 is the row minus the name: two leading spaces, two for the index, two
|
||||
# spaces, the dot, a space, two spaces after the name, two before the age
|
||||
# and three for the age itself.
|
||||
#
|
||||
# Asked of the controlling terminal, not of stdin. `tput cols` reads the
|
||||
# size from fd 0, and this picker's stdin is whatever the front door handed
|
||||
# it - a pipe wherever the choice is fed in rather than typed - where tput
|
||||
# answers with terminfo's 80 and names are cut on a phone that has fewer.
|
||||
local cols budget
|
||||
cols="$(stty size </dev/tty 2>/dev/null | cut -d' ' -f2 || true)"
|
||||
case "$cols" in ''|*[!0-9]*) cols=80 ;; esac
|
||||
# A pty nobody set a size on reports zero columns, which is not a width but
|
||||
# an unanswered question: taken literally it clamps every name to the floor
|
||||
# below, on a terminal that may be perfectly wide.
|
||||
[ "$cols" -ge 20 ] || cols=80
|
||||
budget=$(( cols - 15 - w_state ))
|
||||
# A terminal too narrow for a name at all still gets something to read.
|
||||
[ "$budget" -ge 8 ] || budget=8
|
||||
[ "$w_name" -le "$budget" ] || w_name="$budget"
|
||||
for i in "${!ids[@]}"; do
|
||||
local sc; sc="$(state_color "${states[$i]}")"
|
||||
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%*s%s %s%-*s%s %s%s%s\n' \
|
||||
state_color "${states[$i]}"
|
||||
# Cut names are marked. A name quietly losing its last characters is
|
||||
# worse than a short column: two projects can share twenty-four
|
||||
# characters and differ in the twenty-fifth, and nothing on the row
|
||||
# would say so.
|
||||
#
|
||||
# An ASCII marker even where the rest of the glyphs are not, because
|
||||
# this one sits INSIDE a padded field: bash measures a multi-byte
|
||||
# character as its bytes wherever the locale is not UTF-8, and printf
|
||||
# then pads the field to that many bytes, so the row would come out two
|
||||
# columns short and take the state column with it. This script runs
|
||||
# under whatever locale a forced ssh command or the ttyd unit has.
|
||||
if [ "${#names[$i]}" -gt "$w_name" ]; then
|
||||
names[$i]="${names[$i]:0:$(( w_name - 1 ))}~"
|
||||
fi
|
||||
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%3s%s\n' \
|
||||
"$C_WARN" "$((i + 1))" "$C_OFF" \
|
||||
"$sc" "$GLYPH_DOT" "$C_OFF" \
|
||||
"$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
|
||||
"$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
|
||||
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
|
||||
"$STATE_COLOR" "$GLYPH_DOT" "$C_OFF" \
|
||||
"$C_BOLD$C_TEXT" "$w_name" "${names[$i]}" "$C_OFF" \
|
||||
"$STATE_COLOR" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "${ages[$i]}" "$C_OFF"
|
||||
done
|
||||
tui_pick "${#ids[@]}" 'attach to'
|
||||
tui_pick "${#ids[@]}" 'attach to' "$timeout" || return 1
|
||||
choice="$TUI_CHOICE"
|
||||
attach "${ids[$((choice - 1))]}"
|
||||
}
|
||||
|
||||
# How often --watch redraws. Five seconds, because a redraw is a full listing -
|
||||
# one `docker ps`, one inspect per container - and the thing being watched is a
|
||||
# session either finishing a turn or starting one, which is not a sub-second
|
||||
# event. The screen clear in the banner is what keeps a redraw from scrolling.
|
||||
WATCH_INTERVAL=5
|
||||
|
||||
# The at-a-glance view: the same picker, redrawn until something is typed.
|
||||
# Deliberately not a second tool. A separate monitor would have to grow its own
|
||||
# list, its own columns and its own idea of what a session is, and the thing an
|
||||
# operator does next after seeing which session wants them is to go to it -
|
||||
# which this can already do, through the same numbers.
|
||||
watch_picker() {
|
||||
while ! picker "$WATCH_INTERVAL"; do :; done
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
'') picker ;;
|
||||
--watch) watch_picker ;;
|
||||
--list) list_sessions ;;
|
||||
--count-waiting) count_waiting ;;
|
||||
--counts) counts_line ;;
|
||||
# Where the registry and the reported states live, for anything on this
|
||||
# host that wants to read them without re-deriving which of
|
||||
# XDG_RUNTIME_DIR and /tmp this user got.
|
||||
--state-dir) printf '%s\n' "$STATE_DIR" ;;
|
||||
*)
|
||||
# This argument arrives from ttyd's --url-arg, i.e. from whoever can
|
||||
# reach the web front door. Validate its shape before it is used as a
|
||||
|
||||
+17
-2
@@ -84,9 +84,14 @@ tui_banner() {
|
||||
#
|
||||
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
|
||||
# better than arrow keys, and dejima-attach is the phone's entry point.
|
||||
# A third argument makes the prompt time out after that many seconds and
|
||||
# return 1 with no choice, for a caller that redraws and asks again. Kept here
|
||||
# rather than in the caller's own read so that there is one prompt, one range
|
||||
# check and one set of exits: a live list whose prompt behaved differently from
|
||||
# the static one is two front doors wearing the same colours.
|
||||
TUI_CHOICE=""
|
||||
tui_pick() {
|
||||
local count="$1" verb="$2" choice
|
||||
local count="$1" verb="$2" timeout="${3:-}" choice rc=0
|
||||
TUI_CHOICE=""
|
||||
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
|
||||
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
|
||||
@@ -94,7 +99,17 @@ tui_pick() {
|
||||
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
|
||||
# returns nonzero immediately, and exiting 1 with nothing printed reads as
|
||||
# a broken front door.
|
||||
read -r choice || exit 0
|
||||
#
|
||||
# A timed read separates the two: bash reports a timeout as a status above
|
||||
# 128 and an unreadable stdin as 1, so a front door with no terminal still
|
||||
# exits rather than spinning on a prompt nobody can answer.
|
||||
if [ -n "$timeout" ]; then
|
||||
read -r -t "$timeout" choice || rc=$?
|
||||
[ "$rc" -le 128 ] || return 1
|
||||
[ "$rc" -eq 0 ] || exit 0
|
||||
else
|
||||
read -r choice || exit 0
|
||||
fi
|
||||
case "$choice" in
|
||||
q|Q|'') exit 0 ;;
|
||||
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
# Registers the `done` notification handler into dejima's settings layer, in
|
||||
# the shape Claude Code reads them.
|
||||
#
|
||||
# Stop only, though the hook and the host-side watcher both understand
|
||||
# `waiting` as well. Claude Code raises Notification when it needs permission
|
||||
# to run something and when the prompt has sat idle, and a dejima session runs
|
||||
# with permission prompts off - so the first never happens, and what is left
|
||||
# is a timer that expires shortly after the Stop it follows. Registering both
|
||||
# put two pushes on the operator's phone for one event, a minute apart, the
|
||||
# second saying nothing the first had not. `waiting` stays in the enum for a
|
||||
# harness that can say it without having stopped first.
|
||||
# No Notification handler, though the hook and the host-side watcher both
|
||||
# understand `waiting` as well. Claude Code raises Notification when it needs
|
||||
# permission to run something and when the prompt has sat idle, and a dejima
|
||||
# session runs with permission prompts off - so the first never happens, and
|
||||
# what is left is a timer that expires shortly after the Stop it follows.
|
||||
# Registering both put two pushes on the operator's phone for one event, a
|
||||
# minute apart, the second saying nothing the first had not. `waiting` stays in
|
||||
# the enum for a harness that can say it without having stopped first.
|
||||
#
|
||||
# Claude Code merges hook entries across settings layers rather than letting
|
||||
# the higher one replace the lower, so these are added to whatever the
|
||||
@@ -40,7 +40,13 @@ DEJIMA_HOOK_DIR=/usr/local/lib/dejima/hooks
|
||||
harness_register_notify() {
|
||||
local settings="$1" tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])' \
|
||||
# UserPromptSubmit is the complement of Stop, and it is registered for the
|
||||
# level alone: the hook writes no notification edge for `running`, so this
|
||||
# adds nothing to the phone. What it adds is the difference between "this
|
||||
# session has stopped" and "nothing has been heard from this session",
|
||||
# which a picker cannot tell apart from the Stop event by itself.
|
||||
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])
|
||||
| .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh running"}]}])' \
|
||||
"$settings" > "$tmp"; then
|
||||
mv "$tmp" "$settings"
|
||||
else
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ why each is not a test:
|
||||
| `forges/`, the forge guard | Open a pull request through each forge and read the body that arrived |
|
||||
| any guard | Try the refused thing by the route an agent would take, not by the route the test takes |
|
||||
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
|
||||
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
|
||||
| `web/` | `cd web && node test/browser/sess-button.mjs`, then load it on a phone; nothing here can see a soft keyboard |
|
||||
| `harnesses/` | Launch the harness against a real model |
|
||||
|
||||
## 5. The release pull request
|
||||
|
||||
+155
-7
@@ -1013,30 +1013,178 @@ debounce instead.
|
||||
than arrow keys, because a phone soft keyboard handles those far better.
|
||||
`--list` prints one tab-separated row per live session for scripting, and a
|
||||
run-id argument attaches directly, which is what the deep link and `ttyd`'s
|
||||
`--url-arg` produce. Rows carry the run id, the tmux label, the workspace
|
||||
name, the session's age, its state, and the container id.
|
||||
`--url-arg` produce. Oldest first in both: the numbers are how a session is
|
||||
picked, so the order holds still rather than following the state.
|
||||
|
||||
**The picker shows three columns - name, state, age** - and the name comes
|
||||
first, against the index, because those two are what you match up. A `--list`
|
||||
row carries everything: the run id, the session name, the tmux label, the
|
||||
workspace name, the age in minutes, the state, and the container id. The label
|
||||
and the container id are deliberately not in the picker. Carrying all five made
|
||||
a row about eighty characters wide, which on a phone wrapped onto a second line
|
||||
and put the state and the age underneath the *next* session's number; and
|
||||
neither is something to act on - picking a session goes to its window for you,
|
||||
and every container id shares a prefix with every other.
|
||||
|
||||
The name column is whatever the terminal has room for after the rest of the
|
||||
row, so a desktop shows every name whole and a phone shows as much as it can.
|
||||
A name that did not fit is cut with a `~`, because two projects can differ only
|
||||
past the cut and a row that said nothing about it would be worse than a short
|
||||
column. `--list` always carries the whole name.
|
||||
|
||||
**Every session has a name, and `dejima --name <text>` is how you choose it.**
|
||||
Without the flag it is the project directory's name, which is what both front
|
||||
doors showed before and is the right answer for the usual one-session case.
|
||||
|
||||
**A name already in use gets the first free `-N`**, so a second session
|
||||
against the same project is `myproject-2` rather than a second row reading
|
||||
`myproject` - and rather than a launch that refuses to start, which would be a
|
||||
worse answer than an ambiguous row. The first *free* suffix: a number a
|
||||
finished session gives back is used again, instead of the count climbing for
|
||||
the life of the host. Collisions are read from the live registry and from the
|
||||
tmux window list, because each is blind to a case the other sees. A name you
|
||||
passed explicitly is taken verbatim, collision or not; you typed it for this
|
||||
launch.
|
||||
|
||||
Nothing reserves a name, so two launches in the same instant can still land on
|
||||
one. That costs a pair of rows that read alike, which is where this started
|
||||
rather than a failure, and the alternative is a lock held across a cold image
|
||||
build.
|
||||
|
||||
A derived name is cut down where an explicit one is refused: control
|
||||
characters are stripped and the whole thing is truncated to 32 characters,
|
||||
leaving room for the suffix. A directory was not named for this purpose, so a
|
||||
launch that stopped because a path was long would be refusing over something
|
||||
it can fix itself.
|
||||
|
||||
The name is also the **tmux window name**, which is the part worth having even
|
||||
with remote access off: `choose-window`, the status line and the window list
|
||||
all show that name, and for a dozen sandboxes they otherwise show a dozen
|
||||
copies of whatever `automatic-rename` makes of the container command. A window
|
||||
you renamed yourself is left alone unless the launch passed `--name`.
|
||||
|
||||
The picker is coloured (gruvbox) when stdout is a terminal that says it can
|
||||
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
|
||||
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
|
||||
parses those rows itself and `dejima-attach --help` calls them scriptable.
|
||||
|
||||
Three states, from a registry `dejima` writes on launch and removes on
|
||||
exit:
|
||||
The state column answers two questions with one word, because they do not
|
||||
overlap: a session that cannot be attached to has nothing useful to say about
|
||||
whether it wants attention, and for one that can, "attachable" is the answer
|
||||
to a question nobody asked.
|
||||
|
||||
Reachability, from a registry `dejima` writes on launch and removes on exit:
|
||||
|
||||
- `attachable` - a live tmux pane to join.
|
||||
- `stale` - registered, but its pane is gone. Listed rather than hidden,
|
||||
since the registry is a hint and not truth: the exit trap doesn't run
|
||||
under `SIGKILL` or a host crash.
|
||||
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
|
||||
attach to. Still listed, deliberately, rather than silently omitted.
|
||||
|
||||
And for a session with a pane to join, what it last said about itself:
|
||||
|
||||
- `waiting` - it finished a turn, or asked for something, and has not been
|
||||
given anything since. Both of the harness events the hook reports,
|
||||
[`done` and `waiting`](#notifications), land here: they are one thing to an
|
||||
operator, who does the same about either.
|
||||
- `working` - it was given a prompt and has not stopped.
|
||||
- `starting` - its image is still being built, so there is no session in there
|
||||
to attach to yet. In the picker only: `--list` says this in its container
|
||||
column, which the picker does not carry.
|
||||
- `attachable` - it has said nothing yet. A harness that reports no state at
|
||||
all stays here for its whole life, and so does a session between launching
|
||||
and its first prompt.
|
||||
|
||||
That reporting is the notification hook writing one file into a directory of
|
||||
its own, overwritten rather than consumed: the markers the
|
||||
[notification watcher](#notifications) consumes are gone the moment it pushes
|
||||
them, and with no watcher configured nothing reads them at all. So the states
|
||||
above work whether or not you use notifications, which is the point - they are
|
||||
the answer to "which sessions need me" for an operator who is at the machine.
|
||||
|
||||
It is the session reporting on itself, so it is best-effort by construction. A
|
||||
harness killed mid-turn leaves `working` behind, and the age column is the
|
||||
thing to read next to it.
|
||||
|
||||
Entries whose container has gone away are pruned as they're listed. When
|
||||
Docker can't be asked at all, nothing is pruned and the container column
|
||||
reads `?` - an unanswerable question is never treated as a "no".
|
||||
|
||||
Attaching creates a **grouped** tmux session (`dejima-<run-id>`) rather than
|
||||
joining yours. Grouped sessions share windows but track their own current
|
||||
### Watching all of them, and getting to one
|
||||
|
||||
`dejima-attach --watch` is the same picker redrawn every five seconds. One
|
||||
window showing which sessions are working and which are waiting, with the same
|
||||
numbers to go to one. A redraw is a full listing, which is two questions to
|
||||
Docker and one to tmux however many sessions are running.
|
||||
|
||||
`dejima-attach --count-waiting` reduces it to a number, for a tmux status line:
|
||||
|
||||
```tmux
|
||||
# ~/.tmux.conf - how many sessions are waiting for you, in the status bar
|
||||
set -g status-interval 10
|
||||
set -ag status-right ' waiting: #(dejima-attach --count-waiting)'
|
||||
```
|
||||
|
||||
That path reads the registry, the pane list and the reported states and asks
|
||||
Docker nothing, because the point of the number is not having to open
|
||||
anything. What it gives up is noticing a session whose container died without
|
||||
taking its launcher with it - every listing shows that one as gone - and
|
||||
telling a session whose image is still building from one that is simply quiet;
|
||||
both read as `attachable`.
|
||||
|
||||
`--counts` answers for every state in the same single pass, because two
|
||||
numbers on a status bar would otherwise cost two invocations of something that
|
||||
runs on a timer:
|
||||
|
||||
```console
|
||||
$ dejima-attach --counts
|
||||
waiting=3 working=5 attachable=8 stale=0 no-tmux=0 total=16
|
||||
```
|
||||
|
||||
The words are the picker's, so a number on a status bar and a row on a screen
|
||||
say the same thing about the same session. Formatting is yours; `cut` is
|
||||
enough for the usual case, and nothing here decides how your status bar looks:
|
||||
|
||||
```tmux
|
||||
set -ag status-right ' #(dejima-attach --counts | cut -d" " -f1,2)'
|
||||
```
|
||||
|
||||
`--state-dir` prints where the registry and the reported states live, for
|
||||
anything on this host that would rather read the files than run this. It is
|
||||
under `$XDG_RUNTIME_DIR` when there is one and under `/tmp` when there is not,
|
||||
so it is not a path to hard-code:
|
||||
|
||||
```console
|
||||
$ ls "$(dejima-attach --state-dir)"/notify/*/level
|
||||
```
|
||||
|
||||
The filename is the state, and it is the harness event rather than the word
|
||||
the picker shows: `done` or `waiting` is a session waiting for you, `running`
|
||||
is one working.
|
||||
|
||||
**The SESS button does not reload the page.** ttyd takes the run id as a
|
||||
WebSocket argument rather than as part of the page, so pressing it closes the
|
||||
socket and opens another without that argument, which is `dejima-attach` with
|
||||
no argument, which is the picker. The half a megabyte of inlined terminal
|
||||
stays loaded and stays running; on a phone, fetching and re-running it was
|
||||
most of the wait. Closing the socket is also what ends the session you are
|
||||
leaving, exactly as navigating away used to: ttyd kills the command when its
|
||||
connection goes.
|
||||
|
||||
The terminal is reset on the way, and the mode tracker with it. What the
|
||||
socket carries away is the process, not the state it left behind in the
|
||||
terminal - an alternate screen, mouse reporting, a cursor key mode - and the
|
||||
picker would otherwise open into whatever the session had switched on.
|
||||
|
||||
**Inside tmux, the picker switches your client to the session's own window**
|
||||
rather than attaching. tmux refuses to nest a session inside one of its own
|
||||
panes, so on your own machine the picker would otherwise list every session
|
||||
and reach none of them - and switching is what makes it the window switcher
|
||||
that a dozen parallel sessions actually need. `prefix` + a window number stops
|
||||
at nine; this does not.
|
||||
|
||||
Attaching, on a client with no tmux of its own, creates a **grouped** tmux
|
||||
session (`dejima-<run-id>`) rather than joining yours. Grouped sessions share windows but track their own current
|
||||
window, so the phone can sit on one sandbox while the desktop sits on
|
||||
another, and the throwaway carries `destroy-unattached`, so it disposes of
|
||||
itself when the phone detaches or the connection drops.
|
||||
|
||||
+22
-2
@@ -20,7 +20,7 @@ set -euo pipefail
|
||||
# Sanitizing an arbitrary name into a marker would make the file name a
|
||||
# channel the sandbox controls, which is the one thing this hook must not be.
|
||||
case "${1:-}" in
|
||||
waiting|done) ;;
|
||||
waiting|done|running) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
@@ -32,4 +32,24 @@ esac
|
||||
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
|
||||
[ -d "$DEJIMA_NOTIFY_DIR" ] || exit 0
|
||||
|
||||
: > "$DEJIMA_NOTIFY_DIR/$1"
|
||||
# The notification edge, for dejima-notify-watch to consume and push. Two
|
||||
# names, because that is the set the watcher iterates: a marker it never looks
|
||||
# for is a file nothing ever removes.
|
||||
case "$1" in
|
||||
waiting|done) : > "$DEJIMA_NOTIFY_DIR/$1" ;;
|
||||
esac
|
||||
|
||||
# The level: which of those the session is in NOW, rather than that it just
|
||||
# changed. Nothing consumes it, so it answers "which sessions need me" for a
|
||||
# picker or a status line, and it does that whether or not a watcher is
|
||||
# running - the edge above is gone the moment one is. Still a name and no
|
||||
# content, and still only ever about this session: the directory is this run's
|
||||
# own, so the worst a compromised session can do here is misreport itself.
|
||||
#
|
||||
# Siblings first, then the new one: a reader between the two calls sees no
|
||||
# state rather than two, and "unknown" is a thing the readers already render.
|
||||
[ -d "$DEJIMA_NOTIFY_DIR/level" ] || exit 0
|
||||
for _other in waiting 'done' running; do
|
||||
[ "$_other" = "$1" ] || rm -f "$DEJIMA_NOTIFY_DIR/level/$_other"
|
||||
done
|
||||
: > "$DEJIMA_NOTIFY_DIR/level/$1"
|
||||
|
||||
@@ -586,6 +586,37 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "the session name has no config key either, and refuses what would break a row" {
|
||||
# Same reasoning as the mode above, from the other direction: a name every
|
||||
# launch of a project shares is the ambiguity --name exists to remove, so
|
||||
# a file is the one place it cannot usefully live.
|
||||
local key
|
||||
for key in name session_name label; do
|
||||
write_config '[core]' "$key = whatever"
|
||||
run lib config_validate
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "[core] $key was accepted"; return 1; }
|
||||
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
|
||||
# The value lands in a flat key=value registry entry that dejima-attach
|
||||
# parses last-match-wins, and in a tab-separated listing. A newline or a
|
||||
# tab is refused rather than sanitized, like the workspace path beside it.
|
||||
local bad
|
||||
for bad in $'two\nlines' $'two\tcolumns' $'bell\a'; do
|
||||
run "$REPO_ROOT/bin/dejima" --name "$bad" .
|
||||
[ "$status" -ne 0 ] || { echo "accepted a control character"; return 1; }
|
||||
[[ "$output" == *"control character"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
run "$REPO_ROOT/bin/dejima" --name "$(printf 'x%.0s' {1..33})" .
|
||||
[ "$status" -ne 0 ] || { echo "accepted 33 characters"; return 1; }
|
||||
[[ "$output" == *"32 is the most"* ]] || { echo "$output"; return 1; }
|
||||
# And the boundary itself is accepted, or the cap is off by one: this gets
|
||||
# past the name check and fails on something else further in.
|
||||
run "$REPO_ROOT/bin/dejima" --name "$(printf 'x%.0s' {1..32})" .
|
||||
[[ "$output" != *"32 is the most"* ]] || { echo "32 characters was refused"; return 1; }
|
||||
}
|
||||
|
||||
@test "an invalid value is fatal and names the key" {
|
||||
write_config '[audit]' 'keep = 0'
|
||||
run lib config_validate
|
||||
|
||||
+669
-2
@@ -204,6 +204,18 @@ for arg in "$@"; do
|
||||
[ -z "${SANDBOX_TEST_SETTINGS_COPY:-}" ] || cp "$SANDBOX_SETTINGS_FILE" "$SANDBOX_TEST_SETTINGS_COPY"
|
||||
break
|
||||
done
|
||||
# The marker directory and what the host created inside it, while it still
|
||||
# exists: bin/dejima's EXIT trap rm -rf's the whole thing, so from outside the
|
||||
# run there is nothing left to look at. The host path comes off the mount
|
||||
# argument, which is the only place this stub can learn it.
|
||||
if [ -n "${SANDBOX_TEST_LEVEL_PROBE:-}" ]; then
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*:/run/dejima-notify)
|
||||
[ ! -d "${arg%%:*}/level" ] || echo level >> "$SANDBOX_TEST_LEVEL_PROBE" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
@@ -232,6 +244,7 @@ launch() {
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
||||
SANDBOX_TEST_LEVEL_PROBE="$LAUNCH_DIR/level-probe" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||
}
|
||||
@@ -610,7 +623,9 @@ stub_docker_run_id() {
|
||||
#!/bin/bash
|
||||
case "\$1" in
|
||||
ps) echo dejima-test-container ;;
|
||||
inspect) echo "DEJIMA_RUN_ID=$1" ;;
|
||||
# One line per container, both values JSON, which is the shape
|
||||
# dejima-attach asks for so that one call answers for all of them.
|
||||
inspect) echo '"/dejima-test-container" ["PATH=/usr/bin","DEJIMA_RUN_ID=$1"]' ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
@@ -633,6 +648,9 @@ label=$3
|
||||
started=${4:-$(date +%s)}
|
||||
EOF
|
||||
[ -z "${5:-}" ] || printf 'pid=%s\n' "$5" >> "$RUNTIME_DIR/dejima/sessions/$1"
|
||||
# bin/dejima writes name= first; written last here on purpose, so the
|
||||
# reader is exercised against a field order it does not control.
|
||||
[ -z "${6:-}" ] || printf 'name=%s\n' "$6" >> "$RUNTIME_DIR/dejima/sessions/$1"
|
||||
}
|
||||
|
||||
# A pid that is certain to be dead: spawn a process and reap it, so the number
|
||||
@@ -718,6 +736,552 @@ dead_pid() {
|
||||
[[ "$output" == *"no-tmux"* ]]
|
||||
}
|
||||
|
||||
# A `docker` that records the launching window's name instead of its argv. The
|
||||
# rename happens before the first docker call and is undone by the EXIT trap,
|
||||
# so from outside the run there is no moment at which it is observable - the
|
||||
# stub is standing in for that moment.
|
||||
stub_window_probe() {
|
||||
cat > "$LAUNCH_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
tmux display-message -p -t '$TARGET_PANE' '#{window_name}' >> "$LAUNCH_DIR/winname"
|
||||
# And what the registry recorded for the same launch, which the exit trap
|
||||
# removes too. The two have to be one value: the window is how an operator
|
||||
# finds the session and the entry is how both front doors name it.
|
||||
sed -n 's/^name=//p' "$RUNTIME_DIR"/dejima/sessions/* 2>/dev/null >> "$LAUNCH_DIR/regname"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
: > "$LAUNCH_DIR/regname"
|
||||
}
|
||||
|
||||
@test "an unnamed launch takes the project's name, and a colliding one takes a suffix" {
|
||||
# Most launches pass no --name, so the default is what this feature mostly
|
||||
# is. A collision must not stop a launch: several sessions against one
|
||||
# project is the case the name exists for, not an error.
|
||||
stub_launch
|
||||
write_remote_config
|
||||
start_tmux
|
||||
stub_window_probe
|
||||
local proj; proj="$(basename "$PROJ")"
|
||||
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
# The window and the registry carry one value, not two derivations of it.
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/regname" \
|
||||
|| { echo "registry: $(cat "$LAUNCH_DIR/regname")"; return 1; }
|
||||
|
||||
# A live session already holding the name. $$ is this bats process, which
|
||||
# is alive by construction.
|
||||
write_entry 0000000000000001 '%1' 'work:1' '' $$ "$proj"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj-2" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
write_entry 0000000000000002 '%1' 'work:2' '' $$ "$proj-2"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj-3" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# The FIRST free suffix: a number a finished session gives back is used
|
||||
# again rather than the count climbing for the life of the host.
|
||||
rm "$RUNTIME_DIR/dejima/sessions/0000000000000001"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# An entry whose launcher is gone is holding no name: the exit trap removes
|
||||
# the entry, but a session killed outright leaves it behind.
|
||||
write_entry 0000000000000003 '%1' 'work:3' '' "$(dead_pid)" "$proj"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# An explicit --name is taken verbatim, collision or not: it was typed for
|
||||
# this launch, and handing back something else would be worse than two rows
|
||||
# that agree.
|
||||
write_entry 0000000000000004 '%1' 'work:4' '' $$ 'mine'
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch --name mine "$PROJ"
|
||||
grep -qxF 'mine' "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "a tmux window already holding the name is a collision too" {
|
||||
# The other source, and the one that works with remote access off: there is
|
||||
# no registry then, and two windows wearing one name is exactly what the
|
||||
# name is meant to prevent.
|
||||
stub_launch
|
||||
write_remote_config
|
||||
start_tmux
|
||||
stub_window_probe
|
||||
local proj; proj="$(basename "$PROJ")"
|
||||
|
||||
tmux rename-window -t work:0 "$proj"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj-2" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# But not this launch's own window, which may already carry the name from
|
||||
# the session before it or from automatic-rename guessing the directory.
|
||||
#
|
||||
# Asserted on the registry rather than on the window: renaming a window by
|
||||
# hand turns automatic-rename off, which is exactly the case where the
|
||||
# launch leaves the name alone - so the window would read `$proj` here
|
||||
# whatever label was chosen, and the assertion would hold for the wrong
|
||||
# reason.
|
||||
tmux rename-window -t work:0 'other'
|
||||
tmux rename-window -t "$TARGET_PANE" "$proj"
|
||||
: > "$LAUNCH_DIR/regname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/regname" \
|
||||
|| { echo "registry: $(cat "$LAUNCH_DIR/regname")"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "a project name too long or too odd for a label is cut down, not refused" {
|
||||
# --name refuses these shapes because the operator typed them. A directory
|
||||
# name was not chosen for this, so a launch that stopped over one would be
|
||||
# refusing over something it can fix itself.
|
||||
stub_launch
|
||||
write_remote_config
|
||||
start_tmux
|
||||
stub_window_probe
|
||||
|
||||
local long; long="$(printf 'x%.0s' {1..50})"
|
||||
local deep="$PROJ/$long"
|
||||
mkdir -p "$deep"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$deep"
|
||||
[ "$(head -1 "$LAUNCH_DIR/winname")" = "${long:0:32}" ] \
|
||||
|| { echo "window: $(head -1 "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# And the suffix still fits, which means the base gives room for it rather
|
||||
# than the label growing past the cap.
|
||||
write_entry 0000000000000001 '%1' 'work:1' '' $$ "${long:0:32}"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$deep"
|
||||
[ "$(head -1 "$LAUNCH_DIR/winname")" = "${long:0:30}-2" ] \
|
||||
|| { echo "window: $(head -1 "$LAUNCH_DIR/winname")"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "the launching tmux window is named for the session and handed back after" {
|
||||
# tmux's own navigation - choose-window, the status line, the window list -
|
||||
# shows a window name, and for a dozen sandboxes it showed a dozen copies
|
||||
# of whatever automatic-rename made of the container command.
|
||||
stub_launch
|
||||
start_tmux
|
||||
stub_window_probe
|
||||
|
||||
TMUX_PANE="$TARGET_PANE" launch --name 'release notes' "$PROJ"
|
||||
grep -qxF 'release notes' "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window was named: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
# Handed back, so the window does not keep the name of a session that has
|
||||
# exited. The option is the assertion rather than the name: tmux recomputes
|
||||
# the name on its own schedule, but turning the option back on is what this
|
||||
# does and what makes the recompute happen at all.
|
||||
[ "$(tmux display-message -p -t "$TARGET_PANE" '#{automatic-rename}')" = 1 ] \
|
||||
|| { echo "automatic-rename was left off"; return 1; }
|
||||
|
||||
# Without --name the project stands in, which is still better than the
|
||||
# container command for finding one window among many.
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$(basename "$PROJ")" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window was named: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# A window the operator named by hand keeps that name. tmux turns
|
||||
# automatic-rename off on rename, so the option is the evidence that the
|
||||
# current name is tmux's guess rather than a choice to leave alone.
|
||||
tmux rename-window -t "$TARGET_PANE" 'mine'
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF 'mine' "$LAUNCH_DIR/winname" \
|
||||
|| { echo "the operator's own window name was taken: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
# But an explicit --name was typed for this launch, so it wins over that.
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch --name 'explicit' "$PROJ"
|
||||
grep -qxF 'explicit' "$LAUNCH_DIR/winname" \
|
||||
|| { echo "--name lost to a hand-named window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
# The level the hook would have written, for a session this suite registered
|
||||
# by hand. $STATE_DIR/notify/<run-id>/level is the host's shape, not the
|
||||
# session's, so writing it here is standing in for the hook and not for a
|
||||
# sandbox reaching somewhere it should not.
|
||||
write_level() { # <run-id> <state>
|
||||
mkdir -p "$RUNTIME_DIR/dejima/notify/$1/level"
|
||||
: > "$RUNTIME_DIR/dejima/notify/$1/level/$2"
|
||||
}
|
||||
|
||||
@test "docker is asked twice for a whole listing, whatever it holds" {
|
||||
# It used to run `docker ps` and then inspect containers until it found a
|
||||
# match, per registry entry: quadratic in the number of sessions, on the
|
||||
# listing both front doors open on. A dozen parallel sessions is the case
|
||||
# this tool exists for, so the cost has to be linear in them.
|
||||
write_remote_config
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
|
||||
case "\$1" in
|
||||
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
|
||||
# Every name it was handed, one line each: dejima-attach asks about all
|
||||
# of them in one call, so a stub answering only for the last would
|
||||
# silently prune every other session.
|
||||
inspect)
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
c-*) printf '"/%s" ["DEJIMA_RUN_ID=%s"]\n' "\$a" "\${a#c-}" ;;
|
||||
esac
|
||||
done ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH"
|
||||
: > "$BATS_TEST_TMPDIR/docker-calls"
|
||||
local i
|
||||
for i in 1 2 3 4; do write_entry "000000000000000$i" '%1' "work:$i"; done
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(grep -c '^ps$' "$BATS_TEST_TMPDIR/docker-calls")" = 1 ] \
|
||||
|| { echo "docker ps ran $(grep -c '^ps$' "$BATS_TEST_TMPDIR/docker-calls") times"; return 1; }
|
||||
# One for all of them, not one each and not one per session: this is two
|
||||
# round trips to the daemon whatever the host is running, where it was one
|
||||
# plus one per container, and before that one plus one per container per
|
||||
# session.
|
||||
[ "$(grep -c '^inspect$' "$BATS_TEST_TMPDIR/docker-calls")" = 1 ] \
|
||||
|| { echo "inspect ran $(grep -c '^inspect$' "$BATS_TEST_TMPDIR/docker-calls") times"; return 1; }
|
||||
# And it still answered: a listing that asks nothing would also be cheap.
|
||||
[ "$(printf '%s\n' "$output" | wc -l)" = 4 ]
|
||||
}
|
||||
|
||||
@test "the picker's row is narrow enough for the phone that reads it" {
|
||||
# Five columns made a row about eighty characters wide, which wrapped onto
|
||||
# a second line on a phone and put the state and the age underneath the
|
||||
# NEXT session's number. The two that went are in --list, which is where a
|
||||
# script reads them: the tmux label is a coordinate nobody has to act on
|
||||
# now that picking a session goes to it, and every container id shares a
|
||||
# prefix with every other.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' \
|
||||
$(( $(date +%s) - 7200 )) $$ 'release-notes'
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
local row
|
||||
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F release-notes)"
|
||||
[ -n "$row" ] || { echo "no row for the session"; return 1; }
|
||||
[ "${#row}" -le 52 ] || { echo "row is ${#row} columns: $row"; return 1; }
|
||||
[[ "$row" != *"work:1"* ]] || { echo "the tmux label is still in the row: $row"; return 1; }
|
||||
[[ "$row" != *"c-0123456789"* ]] || { echo "the container id is still in the row: $row"; return 1; }
|
||||
# Two hours, not a hundred and twenty minutes: three characters is what
|
||||
# makes the row fit, and the sum is one an operator should not have to do.
|
||||
[[ "$row" == *"2h"* ]] || { echo "age reads: $row"; return 1; }
|
||||
|
||||
# Both are still in the listing a script reads, in the units it wants.
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"work:1"* ]] || { echo "--list lost the label: $output"; return 1; }
|
||||
[[ "$output" == *"120m"* ]] || { echo "--list lost the minutes: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the name column is what the terminal has room for, and says when it cut one" {
|
||||
# A fixed width is wrong in both directions at once: a desktop has room for
|
||||
# every name and would cut them anyway, and a narrow phone would still
|
||||
# overflow and wrap, which is the failure this layout exists to avoid.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' $$ 'flashpoint-ultimate-server'
|
||||
|
||||
local row
|
||||
run script -qec "stty cols 70; echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F flashpoint)"
|
||||
[[ "$row" == *"flashpoint-ultimate-server"* ]] \
|
||||
|| { echo "a terminal with room still cut it: $row"; return 1; }
|
||||
|
||||
run script -qec "stty cols 50; echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F flashpoint | tr -d '\r')"
|
||||
[[ "$row" != *"flashpoint-ultimate-server"* ]] \
|
||||
|| { echo "the name was not cut to fit: $row"; return 1; }
|
||||
# Marked, because a name quietly losing its last characters is worse than a
|
||||
# short column: two projects can differ only past the cut.
|
||||
[[ "$row" == *"~"* ]] || { echo "the cut was silent: $row"; return 1; }
|
||||
# The dot is one column and three bytes, and ${#} counts bytes wherever
|
||||
# the locale is not UTF-8 - which is this suite. Swapped for one byte so
|
||||
# the measurement is of columns, which is what has to fit.
|
||||
row="${row//●/*}"
|
||||
[ "${#row}" -le 50 ] || { echo "row is ${#row} columns on a 50-column terminal: $row"; return 1; }
|
||||
}
|
||||
|
||||
@test "a session still building reaches the picker as starting, not as attachable" {
|
||||
# It was the container column that said so, and that column is gone. A
|
||||
# session whose image is still being built is not one to attach to yet.
|
||||
write_remote_config
|
||||
stub_docker_run_id ffffffffffffffff
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' $$ 'building'
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
local row
|
||||
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F building)"
|
||||
[[ "$row" == *"starting"* ]] || { echo "row reads: $row"; return 1; }
|
||||
[[ "$row" != *"attachable"* ]] || { echo "row reads: $row"; return 1; }
|
||||
}
|
||||
|
||||
@test "an empty field does not shift the picker's columns" {
|
||||
# A tab is IFS whitespace, so a run of them collapses into one delimiter.
|
||||
# An entry written before the name field existed has an empty one, and the
|
||||
# picker then read the tmux label as the name, the state as the age and the
|
||||
# container id as the state - so a stale session was neither coloured as
|
||||
# one nor labelled stale where it was being read.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef '%99999' 'work:1'
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
local plain; plain="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output")"
|
||||
# The project is the first thing after the index and the dot, where the
|
||||
# label used to land.
|
||||
# [^[:space:]]+ for the dot rather than `.`, which matches one BYTE of a
|
||||
# three-byte glyph outside a UTF-8 locale.
|
||||
grep -qE '^[[:space:]]*1[[:space:]]+[^[:space:]]+[[:space:]]+myproject' <<< "$plain" \
|
||||
|| { echo "row: $(grep -F myproject <<< "$plain")"; return 1; }
|
||||
# And the state is in the state column, which is what colours it: under the
|
||||
# shift this was the age column's muted grey.
|
||||
[[ "$output" == *$'\033[91m'"stale"* ]] \
|
||||
|| { echo "stale was not coloured as a failure state"; return 1; }
|
||||
}
|
||||
|
||||
@test "the listing says which sessions want attention and which are working" {
|
||||
# The bouncing-between-windows problem: every row said "attachable",
|
||||
# which is true of all of them and the answer to a question nobody asked.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' $(( $(date +%s) - 300 ))
|
||||
write_entry fedcba9876543210 "$TARGET_PANE" 'work:2' $(( $(date +%s) - 200 ))
|
||||
write_entry 00112233445566aa "$TARGET_PANE" 'work:3' $(( $(date +%s) - 100 ))
|
||||
write_level 0123456789abcdef done
|
||||
write_level fedcba9876543210 running
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"0123456789abcdef"*$'\t'"waiting"$'\t'* ]] \
|
||||
|| { echo "stopped session: $output"; return 1; }
|
||||
[[ "$output" == *"fedcba9876543210"*$'\t'"working"$'\t'* ]] \
|
||||
|| { echo "running session: $output"; return 1; }
|
||||
# Said nothing yet, so the plain reachability answer stands. A harness that
|
||||
# reports no state at all has to keep working, and so does a session that
|
||||
# started before its first prompt.
|
||||
[[ "$output" == *"00112233445566aa"*$'\t'"attachable"$'\t'* ]] \
|
||||
|| { echo "silent session: $output"; return 1; }
|
||||
|
||||
# waiting is the other half of the same question, for a harness that can
|
||||
# say it: both render as one word, because an operator acts on them alike.
|
||||
rm "$RUNTIME_DIR/dejima/notify/0123456789abcdef/level/done"
|
||||
write_level 0123456789abcdef waiting
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"waiting"* ]] || { echo "$output"; return 1; }
|
||||
|
||||
# The hook clears the siblings before writing, so holding two levels at
|
||||
# once means a session has contradicted itself - which it can, the
|
||||
# directory being its own. The answer that sends an operator to look is the
|
||||
# one to be wrong with.
|
||||
write_level 00112233445566aa running
|
||||
write_level 00112233445566aa done
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"00112233445566aa"*$'\t'"waiting"$'\t'* ]] \
|
||||
|| { echo "contradicting session: $output"; return 1; }
|
||||
rm "$RUNTIME_DIR/dejima/notify/00112233445566aa/level/done" \
|
||||
"$RUNTIME_DIR/dejima/notify/00112233445566aa/level/running"
|
||||
|
||||
# A session that cannot be attached to has nothing useful to say about
|
||||
# whether it wants attention, so reachability still wins the column.
|
||||
write_entry ffeeddccbbaa9988 '%99999' 'work:9' $(( $(date +%s) - 50 ))
|
||||
write_level ffeeddccbbaa9988 done
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"ffeeddccbbaa9988"*$'\t'"stale"$'\t'* ]] \
|
||||
|| { echo "stale session: $output"; return 1; }
|
||||
|
||||
# Oldest first, and by start time rather than by state: the numbers are how
|
||||
# a session is picked, so an order that followed the state would renumber
|
||||
# the rows between an operator reading one and typing it.
|
||||
run bash -c "'$REPO_ROOT/bin/dejima-attach' --list | cut -f1 | tr '\n' ' '"
|
||||
[ "$output" = "0123456789abcdef fedcba9876543210 00112233445566aa ffeeddccbbaa9988 " ] \
|
||||
|| { echo "order was: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--count-waiting counts the sessions an operator has to go to, and asks docker nothing" {
|
||||
# For a tmux status line, which re-runs it every few seconds: the point of
|
||||
# the number is to not have to open anything, so it must not cost a round
|
||||
# trip per container to produce.
|
||||
write_remote_config
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH"
|
||||
: > "$BATS_TEST_TMPDIR/docker-calls"
|
||||
start_tmux
|
||||
|
||||
# Two waiting, one working, one waiting but unreachable: the last is a
|
||||
# session nothing can be done about, and counting it sends an operator
|
||||
# looking for a window that is not there.
|
||||
write_entry 0000000000000001 "$TARGET_PANE" 'work:1' '' $$
|
||||
write_entry 0000000000000002 "$TARGET_PANE" 'work:2' '' $$
|
||||
write_entry 0000000000000003 "$TARGET_PANE" 'work:3' '' $$
|
||||
write_entry 0000000000000004 '%99999' 'work:4' '' $$
|
||||
write_level 0000000000000001 done
|
||||
write_level 0000000000000002 waiting
|
||||
write_level 0000000000000003 running
|
||||
write_level 0000000000000004 done
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --count-waiting
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = 2 ] || { echo "counted $output"; return 1; }
|
||||
[ ! -s "$BATS_TEST_TMPDIR/docker-calls" ] \
|
||||
|| { echo "docker was asked: $(cat "$BATS_TEST_TMPDIR/docker-calls")"; return 1; }
|
||||
|
||||
# And a dead launcher is not waiting for anyone.
|
||||
write_entry 0000000000000005 "$TARGET_PANE" 'work:5' '' "$(dead_pid)"
|
||||
write_level 0000000000000005 done
|
||||
run "$REPO_ROOT/bin/dejima-attach" --count-waiting
|
||||
[ "$output" = 2 ] || { echo "counted $output with a dead launcher"; return 1; }
|
||||
}
|
||||
|
||||
@test "--counts answers for every state in one pass, and asks docker nothing" {
|
||||
# Two numbers on a status bar cost two invocations otherwise, and this is
|
||||
# the one thing here that runs on a timer.
|
||||
write_remote_config
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH"
|
||||
: > "$BATS_TEST_TMPDIR/docker-calls"
|
||||
start_tmux
|
||||
|
||||
write_entry 0000000000000001 "$TARGET_PANE" 'work:1' '' $$
|
||||
write_entry 0000000000000002 "$TARGET_PANE" 'work:2' '' $$
|
||||
write_entry 0000000000000003 "$TARGET_PANE" 'work:3' '' $$
|
||||
write_entry 0000000000000004 '%99999' 'work:4' '' $$
|
||||
write_entry 0000000000000005 '' '' '' $$
|
||||
write_level 0000000000000001 done
|
||||
write_level 0000000000000002 running
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --counts
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "waiting=1 working=1 attachable=1 stale=1 no-tmux=1 total=5" ] \
|
||||
|| { echo "counts read: $output"; return 1; }
|
||||
[ ! -s "$BATS_TEST_TMPDIR/docker-calls" ] \
|
||||
|| { echo "docker was asked: $(cat "$BATS_TEST_TMPDIR/docker-calls")"; return 1; }
|
||||
|
||||
# The words are the picker's, so a number on a status bar and a row on a
|
||||
# screen say the same thing about the same session.
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"waiting"* ]] && [[ "$output" == *"no-tmux"* ]] \
|
||||
|| { echo "the listing uses different words: $output"; return 1; }
|
||||
|
||||
# A session whose launcher is gone is not counted at all: the listing is
|
||||
# where those are found, and a status bar that never reaches zero stops
|
||||
# being read.
|
||||
write_entry 0000000000000006 "$TARGET_PANE" 'work:6' '' "$(dead_pid)"
|
||||
write_level 0000000000000006 done
|
||||
run "$REPO_ROOT/bin/dejima-attach" --counts
|
||||
[ "$output" = "waiting=1 working=1 attachable=1 stale=1 no-tmux=1 total=5" ] \
|
||||
|| { echo "a dead launcher was counted: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--state-dir names the directory the registry is actually in" {
|
||||
# For anything on this host that wants to read the states without
|
||||
# re-deriving which of XDG_RUNTIME_DIR and /tmp this user got.
|
||||
write_remote_config
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --state-dir
|
||||
[ "$status" -eq 0 ]
|
||||
[ -d "$output" ] || { echo "not a directory: $output"; return 1; }
|
||||
[ -f "$output/sessions/0123456789abcdef" ] \
|
||||
|| { echo "the registry is not under $output"; return 1; }
|
||||
# And the levels, which is the half an outside reader is actually after.
|
||||
write_level 0123456789abcdef running
|
||||
[ -e "$output/notify/0123456789abcdef/level/running" ] \
|
||||
|| { echo "the levels are not under $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--watch redraws the list until something is typed" {
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
|
||||
|
||||
# Nothing typed for longer than one interval, then a quit. Two drawings of
|
||||
# the heading is the assertion: the one-shot picker would print it once and
|
||||
# then sit on a read that never returns.
|
||||
run script -qec "{ sleep 8; echo q; } | '$REPO_ROOT/bin/dejima-attach' --watch" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(grep -c 'live sessions' <<< "$output")" -ge 2 ] \
|
||||
|| { echo "drew $(grep -c 'live sessions' <<< "$output") times"; return 1; }
|
||||
|
||||
# And it is still the picker: a number attaches rather than being read as
|
||||
# part of the next redraw. Nothing here can attach - there is no terminal
|
||||
# for it - but reaching the attempt at all is what distinguishes picking
|
||||
# from the prompt timing out again.
|
||||
run script -qec "echo 1 | '$REPO_ROOT/bin/dejima-attach' --watch" /dev/null
|
||||
[[ "$output" == *"live sessions"* ]]
|
||||
[ "$(grep -c 'live sessions' <<< "$output")" = 1 ] \
|
||||
|| { echo "a typed choice did not end the loop"; return 1; }
|
||||
}
|
||||
|
||||
@test "a session's name reaches both the listing and the picker" {
|
||||
# The case the name exists for: two sessions, one workspace, so the
|
||||
# project column answers nothing. --list carries both fields; the picker
|
||||
# has one column for the two and spends it on whichever is more specific.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' '' 'release notes'
|
||||
write_entry fedcba9876543210 "$TARGET_PANE" 'work:2'
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"0123456789abcdef"$'\t'"release notes"$'\t'"work:1"$'\t'"myproject"$'\t'* ]] \
|
||||
|| { echo "named row: $output"; return 1; }
|
||||
# An entry from before the field existed: present and empty rather than
|
||||
# absent, so a reader splitting on tabs does not find the project where
|
||||
# the name should be. A launch always writes one now.
|
||||
[[ "$output" == *"fedcba9876543210"$'\t'$'\t'"work:2"$'\t'"myproject"$'\t'* ]] \
|
||||
|| { echo "unnamed row: $output"; return 1; }
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"release notes"* ]] \
|
||||
|| { echo "the picker dropped the name: $output"; return 1; }
|
||||
[[ "$output" == *"myproject"* ]] \
|
||||
|| { echo "the picker dropped the unnamed row's project: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--list marks a session whose pane is gone as stale" {
|
||||
write_remote_config
|
||||
stub_docker_run_id 0123456789abcdef
|
||||
@@ -735,7 +1299,15 @@ stub_docker_sessions() {
|
||||
#!/bin/bash
|
||||
case "\$1" in
|
||||
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
|
||||
inspect) name="\${!#}"; echo "DEJIMA_RUN_ID=\${name#c-}" ;;
|
||||
# Every name it was handed, one line each: dejima-attach asks about all
|
||||
# of them in one call, so a stub answering only for the last would
|
||||
# silently prune every other session.
|
||||
inspect)
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
c-*) printf '"/%s" ["DEJIMA_RUN_ID=%s"]\n' "\$a" "\${a#c-}" ;;
|
||||
esac
|
||||
done ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
@@ -865,6 +1437,46 @@ start_tmux() {
|
||||
done
|
||||
}
|
||||
|
||||
@test "inside tmux the picker switches the client instead of nesting a session" {
|
||||
# tmux refuses to attach a session from inside one of its own panes, so on
|
||||
# the operator's own machine the picker could list every session and then
|
||||
# reach none of them. Switching is also what makes it a window switcher,
|
||||
# which is the thing a dozen parallel sessions actually need.
|
||||
write_remote_config
|
||||
stub_docker_run_id 0123456789abcdef
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
|
||||
|
||||
# A real client, because switch-client has nothing to move without one,
|
||||
# and it starts on window 0 while the session being picked is window 1.
|
||||
script -qec "tmux attach -t work" /dev/null >/dev/null 2>&1 &
|
||||
client_pid=$!
|
||||
for _ in $(seq 100); do
|
||||
[ -n "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
tmux select-window -t work:0
|
||||
[ "$(tmux display-message -p -t work '#{window_index}')" = "0" ]
|
||||
|
||||
# Run from inside a pane of that session, which is how an operator reaches
|
||||
# it and the only way $TMUX is set for the right server.
|
||||
tmux new-window -t work -d \
|
||||
"'$REPO_ROOT/bin/dejima-attach' 0123456789abcdef; touch '$BATS_TEST_TMPDIR/switched'"
|
||||
for _ in $(seq 100); do
|
||||
[ -e "$BATS_TEST_TMPDIR/switched" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -e "$BATS_TEST_TMPDIR/switched" ] || { echo "dejima-attach never returned"; return 1; }
|
||||
|
||||
# The client moved to the sandbox's own window, and no throwaway session
|
||||
# was created to do it.
|
||||
[ "$(tmux display-message -p -t work '#{window_index}')" = "1" ] \
|
||||
|| { echo "client is on window $(tmux display-message -p -t work '#{window_index}')"; return 1; }
|
||||
run tmux has-session -t '=dejima-0123456789abcdef'
|
||||
[ "$status" -ne 0 ] || { echo "a grouped session was created anyway"; return 1; }
|
||||
kill "$client_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
|
||||
# The positive direction, which is where every interesting tmux mistake
|
||||
# lives: an invalid set-option target, or setting destroy-unattached while
|
||||
@@ -1371,6 +1983,46 @@ EOF
|
||||
rm -rf "$NOTIFY" "$payload_file"
|
||||
}
|
||||
|
||||
@test "the notify hook keeps a level beside the edge, and only the edge is pushable" {
|
||||
# The edge markers are consumed by the watcher, so after a push there is
|
||||
# nothing left to read - and with no watcher running, nothing has read them
|
||||
# at all. The level answers "which state is this session in now" for the
|
||||
# picker and the status line, which is a different question from "it just
|
||||
# changed" and needs an answer that survives being read.
|
||||
NOTIFY="$(mktemp -d)"
|
||||
mkdir "$NOTIFY/level"
|
||||
|
||||
# `running` is level-only: the watcher iterates waiting and done, so an
|
||||
# edge marker it never looks for would be a file nothing ever removes.
|
||||
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" running
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(ls -A "$NOTIFY" | sort | tr '\n' ' ')" = "level " ] \
|
||||
|| { echo "running left an edge marker: $(ls -A "$NOTIFY")"; return 1; }
|
||||
[ "$(ls -A "$NOTIFY/level")" = running ]
|
||||
[ ! -s "$NOTIFY/level/running" ]
|
||||
|
||||
# Exactly one level at a time, so a reader never has to choose.
|
||||
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" done
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(ls -A "$NOTIFY/level")" = done ] \
|
||||
|| { echo "level holds: $(ls -A "$NOTIFY/level")"; return 1; }
|
||||
[ -f "$NOTIFY/done" ] || { echo "done left no edge marker"; return 1; }
|
||||
|
||||
# The same whitelist covers the level: a name outside the enum writes
|
||||
# neither, and leaves the state that was there.
|
||||
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" 'running;done'
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(ls -A "$NOTIFY/level")" = done ]
|
||||
|
||||
# A run directory with no level/ is a launch from before it existed. The
|
||||
# edge still works there, which is what keeps notifications independent.
|
||||
OLD="$(mktemp -d)"
|
||||
DEJIMA_NOTIFY_DIR="$OLD" run "$REPO_ROOT/hooks/dejima-notify.sh" waiting
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$OLD/waiting" ]
|
||||
rm -rf "$NOTIFY" "$OLD"
|
||||
}
|
||||
|
||||
@test "the notify hook refuses any event name outside the enum" {
|
||||
NOTIFY="$(mktemp -d)"
|
||||
for bad in 'error' '../escape' 'waiting done' '' '$(id)' 'waiting;done' '.'; do
|
||||
@@ -1419,6 +2071,21 @@ EOF
|
||||
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
|
||||
[[ "$output" != *operator-desktop-notify.sh* ]]
|
||||
[[ "$output" != *operator-tts.sh* ]]
|
||||
|
||||
# The complement of Stop, for the level and not for the phone: without it
|
||||
# a picker cannot tell "this session has stopped" from "nothing has been
|
||||
# heard from this session". It pushes nothing, because the hook writes no
|
||||
# edge marker for `running`.
|
||||
grep -q 'dejima-notify.sh running' "$LAUNCH_DIR/settings-copy.json" \
|
||||
|| { echo "nothing marks the session as working again"; return 1; }
|
||||
[ "$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "$LAUNCH_DIR/settings-copy.json")" \
|
||||
= "/usr/local/lib/dejima/hooks/dejima-notify.sh running" ]
|
||||
|
||||
# And the directory the hook writes that level into is the host's to
|
||||
# create, like the marker directory it sits in. Observed from inside the
|
||||
# run, because the EXIT trap takes the whole tree with it.
|
||||
grep -qxF level "$LAUNCH_DIR/level-probe" \
|
||||
|| { echo "no level directory was created inside the marker directory"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,24 @@ so they cannot quietly stop running.
|
||||
The rest — gestures, the viewport manager, the toolbar — needs a real browser
|
||||
and a real phone. `docs/remote-access.md` has the manual checklist.
|
||||
|
||||
One of them is automated, against a real browser and a mock ttyd:
|
||||
|
||||
```sh
|
||||
node test/browser/sess-button.mjs
|
||||
```
|
||||
|
||||
It drives the built page in Chromium and proves the thing the unit tests
|
||||
cannot: that SESS reaches the picker on a new WebSocket, without the page
|
||||
being fetched or run a second time. Three seconds, and it needs Playwright,
|
||||
which a dejima sandbox has and this package deliberately does not depend on -
|
||||
a browser is a strange thing to install to inline two files.
|
||||
|
||||
Deliberately not part of `npm test`, which CI runs in a `node:20-bookworm`
|
||||
container with no browser in it — a test that cannot run there would have to
|
||||
be skipped, and a skipped test is one nobody is reading. What keeps it from
|
||||
rotting instead is [docs/release.md](../docs/release.md), whose manual list
|
||||
names it for any release that touches `web/`.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Role |
|
||||
|
||||
+65
-3
@@ -14,7 +14,7 @@
|
||||
no path a /favicon.ico could be fetched from, so a link to one would 404 and
|
||||
the tab would fall back to the generic icon. -->
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAABlCAYAAABUfC3PAAAACXBIWXMAAAsSAAALEgHS3X78AAAK9UlEQVR4nO2db0wb5x3Hv6BAa2w0ggcjJlHsOHYkYgs8oIFIThMsUTlCJJsEFW/CgjJpaFsi7VWFtBfTVNZXUYJU5Q1K11ckIK0KQ0JDcrTWVU0FFCMcpJxLMQoYhOuEyf+K3XV74dxxZ5/xnX1nH+E+b+D85+6xPzzP7+653++hBAACxNRlyEgBd42xa7ckQEx9BuB6sVsjAwDYBaArhSxESlQBaCotditk0pGlSBBZigSRpUgQWYoEkaVIEFmKBJGlSBBZigSRpUgQWYoEkaVIkGPFbgBXvGt+hCMxuJdXAQBbO6+wvfOS9bUqpQIGnQYAcFangUqlgMWkL1hb80WSUrxrfriXV+Fd8+Pb7zbhXfPz3odz1pP2WF1tNQxnNDDoNGgy6yUrqiRATP2v2I0gJSx6Vlm/TDEx6DSwtpmkJOlK0aR41/yYdszBOfss4zBEp1KtRKW6AupTVShXlFHbbOzFEgi+2AUAbBEBxvZBqJQKWNtMsF44D4tZD5VSwe9DCUNhpXAVUV5RBo2xBuqTVcmfr0XkyxYRwPcbuwi++A+2iABCwciBr7fbWmG9cB7WNlPex+aB+FLCkRimHfMYn3QeKELbpMEJYw0loxCEglFsEQH4iQB8S5uIRxOsr1MpFbDbWtDbfQl1tcfFbpZ4UhY9q5h2zGPaMcf6fHlFGbSN9dA2aaAx1gjSE/JliwjAt+SHz+3P2IssZj3sHa2w21rEaobwUqYd85iY/CLjGZO2SQNtYz2M7aeFPKzgbBEBPHetZ+xBdbXVuGprQU+3VejYI5yUacc8Ho7NsA5RlWolTLazONeulUSP4AvhWgfh8sFPBNKeUykV6O22wm5rFWpoy1/KxKQTD8dmEI7E0p7TNmlg7jDghLEmn0NIhlAwioWplYy9x25rxe1b3fn2nNylZOoZZKxo7mrIeMp62InHElh2eEG41tNiD9lz8hjW+EtZ9Kxi+N5jVhnmDgPMNsOhHKJyhXCtY2FqhVXOQF8nerqtfHfJXcr2ziuMjD5Ju+I+qjJSySTHoNPgj7+9xme2gJuUTHHDbDOguavhSMtIZfmpFwtTK2kxp6fbioG+Ti5DWnYp0455DN9/xHhMY6zBu/2tb2zMyJd4LIGFqRUsO7yMx+22VgzdeT/b269knSVO7R2dgxehbdTwbeeRolxRhvaeRhjbT2PmgYsa0tjOUNnIepMrdd5H7h3cUZ+swl4sTm1bL3CbQ8sqpa72OEPMsuPbHJp3NCFc61RsIefPuMDpdrC9Y39nhMuHeIx94k6GCeHyUb/zmSvjJMXaZkJdbTW1/Zx2MBl2QsEoY1qmt/sS5/dyTpy4SjPtkYewrHie7p95Wcx6XvNinKXYba3U76FgBFssk3MySeKxBGM0sXe0ZnwtG5yl1NUeZ4h57lrndaCjhM/tzynAk/DK+6LvXA74mVmmDV0qpQLTjnle7+clxWLSywE/C8GNXUaSxvbOSwzffwR73585Z+rwzpDspc16ygE/nUzXceFIDCOjk5z2wVsKfQiTAz6TeCwB39Imtf3BL3/AjXN71Pb2zktOQxlvKcnAJQd8NugBvl75E24Y9/CB5Qdc1+1PtTwcm8m6n5wSvOWAzw49wF/X7X8nv6L9ziXxMCcpqQE/dYr6KJIa4Om947O1/ftNXBL7ci6FoAd8Qh7CGAHedjKBeuVPAIBQogSOzX0p9HnETOQsJTXg+5b4Z8a/KaQGePrQ5dgoQyheAiCZK8alp+RcCkEGfDIDknD5CnLzK7ixi3g0gVAwmjGLsbyiDD9/nfpaiPSm5y4f4/bvZqQUm5FSzO0cw8eet6jHr3K8ss+rPqWn20pJSaZ6RgW9CUbP9U0ds/lQqVZCfepnouUqp16vffTN2/jom7fTXkc/az2IvKQYdMkCHDJFlXD50NzVkM8uEdzYBeFaPzCfly+hYCQ5xLqT7aTnMefbu7lk7wPJP2CuM8V5V3L1dF+iEisI13pOUshZVcK1zrk3vFP7I1pr/8v63GYkGVzJsTzteNEECJcPhMuH8ooynGvXwtRhyKmX06/TLGY9rBdMVIVBMoac552tn7cUa9t5qEYVCEdiVMDn+teXLQ0USH5Qi0mPJrMet4ceUI//3rSH1tof2febcsZz+9Y1AMlEwsXlVUYCQzyazHZcdnh5p9nGYwnG3cWbfZ2wmPS5JOAxyFsKWf3EJ+BvEQEsP/VSw0kqyWoqU1I4LU/K2maiJvX+8GUF/vFemDr1JAklSjD0tYLqJSqlgvqSyJ/OWQ+cXz+Dc9bDEORzJ8sg1KeqYO4wZK0MoE/I1tVWC1aeJ0ghKteAv0UEsDC1wpq9TpYWHJS9frOvk5ISipfg1/9S4YYxjnde95jNSCk+9ryFzcj+mf5AX2fafqxtJljbTAjf6oZz9lla6UbwxS7+/ekcFqZW0NzVkFEOPcD35tk76AhWCjFw5y71wZq7Ghix5SAZfItw2JIDM8Ex+Q3AwUVOlWplmpwtIoB/3v18v11jfxWqTkXY+hTyyyqvKMNv7l7LKoMcg/ninPVg+P7jA5PbBvo6cZOll2Rje+cVHo7NHChH26TBV+NLVDzhI58DwkkJR2LouTVMfVHlFWUZazjstpa8x1+ylnLaMUf1UCFrE7d3XmF88gtMO+bT5Kd+tpHhQSHLvYUtrxu+/zhjjaPd1oqBvk7BCzkXPavUWZnFrMfIh4OC7j8ciWFi0onxSSdrz6yrrcbE6JCQh8yeS8wHesAnEUtGoVApFbj5us6ETY6QAZ5EUCkGnQY93Va4l1dx9kz9oZaRSqoc56wHdb+oFqVKWPC1WcgLtTcVUk4uJxFckZeWkiCyFAkiS5EgshQJIon1vrgQjsSodcHoq+J5v/MzTlEt5uRFHLk63lmdBoYz9YfpLLB4631xwbvmx5ezHjhnPTmtjkeHvLdhMekLvVQUX6QnJRyJsc7cCkmuN58KhHSkZJvOAJITgieMNahUV0Dz+kaUirZCXpy2At73G7sIB6MIvthlnRAlkeCMgzSkZFt0R9tYT8nIhXgskVxoze3PeJeTx+IDYlNcKd41P/5271HaMEUuLWJs1wpeIh6PJeBz+zOupzJ05/1ix5ziSZl2zGNk9AmjdxR6nRffkh+u8aU0OUXuNcWRwjbFX8x1XtjWUzHoNBgZHiyGmMJKCUdiuD30gDFclVeU4b3fXSz6Qm2hYBSffzrHOClQKRUYGR6kVgMvEIWTwiZEY6xB5+BFSa2C5JpYYlQRFEHMlYJMs7AJMbZr0fWndyUlBADaexpxuX8/vZSt7WJTECnD9x+nCbncL9oSsnljbD+dJmbow79zXoUoX0SX8snYDKMqVupCSFLFbO+8ZGRoiomoUhY9q4wav8MihCRVjHfNj5HRJ6IfVzQpZJcnUZ+qwsXeRrEOJxrG9tMwtmup7YlJJxY9q6IeUzQpI6OTjBywy/0tkgvqXLnc3wL1qf2aluF7j0U9nihSkimg+xeHzV0NBfunAmJBH3a3d17iEw6l17kiihR6gzXGGpg7DGIcpqCoT1Yx8qMPms3OF8FTjMgaEJJQMIopWiL0YWaPtl4AeatBjFQjwaVMTDoZ22Rp25vIuEhSBB++CjxPVFQMZ8T5rKLMfYl9yigVDDqNGLPIwiZ4k0jkv8AdWuS8LwkiS5EgshQJIkuRILIUCSJLkSCyFAkiS5EgshQJIkuRILIUCVIKwFfsRsgw2D0G4C8A+ovdEhkAgLvG2OX+Pzk3fgySn1OHAAAAAElFTkSuQmCC">
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: c43f6331189abbbe08955f8548a631132008ba6051a6274088be6f85c0f7d2a4 -->
|
||||
<style>
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
@@ -1011,6 +1011,7 @@ body.mono-keys {
|
||||
// onStatus(string), size() -> {cols, rows}}
|
||||
function createClient(opts) {
|
||||
var urls = endpoints(opts.location);
|
||||
var location = opts.location;
|
||||
var encoder = new TextEncoder();
|
||||
var decoder = new TextDecoder();
|
||||
var socket = null;
|
||||
@@ -1108,9 +1109,45 @@ body.mono-keys {
|
||||
};
|
||||
}
|
||||
|
||||
// Point the connection at a different ttyd argument. ttyd reads --url-arg
|
||||
// off the WebSocket request rather than off the page load, so a different
|
||||
// command is a different socket and nothing else: the half a megabyte of
|
||||
// terminal this page is does not have to be fetched, parsed and run again
|
||||
// to reach one.
|
||||
//
|
||||
// The old socket's handlers are detached before it is closed, rather than
|
||||
// a flag being read inside them. onclose is delivered asynchronously, so a
|
||||
// handler left attached can arrive after the replacement socket exists and
|
||||
// null the field holding it - and the reconnect it would schedule is for
|
||||
// the url this call just replaced.
|
||||
function switchTo(search) {
|
||||
urls = endpoints({
|
||||
protocol: location.protocol,
|
||||
host: location.host,
|
||||
pathname: location.pathname,
|
||||
search: search
|
||||
});
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
backoff = RECONNECT_MIN_MS;
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
connect: connect,
|
||||
urls: urls,
|
||||
switchTo: switchTo,
|
||||
// A function, not the object: switchTo replaces it, and a copy taken
|
||||
// here would answer for the connection this client used to have.
|
||||
urls: function () { return urls; },
|
||||
isOpen: open,
|
||||
sendInput: sendInput,
|
||||
sendBinary: sendBinary,
|
||||
@@ -1879,6 +1916,31 @@ body.mono-keys {
|
||||
}
|
||||
});
|
||||
|
||||
// The SESS button: back to dejima-attach with no run id, which is the
|
||||
// picker. A new socket rather than a new page - ttyd takes the run id as a
|
||||
// WebSocket argument, so the command changes without the half a megabyte of
|
||||
// inlined terminal being fetched, parsed and run a second time. On a phone
|
||||
// that was most of the wait.
|
||||
//
|
||||
// The terminal is reset first, and the mode tracker with it. What the socket
|
||||
// carries away is the process, not the state it left in here: an alternate
|
||||
// screen, mouse reporting, a cursor key mode. The picker would otherwise
|
||||
// open into whatever the session it replaced had switched on, and the
|
||||
// gestures would still be sending mouse reports to something that is not
|
||||
// asking for them.
|
||||
//
|
||||
// replaceState rather than a history entry: the address bar has to stop
|
||||
// saying ?arg=<run-id> or a reload would reopen the session just left, and
|
||||
// going "back" to a session that ended is not a thing to offer.
|
||||
function goToPicker(search) {
|
||||
term.reset();
|
||||
mouse.modes.clear();
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', window.location.pathname + search);
|
||||
}
|
||||
client.switchTo(search);
|
||||
}
|
||||
|
||||
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
|
||||
// because the target is a phone: soft keyboards overwhelmingly report
|
||||
// keydown as "Unidentified" and deliver the real character through an input
|
||||
@@ -1938,7 +2000,7 @@ body.mono-keys {
|
||||
prefix: function () { return prefixByte; },
|
||||
notify: notify,
|
||||
search: function () { return window.location.search; },
|
||||
navigate: function (search) { window.location.search = search; },
|
||||
navigate: goToPicker,
|
||||
allowKeyboard: keyboard.allow
|
||||
});
|
||||
toolbar.attach();
|
||||
|
||||
+2
-2
@@ -5,12 +5,12 @@ f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.j
|
||||
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
||||
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
||||
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
||||
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
|
||||
05bc2e446c3baa57422c8fa036939a0df8cf35ac0cf02bb35233befafae5667f src/main.js
|
||||
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
|
||||
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
|
||||
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
||||
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
|
||||
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
|
||||
2490ec55a050d993364a62f590b51e63945ea5b1276832821d6d1d0854fd5c81 src/protocol.js
|
||||
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
||||
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
|
||||
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
|
||||
|
||||
+26
-1
@@ -139,6 +139,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
// The SESS button: back to dejima-attach with no run id, which is the
|
||||
// picker. A new socket rather than a new page - ttyd takes the run id as a
|
||||
// WebSocket argument, so the command changes without the half a megabyte of
|
||||
// inlined terminal being fetched, parsed and run a second time. On a phone
|
||||
// that was most of the wait.
|
||||
//
|
||||
// The terminal is reset first, and the mode tracker with it. What the socket
|
||||
// carries away is the process, not the state it left in here: an alternate
|
||||
// screen, mouse reporting, a cursor key mode. The picker would otherwise
|
||||
// open into whatever the session it replaced had switched on, and the
|
||||
// gestures would still be sending mouse reports to something that is not
|
||||
// asking for them.
|
||||
//
|
||||
// replaceState rather than a history entry: the address bar has to stop
|
||||
// saying ?arg=<run-id> or a reload would reopen the session just left, and
|
||||
// going "back" to a session that ended is not a thing to offer.
|
||||
function goToPicker(search) {
|
||||
term.reset();
|
||||
mouse.modes.clear();
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', window.location.pathname + search);
|
||||
}
|
||||
client.switchTo(search);
|
||||
}
|
||||
|
||||
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
|
||||
// because the target is a phone: soft keyboards overwhelmingly report
|
||||
// keydown as "Unidentified" and deliver the real character through an input
|
||||
@@ -198,7 +223,7 @@
|
||||
prefix: function () { return prefixByte; },
|
||||
notify: notify,
|
||||
search: function () { return window.location.search; },
|
||||
navigate: function (search) { window.location.search = search; },
|
||||
navigate: goToPicker,
|
||||
allowKeyboard: keyboard.allow
|
||||
});
|
||||
toolbar.attach();
|
||||
|
||||
+38
-1
@@ -41,6 +41,7 @@
|
||||
// onStatus(string), size() -> {cols, rows}}
|
||||
function createClient(opts) {
|
||||
var urls = endpoints(opts.location);
|
||||
var location = opts.location;
|
||||
var encoder = new TextEncoder();
|
||||
var decoder = new TextDecoder();
|
||||
var socket = null;
|
||||
@@ -138,9 +139,45 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Point the connection at a different ttyd argument. ttyd reads --url-arg
|
||||
// off the WebSocket request rather than off the page load, so a different
|
||||
// command is a different socket and nothing else: the half a megabyte of
|
||||
// terminal this page is does not have to be fetched, parsed and run again
|
||||
// to reach one.
|
||||
//
|
||||
// The old socket's handlers are detached before it is closed, rather than
|
||||
// a flag being read inside them. onclose is delivered asynchronously, so a
|
||||
// handler left attached can arrive after the replacement socket exists and
|
||||
// null the field holding it - and the reconnect it would schedule is for
|
||||
// the url this call just replaced.
|
||||
function switchTo(search) {
|
||||
urls = endpoints({
|
||||
protocol: location.protocol,
|
||||
host: location.host,
|
||||
pathname: location.pathname,
|
||||
search: search
|
||||
});
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
backoff = RECONNECT_MIN_MS;
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
connect: connect,
|
||||
urls: urls,
|
||||
switchTo: switchTo,
|
||||
// A function, not the object: switchTo replaces it, and a copy taken
|
||||
// here would answer for the connection this client used to have.
|
||||
urls: function () { return urls; },
|
||||
isOpen: open,
|
||||
sendInput: sendInput,
|
||||
sendBinary: sendBinary,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Enough of ttyd to drive the real page: the three paths its HTTP handler
|
||||
// answers, and a WebSocket that spawns nothing but speaks the frame protocol
|
||||
// src/protocol.js was written against.
|
||||
//
|
||||
// Hand-rolled rather than a dependency, because web/ ships with two and both
|
||||
// are the terminal itself. The frames here are the ones this page sends and
|
||||
// receives and no others: a text handshake, masked client frames under 64KB,
|
||||
// and unmasked server frames.
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
function accept(key) {
|
||||
return crypto.createHash('sha1').update(key + GUID).digest('base64');
|
||||
}
|
||||
|
||||
// A server frame: FIN set, binary opcode, no mask, two length forms.
|
||||
function frame(payload) {
|
||||
const len = payload.length;
|
||||
const head = len < 126 ? Buffer.from([0x82, len])
|
||||
: Buffer.concat([Buffer.from([0x82, 126]), Buffer.from([len >> 8, len & 0xff])]);
|
||||
return Buffer.concat([head, payload]);
|
||||
}
|
||||
|
||||
// One client frame off the front of a buffer, or null when it is not all here
|
||||
// yet. Only the opcodes this page sends: text, binary and close.
|
||||
function unframe(buf) {
|
||||
if (buf.length < 2) return null;
|
||||
const masked = (buf[1] & 0x80) !== 0;
|
||||
let len = buf[1] & 0x7f;
|
||||
let off = 2;
|
||||
if (len === 126) {
|
||||
if (buf.length < 4) return null;
|
||||
len = buf.readUInt16BE(2);
|
||||
off = 4;
|
||||
}
|
||||
const maskLen = masked ? 4 : 0;
|
||||
if (buf.length < off + maskLen + len) return null;
|
||||
const mask = masked ? buf.subarray(off, off + 4) : null;
|
||||
const body = Buffer.from(buf.subarray(off + maskLen, off + maskLen + len));
|
||||
if (mask) for (let i = 0; i < body.length; i++) body[i] ^= mask[i % 4];
|
||||
return { opcode: buf[0] & 0x0f, body, rest: buf.subarray(off + maskLen + len) };
|
||||
}
|
||||
|
||||
// opts.onConnect(url) -> the text this "session" prints. Returning different
|
||||
// text for the picker and for a run id is what lets a test tell which command
|
||||
// ttyd would have run.
|
||||
export async function startMockTtyd(indexPath, opts) {
|
||||
const index = fs.readFileSync(indexPath);
|
||||
const pageLoads = [];
|
||||
const sockets = [];
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ token: '' }));
|
||||
return;
|
||||
}
|
||||
if (path === '/' || path === '/index.html') {
|
||||
pageLoads.push(req.url);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(index);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.on('upgrade', (req, socket) => {
|
||||
sockets.push({ url: req.url, closed: false, input: '' });
|
||||
const mine = sockets[sockets.length - 1];
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||
'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
|
||||
'Sec-WebSocket-Protocol: tty\r\n' +
|
||||
'Sec-WebSocket-Accept: ' + accept(req.headers['sec-websocket-key']) + '\r\n\r\n');
|
||||
let buf = Buffer.alloc(0);
|
||||
let spawned = false;
|
||||
socket.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
for (;;) {
|
||||
const f = unframe(buf);
|
||||
if (!f) break;
|
||||
buf = f.rest;
|
||||
if (f.opcode === 0x8) { socket.end(); return; }
|
||||
// An input frame is '0' and then the bytes. Recorded so a test can see
|
||||
// that what is typed after a switch reaches the new command rather
|
||||
// than the socket that was replaced.
|
||||
if (spawned && f.body.length > 1 && f.body[0] === 0x30) {
|
||||
mine.input += f.body.subarray(1).toString();
|
||||
}
|
||||
// ttyd spawns the command when the first message - the one carrying
|
||||
// the token and the size - arrives, and not before.
|
||||
if (!spawned) {
|
||||
spawned = true;
|
||||
socket.write(frame(Buffer.concat([
|
||||
Buffer.from('0'), Buffer.from(opts.onConnect(mine.url))
|
||||
])));
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('close', () => { mine.closed = true; });
|
||||
});
|
||||
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}/`,
|
||||
pageLoads,
|
||||
sockets,
|
||||
stop: () => new Promise((r) => server.close(r))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// What the SESS button does, against the real page in a real browser.
|
||||
//
|
||||
// Not part of `npm test`: it needs Chromium, which the CI image does not have
|
||||
// and which `node --test` would fail on rather than skip. Run it by hand, on a
|
||||
// machine with Playwright:
|
||||
//
|
||||
// node test/browser/sess-button.mjs
|
||||
//
|
||||
// It proves the one thing the unit tests cannot: that pressing SESS reaches
|
||||
// the picker without the page being fetched and parsed a second time.
|
||||
import { startMockTtyd } from './mock-ttyd.mjs';
|
||||
|
||||
// Playwright is not a dependency of this package and is deliberately not
|
||||
// added as one: it would pull a browser into an install that exists to inline
|
||||
// two files. Resolved where it is rather than where this happens to have been
|
||||
// run - a dejima sandbox carries it at a fixed path, a host that has it has it
|
||||
// on the module path.
|
||||
async function loadChromium() {
|
||||
for (const spec of ['playwright', '/opt/node-tools/lib/node_modules/playwright/index.mjs']) {
|
||||
try {
|
||||
return (await import(spec)).chromium;
|
||||
} catch (e) { /* the next one, or the message below */ }
|
||||
}
|
||||
console.error('This check needs Playwright, which is not a dependency here.');
|
||||
console.error('Run it inside a dejima sandbox, which has one, or install it:');
|
||||
console.error(' npm i -g playwright && npx playwright install chromium');
|
||||
process.exit(2);
|
||||
}
|
||||
const chromium = await loadChromium();
|
||||
|
||||
const RUN_ID = 'deadbeefdeadbeef';
|
||||
const checks = [];
|
||||
function check(name, ok, detail) {
|
||||
checks.push({ name, ok, detail });
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' <- ' + detail}`);
|
||||
}
|
||||
|
||||
const ttyd = await startMockTtyd(new URL('../../index.html', import.meta.url).pathname, {
|
||||
onConnect: (url) => (url.includes('arg=' + RUN_ID)
|
||||
? 'SESSION ' + RUN_ID + ' attached\r\n'
|
||||
: 'dejima | live sessions\r\n')
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${ttyd.url}?toolbar=1&arg=${RUN_ID}`);
|
||||
await page.waitForFunction((id) => document.body.innerText.includes('SESSION ' + id), RUN_ID);
|
||||
|
||||
check('the run id reaches ttyd as a WebSocket argument',
|
||||
ttyd.sockets.length === 1 && ttyd.sockets[0].url.includes('arg=' + RUN_ID),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.url)));
|
||||
const loadsBefore = ttyd.pageLoads.length;
|
||||
// A marker on the window as well as the server's count: a reload served from
|
||||
// the browser's cache would never reach the server, and this page is one big
|
||||
// cacheable file.
|
||||
await page.evaluate(() => { window.__survivedTheSwitch = true; });
|
||||
|
||||
await page.click('[data-action="sessions"]');
|
||||
await page.waitForFunction(() => document.body.innerText.includes('live sessions'));
|
||||
|
||||
check('the page itself was never re-run',
|
||||
await page.evaluate(() => window.__survivedTheSwitch === true));
|
||||
check('the picker arrives without the page being fetched again',
|
||||
ttyd.pageLoads.length === loadsBefore,
|
||||
`${ttyd.pageLoads.length} loads, was ${loadsBefore}`);
|
||||
check('a second socket is opened, with no arg on it',
|
||||
ttyd.sockets.length === 2 && !ttyd.sockets[1].url.includes('arg='),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.url)));
|
||||
check('the first socket is closed, which is what ends the session left behind',
|
||||
ttyd.sockets[0].closed === true);
|
||||
check('the address bar stops naming the session, so a reload lands on the picker',
|
||||
!(await page.evaluate(() => window.location.search)).includes('arg='),
|
||||
await page.evaluate(() => window.location.search));
|
||||
// The session's own output must not still be on screen under the picker: the
|
||||
// socket carries away the process, not what it left in the terminal.
|
||||
// Typing has to reach the command the new socket spawned. The terminal's
|
||||
// handlers were wired to the client once, at startup, and the client is now
|
||||
// holding a different socket.
|
||||
await page.click('#terminal');
|
||||
await page.keyboard.type('2');
|
||||
await page.waitForFunction(() => true);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
check('what is typed after the switch reaches the new socket',
|
||||
ttyd.sockets[1].input.includes('2') && !ttyd.sockets[0].input.includes('2'),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.input)));
|
||||
check('the terminal is reset, so the picker is not drawn over the session',
|
||||
!(await page.evaluate(() => document.body.innerText)).includes('SESSION ' + RUN_ID));
|
||||
|
||||
await browser.close();
|
||||
await ttyd.stop();
|
||||
|
||||
const failed = checks.filter((c) => !c.ok).length;
|
||||
console.log(`\n${checks.length - failed}/${checks.length} checks passed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -44,3 +44,84 @@ test("the query string rides along, because ttyd's --url-arg reads it off the We
|
||||
'wss://h/ws'
|
||||
);
|
||||
});
|
||||
|
||||
// A WebSocket and a fetch just real enough for createClient: every instance is
|
||||
// recorded, so a test can see which url was opened and what was done to the
|
||||
// one before it.
|
||||
function stubTransport() {
|
||||
const sockets = [];
|
||||
globalThis.WebSocket = function (url, protocols) {
|
||||
this.url = url;
|
||||
this.protocols = protocols;
|
||||
this.readyState = 0;
|
||||
this.closed = false;
|
||||
this.sent = [];
|
||||
this.send = function (data) { this.sent.push(data); };
|
||||
this.close = function () { this.closed = true; if (this.onclose) this.onclose(); };
|
||||
sockets.push(this);
|
||||
};
|
||||
globalThis.WebSocket.OPEN = 1;
|
||||
globalThis.fetch = () => Promise.resolve({ json: () => ({ token: 't' }) });
|
||||
return sockets;
|
||||
}
|
||||
|
||||
function client(search, statuses) {
|
||||
return globalThis.DEJIMA.protocol.createClient({
|
||||
location: { protocol: 'http:', host: 'h', pathname: '/', search },
|
||||
size: () => ({ cols: 80, rows: 24 }),
|
||||
onOutput() {}, onTitle() {}, onPreferences() {},
|
||||
onStatus: (s) => statuses.push(s)
|
||||
});
|
||||
}
|
||||
|
||||
test('switchTo opens a new socket for a different arg, and keeps the page', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(sockets.length, 1);
|
||||
assert.equal(sockets[0].url, 'ws://h/ws?arg=0123456789abcdef');
|
||||
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
// The old one was closed - ttyd kills the command when the socket goes, so
|
||||
// this is what ends the session being left - and a second one opened on the
|
||||
// url with no arg, which is the picker.
|
||||
assert.equal(sockets[0].closed, true);
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[1].url, 'ws://h/ws');
|
||||
assert.equal(c.urls().ws, 'ws://h/ws');
|
||||
});
|
||||
|
||||
test('a switch is not reported as the session dropping', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
statuses.length = 0;
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
// "closed" is what puts "Disconnected. Reconnecting..." on the screen and
|
||||
// starts the backoff. A switch is neither.
|
||||
assert.deepEqual(statuses, ['connecting']);
|
||||
});
|
||||
|
||||
test('a switch while disconnected drops the pending reconnect', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
sockets[0].onclose(); // the session ended; a retry is now queued
|
||||
const queued = sockets.length;
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(sockets.length, queued + 1);
|
||||
assert.equal(sockets[sockets.length - 1].url, 'ws://h/ws');
|
||||
// And the queued retry must not arrive afterwards and reopen the session
|
||||
// that was just left.
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
assert.equal(sockets.length, queued + 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user