Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ffe8564b | ||
|
|
cf8b3ee20f | ||
|
|
91befe91f1 | ||
|
|
d8b687f4e4 | ||
|
|
ab74531b63 | ||
|
|
06018fdda4 | ||
|
|
0e4717d7ed | ||
|
|
7da90f51e1 | ||
|
|
117622e144 | ||
|
|
a26c10e8c6 | ||
|
|
65caf4577b | ||
|
|
21eeaa5bf0 | ||
|
|
d279c93e13 | ||
|
|
439b9b2d8b | ||
|
|
6dcefc127d | ||
|
|
aff7f511e8 | ||
|
|
02159d95e2 | ||
|
|
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
|
The release pull request that merges `development` into `main` replaces this
|
||||||
heading with the date it merges, and tags `main` with the same date.
|
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
|
### Changed
|
||||||
|
|
||||||
- **The GitHub mirrors carry a rewritten history.** AI co-author trailers are
|
- **The GitHub mirrors carry a rewritten history.** AI co-author trailers are
|
||||||
@@ -32,6 +62,22 @@ heading with the date it merges, and tags `main` with the same date.
|
|||||||
and operator-specific values are replaced throughout. Commits keep their
|
and operator-specific values are replaced throughout. Commits keep their
|
||||||
authors and dates, but a forge SHA no longer resolves on GitHub.
|
authors and dates, but a forge SHA no longer resolves on GitHub.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
always held - nothing oversized was read into host memory - but the
|
||||||
|
conversation was a race, and it surfaced as an intermittently red `proxy`
|
||||||
|
job.
|
||||||
|
([#400](https://gitbox.branderson.io/dejima/dejima/issues/400))
|
||||||
|
|
||||||
## 2026.10.14
|
## 2026.10.14
|
||||||
|
|
||||||
Nothing a session does has changed. This release is about where dejima can be
|
Nothing a session does has changed. This release is about where dejima can be
|
||||||
|
|||||||
@@ -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
|
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
|
container keeps running - so for a session you want to leave working and pick
|
||||||
up elsewhere, run dejima inside tmux and use
|
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)
|
→ [why that takes doing](docs/configuration.md#suspending-a-session)
|
||||||
|
|
||||||
## Sign in on the host first
|
## 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
|
--workflow <name> what the agent is told to do with its work
|
||||||
--cmd <command> run this instead of the harness's own command
|
--cmd <command> run this instead of the harness's own command
|
||||||
--identity <name> which [identity.<name>] supplies the credentials
|
--identity <name> which [identity.<name>] supplies the credentials
|
||||||
-i, --include <tool> add an optional tool to this launch (repeatable);
|
-i, --include <tool> add an optional tool (repeatable); see docs/tools.md
|
||||||
see docs/tools.md for what dejima ships
|
--name <text> what this session is for, in the picker and in tmux
|
||||||
--backend <name> the selected harness's model API, model and context
|
--backend <name> the selected harness's model API, model and context
|
||||||
--model <id> budget, each refused for a harness with no such
|
--model <id> budget, each refused for a harness with no such
|
||||||
--context-window <n> setting
|
--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
|
# file that silently decided it would make the next launch's answer to "where
|
||||||
# is my work" depend on a line nobody reread.
|
# is my work" depend on a line nobody reread.
|
||||||
DEJIMA_ISOLATED=0
|
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
|
# 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
|
# path it must refuse from harness arguments it must pass through. Both arrive
|
||||||
# here as positionals once the loop breaks.
|
# here as positionals once the loop breaks.
|
||||||
DEJIMA_ARGS_ONLY=0
|
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
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--) DEJIMA_ARGS_ONLY=1; shift; break ;;
|
--) DEJIMA_ARGS_ONLY=1; shift; break ;;
|
||||||
@@ -308,6 +438,7 @@ while [ $# -gt 0 ]; do
|
|||||||
# for the launch they run by hand tomorrow.
|
# for the launch they run by hand tomorrow.
|
||||||
--no-pause) NO_PAUSE=1; shift ;;
|
--no-pause) NO_PAUSE=1; shift ;;
|
||||||
--isolated) DEJIMA_ISOLATED=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 ""
|
--no-log) _set_override logging.loki_url ""
|
||||||
_set_override logging.conversation off
|
_set_override logging.conversation off
|
||||||
NO_LOG=1; shift ;;
|
NO_LOG=1; shift ;;
|
||||||
@@ -1804,6 +1935,14 @@ cleanup() {
|
|||||||
# run that was killed rather than exited put neither back.
|
# run that was killed rather than exited put neither back.
|
||||||
printf '\033[?25h' > /dev/tty 2>/dev/null || true
|
printf '\033[?25h' > /dev/tty 2>/dev/null || true
|
||||||
fi
|
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
|
if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
|
||||||
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
|
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
|
||||||
wait "$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
|
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
|
# 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
|
# 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
|
# `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"
|
mkdir -p -m 700 "$DEJIMA_STATE_DIR/sessions" "$DEJIMA_STATE_DIR/notify"
|
||||||
DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
|
DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
|
||||||
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$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
|
# 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.
|
# Verify what was just created, because everything downstream assumes it.
|
||||||
# A leaf left behind root-owned by an earlier container restart (see
|
# 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
|
# startup banner makes, and for the same reason. What tells two
|
||||||
# concurrent isolated sessions apart is the run id beside it, which
|
# concurrent isolated sessions apart is the run id beside it, which
|
||||||
# that listing already prints.
|
# 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
|
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||||
printf 'workspace=isolated\n'
|
printf 'workspace=isolated\n'
|
||||||
else
|
else
|
||||||
|
|||||||
+380
-61
@@ -19,12 +19,21 @@ case "${1:-}" in
|
|||||||
cat <<'DEJIMA_ATTACH_HELP'
|
cat <<'DEJIMA_ATTACH_HELP'
|
||||||
dejima-attach - list live dejima sessions and attach to one
|
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
|
(no argument) numbered picker of live sessions; attaches to the choice
|
||||||
run-id attach to one directly, which is what a notification's deep
|
run-id attach to one directly, which is what a notification's deep
|
||||||
link and ttyd's --url-arg both produce
|
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
|
--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
|
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
|
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.
|
# this - see tui_clear.
|
||||||
banner() { tui_clear; tui_banner 'live sessions'; }
|
banner() { tui_clear; tui_banner 'live sessions'; }
|
||||||
|
|
||||||
# Coloured by meaning, not by name: attachable is the one an operator wants to
|
# Coloured by meaning, not by name: waiting is the one an operator is looking
|
||||||
# find, and stale/no-tmux are the two that will refuse if picked.
|
# 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() {
|
state_color() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
attachable) printf '%s' "$C_OK" ;;
|
working|attachable) STATE_COLOR="$C_OK" ;;
|
||||||
stale) printf '%s' "$C_ERR" ;;
|
stale) STATE_COLOR="$C_ERR" ;;
|
||||||
*) printf '%s' "$C_WARN" ;;
|
*) STATE_COLOR="$C_WARN" ;;
|
||||||
esac
|
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
|
# 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
|
# 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
|
# container before the prune treats it as gone. Sized against a cold first
|
||||||
@@ -96,6 +131,7 @@ PRE_PID_STARTUP_GRACE=900
|
|||||||
read_entry() {
|
read_entry() {
|
||||||
local file="$1" line
|
local file="$1" line
|
||||||
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""; entry_pid=""
|
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""; entry_pid=""
|
||||||
|
entry_name=""
|
||||||
[ -r "$file" ] || return 1
|
[ -r "$file" ] || return 1
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
case "$line" in
|
case "$line" in
|
||||||
@@ -104,6 +140,7 @@ read_entry() {
|
|||||||
label=*) entry_label="${line#*=}" ;;
|
label=*) entry_label="${line#*=}" ;;
|
||||||
started=*) entry_started="${line#*=}" ;;
|
started=*) entry_started="${line#*=}" ;;
|
||||||
pid=*) entry_pid="${line#*=}" ;;
|
pid=*) entry_pid="${line#*=}" ;;
|
||||||
|
name=*) entry_name="${line#*=}" ;;
|
||||||
esac
|
esac
|
||||||
done < "$file" || return 1
|
done < "$file" || return 1
|
||||||
# pid reaches `kill`, which takes a job spec as well as a number: "%1" or
|
# pid reaches `kill`, which takes a job spec as well as a number: "%1" or
|
||||||
@@ -122,36 +159,97 @@ read_entry() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
|
# Every running sandbox's container, keyed by the run id it reports.
|
||||||
# as env precisely so this correlation is possible without the container
|
# bin/dejima passes DEJIMA_RUN_ID in as env precisely so this correlation is
|
||||||
# having any write access to the registry.
|
# possible without the container having any write access to the registry.
|
||||||
#
|
#
|
||||||
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
|
# Built once per listing rather than once per session: this used to run
|
||||||
# the caller prunes on the former, and pruning on the latter would permanently
|
# `docker ps` and then inspect containers one by one until it found the match,
|
||||||
# unregister every running sandbox the moment dockerd happens to be restarting.
|
# for every entry in the registry in turn, so a dozen sessions cost a dozen
|
||||||
container_for() {
|
# `docker ps` calls and up to a hundred and forty-four inspects. The listing
|
||||||
local run_id="$1" names name env unasked=""
|
# 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" \
|
names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
|
||||||
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
|
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" \
|
||||||
for name in $names; do
|
|| { CONTAINER_MAP_UNASKED=1; return 0; }
|
||||||
# Captured rather than piped straight into grep: a pipeline reports
|
[ -n "$names" ] || return 0
|
||||||
# only grep's status, which would turn "inspect couldn't answer for
|
# One inspect for every container rather than one each. Two round trips to
|
||||||
# this container" into "this isn't the one" - and one such container
|
# the daemon where there were one plus one per container, which on a host
|
||||||
# anywhere in the list would prune an unrelated live session.
|
# running a dozen sessions was most of the wait before the picker drew.
|
||||||
env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
|
#
|
||||||
|| { unasked=1; continue; }
|
# Both values as JSON, which is what makes one line per container a
|
||||||
if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
|
# property rather than a hope: a newline inside an environment value is
|
||||||
printf '%s' "$name"
|
# escaped rather than printed, so a line cannot be split and the name
|
||||||
return 0
|
# cannot drift away from the environment beside it. A missing container
|
||||||
fi
|
# takes its line with it instead of shifting the rest.
|
||||||
done
|
#
|
||||||
[ -z "$unasked" ] || return 2
|
# 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
|
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() {
|
pane_alive() {
|
||||||
[ -n "$1" ] || return 1
|
[ -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
|
# 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
|
# 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
|
# pruned outright; one whose pane is gone is shown as stale rather than
|
||||||
# offered for attachment.
|
# 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() {
|
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
|
[ -d "$SESSIONS_DIR" ] || return 0
|
||||||
now="$(date +%s)"
|
now="$(date +%s)"
|
||||||
|
load_container_map
|
||||||
|
load_pane_list
|
||||||
for file in "$SESSIONS_DIR"/*; do
|
for file in "$SESSIONS_DIR"/*; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
run_id="$(basename "$file")"
|
run_id="$(basename "$file")"
|
||||||
valid_run_id "$run_id" || continue
|
valid_run_id "$run_id" || continue
|
||||||
rc=0
|
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
|
if [ "$rc" -eq 1 ] && launcher_alive "$file" "$now"; then
|
||||||
# Registered, but bin/dejima is still building - "not started yet"
|
# Registered, but bin/dejima is still building - "not started yet"
|
||||||
# is a third answer, and pruning on it would unregister a session
|
# is a third answer, and pruning on it would unregister a session
|
||||||
@@ -249,12 +362,102 @@ list_sessions() {
|
|||||||
else
|
else
|
||||||
state="stale"
|
state="stale"
|
||||||
fi
|
fi
|
||||||
printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
|
# One column for two questions, because they do not overlap: a session
|
||||||
"$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
|
# 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}"
|
"$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
|
||||||
done
|
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
|
# 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
|
# 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
|
# 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
|
echo "Session $run_id was not started from tmux - nothing to attach to." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if ! pane_alive "$entry_pane"; then
|
||||||
echo "Session $run_id is stale - its tmux pane is gone." >&2
|
echo "Session $run_id is stale - its tmux pane is gone." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -306,6 +512,20 @@ attach() {
|
|||||||
echo "Session $run_id is stale - its tmux pane went away." >&2
|
echo "Session $run_id is stale - its tmux pane went away." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if tmux has-session -t "=$group" 2>/dev/null; then
|
||||||
# Verified exactly like the session created below, and for the same
|
# Verified exactly like the session created below, and for the same
|
||||||
# reason: nothing here created this one, so carrying the name is not
|
# reason: nothing here created this one, so carrying the name is not
|
||||||
@@ -366,51 +586,150 @@ attach() {
|
|||||||
trap - EXIT
|
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() {
|
picker() {
|
||||||
local -a ids=() labels=() projects=() ages=() states=() containers=()
|
local timeout="${1:-}"
|
||||||
local i choice run_id label project age state container
|
local -a ids=() names=() ages=() states=()
|
||||||
local w_label=0 w_project=0 w_age=0 w_state=0
|
local i choice run_id name label project age state container
|
||||||
# Split on tabs only, so a space in a project name stays part of one field.
|
local w_name=0 w_state=0
|
||||||
# The columns are measured on the way in because they are padded to the
|
# Three columns, because the front door this is read through is a phone.
|
||||||
# widest value rather than to a guess: a project name is a directory name,
|
# The tmux label and the container id are in `--list` and not here: the
|
||||||
# and a fixed width either truncates a long one or indents every short one
|
# label is a coordinate an operator no longer has to act on, since picking
|
||||||
# off the right edge of a phone.
|
# a session goes to it, and the container id shares a prefix with every
|
||||||
while IFS=$'\t' read -r run_id label project age state container; do
|
# other one, so a column of them says nothing. Carrying all five made a row
|
||||||
ids+=("$run_id"); labels+=("$label"); projects+=("$project")
|
# about eighty columns wide, which wrapped onto a second line and put the
|
||||||
ages+=("$age"); states+=("$state"); containers+=("$container")
|
# state and the age under the NEXT session's number.
|
||||||
[ "${#label}" -le "$w_label" ] || w_label="${#label}"
|
#
|
||||||
[ "${#project}" -le "$w_project" ] || w_project="${#project}"
|
# The name is first, against the index, because those two are what an
|
||||||
[ "${#age}" -le "$w_age" ] || w_age="${#age}"
|
# 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}"
|
[ "${#state}" -le "$w_state" ] || w_state="${#state}"
|
||||||
done < <(list_sessions)
|
done < <(list_sessions | tr '\t' '\037')
|
||||||
banner
|
banner
|
||||||
if [ "${#ids[@]}" -eq 0 ]; then
|
if [ "${#ids[@]}" -eq 0 ]; then
|
||||||
printf ' %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
# The container column stays, and last, because it is not decoration: it
|
# The name column takes whatever the terminal has left after the fixed
|
||||||
# reads "starting" for a session bin/dejima is still building and "?" when
|
# parts, rather than a width picked here. A fixed one is wrong in both
|
||||||
# docker could not be asked at all, and dropping it would make either one
|
# directions at once: a desktop has room for every name and would cut them
|
||||||
# look like an ordinary attachable session.
|
# 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
|
for i in "${!ids[@]}"; do
|
||||||
local sc; sc="$(state_color "${states[$i]}")"
|
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' \
|
# 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" \
|
"$C_WARN" "$((i + 1))" "$C_OFF" \
|
||||||
"$sc" "$GLYPH_DOT" "$C_OFF" \
|
"$STATE_COLOR" "$GLYPH_DOT" "$C_OFF" \
|
||||||
"$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
|
"$C_BOLD$C_TEXT" "$w_name" "${names[$i]}" "$C_OFF" \
|
||||||
"$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
|
"$STATE_COLOR" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||||
"$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
|
"$C_MUTED" "${ages[$i]}" "$C_OFF"
|
||||||
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
|
|
||||||
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
|
|
||||||
done
|
done
|
||||||
tui_pick "${#ids[@]}" 'attach to'
|
tui_pick "${#ids[@]}" 'attach to' "$timeout" || return 1
|
||||||
choice="$TUI_CHOICE"
|
choice="$TUI_CHOICE"
|
||||||
attach "${ids[$((choice - 1))]}"
|
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
|
case "${1:-}" in
|
||||||
'') picker ;;
|
'') picker ;;
|
||||||
|
--watch) watch_picker ;;
|
||||||
--list) list_sessions ;;
|
--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
|
# 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
|
# reach the web front door. Validate its shape before it is used as a
|
||||||
|
|||||||
@@ -424,6 +424,33 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
% (what, n, self.MAX_BODY_BYTES))
|
% (what, n, self.MAX_BODY_BYTES))
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
def handle_expect_100(self):
|
||||||
|
"""Refuse an oversized body before inviting it.
|
||||||
|
|
||||||
|
The default answers `100 Continue` and returns True, and it runs
|
||||||
|
before do_* - so the proxy invited the upload, read the length in
|
||||||
|
_read_body, refused it and closed mid-flight. Whether the client saw
|
||||||
|
the 413 or only the 100 was timing, which is a race a client cannot
|
||||||
|
be expected to win and a test cannot be expected to pass reliably.
|
||||||
|
|
||||||
|
The declared length is all there is to go on before a body arrives.
|
||||||
|
A chunked request declares nothing and is capped as it is read, which
|
||||||
|
is the one case where the conversation still ends mid-upload - there
|
||||||
|
is no earlier moment to end it at.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._length(self.headers.get("Content-Length", 0), "Content-Length")
|
||||||
|
except BodyTooLarge as e:
|
||||||
|
# Closed for the same reason the other 413 closes: a client that
|
||||||
|
# sends anyway - curl gives up waiting after a second - leaves its
|
||||||
|
# bytes queued, and the next parse would read them as a request
|
||||||
|
# line and answer with garbage 400s instead of one clean refusal.
|
||||||
|
self.close_connection = True
|
||||||
|
self._fail(413, "credential proxy: request body too large",
|
||||||
|
log_message="refused a request body: %s" % e)
|
||||||
|
return False
|
||||||
|
return super().handle_expect_100()
|
||||||
|
|
||||||
def _read_body(self):
|
def _read_body(self):
|
||||||
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
||||||
chunks = []
|
chunks = []
|
||||||
|
|||||||
+17
-2
@@ -84,9 +84,14 @@ tui_banner() {
|
|||||||
#
|
#
|
||||||
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
|
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
|
||||||
# better than arrow keys, and dejima-attach is the phone's entry point.
|
# 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_CHOICE=""
|
||||||
tui_pick() {
|
tui_pick() {
|
||||||
local count="$1" verb="$2" choice
|
local count="$1" verb="$2" timeout="${3:-}" choice rc=0
|
||||||
TUI_CHOICE=""
|
TUI_CHOICE=""
|
||||||
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
|
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
|
||||||
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
|
"$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
|
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
|
||||||
# returns nonzero immediately, and exiting 1 with nothing printed reads as
|
# returns nonzero immediately, and exiting 1 with nothing printed reads as
|
||||||
# a broken front door.
|
# 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
|
case "$choice" in
|
||||||
q|Q|'') exit 0 ;;
|
q|Q|'') exit 0 ;;
|
||||||
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
||||||
|
|||||||
@@ -13,14 +13,14 @@
|
|||||||
# Registers the `done` notification handler into dejima's settings layer, in
|
# Registers the `done` notification handler into dejima's settings layer, in
|
||||||
# the shape Claude Code reads them.
|
# the shape Claude Code reads them.
|
||||||
#
|
#
|
||||||
# Stop only, though the hook and the host-side watcher both understand
|
# No Notification handler, though the hook and the host-side watcher both
|
||||||
# `waiting` as well. Claude Code raises Notification when it needs permission
|
# understand `waiting` as well. Claude Code raises Notification when it needs
|
||||||
# to run something and when the prompt has sat idle, and a dejima session runs
|
# permission to run something and when the prompt has sat idle, and a dejima
|
||||||
# with permission prompts off - so the first never happens, and what is left
|
# session runs with permission prompts off - so the first never happens, and
|
||||||
# is a timer that expires shortly after the Stop it follows. Registering both
|
# what is left is a timer that expires shortly after the Stop it follows.
|
||||||
# put two pushes on the operator's phone for one event, a minute apart, the
|
# Registering both put two pushes on the operator's phone for one event, a
|
||||||
# second saying nothing the first had not. `waiting` stays in the enum for a
|
# minute apart, the second saying nothing the first had not. `waiting` stays in
|
||||||
# harness that can say it without having stopped first.
|
# the enum for a harness that can say it without having stopped first.
|
||||||
#
|
#
|
||||||
# Claude Code merges hook entries across settings layers rather than letting
|
# Claude Code merges hook entries across settings layers rather than letting
|
||||||
# the higher one replace the lower, so these are added to whatever the
|
# 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() {
|
harness_register_notify() {
|
||||||
local settings="$1" tmp
|
local settings="$1" tmp
|
||||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
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
|
"$settings" > "$tmp"; then
|
||||||
mv "$tmp" "$settings"
|
mv "$tmp" "$settings"
|
||||||
else
|
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 |
|
| `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 |
|
| 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 |
|
| `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 |
|
| `harnesses/` | Launch the harness against a real model |
|
||||||
|
|
||||||
## 5. The release pull request
|
## 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.
|
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
|
`--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
|
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
|
`--url-arg` produce. Oldest first in both: the numbers are how a session is
|
||||||
name, the session's age, its state, and the container id.
|
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
|
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
|
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
|
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
|
||||||
parses those rows itself and `dejima-attach --help` calls them scriptable.
|
parses those rows itself and `dejima-attach --help` calls them scriptable.
|
||||||
|
|
||||||
Three states, from a registry `dejima` writes on launch and removes on
|
The state column answers two questions with one word, because they do not
|
||||||
exit:
|
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,
|
- `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
|
since the registry is a hint and not truth: the exit trap doesn't run
|
||||||
under `SIGKILL` or a host crash.
|
under `SIGKILL` or a host crash.
|
||||||
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
|
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
|
||||||
attach to. Still listed, deliberately, rather than silently omitted.
|
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
|
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
|
Docker can't be asked at all, nothing is pruned and the container column
|
||||||
reads `?` - an unanswerable question is never treated as a "no".
|
reads `?` - an unanswerable question is never treated as a "no".
|
||||||
|
|
||||||
Attaching creates a **grouped** tmux session (`dejima-<run-id>`) rather than
|
### Watching all of them, and getting to one
|
||||||
joining yours. Grouped sessions share windows but track their own current
|
|
||||||
|
`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
|
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
|
another, and the throwaway carries `destroy-unattached`, so it disposes of
|
||||||
itself when the phone detaches or the connection drops.
|
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
|
# 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.
|
# channel the sandbox controls, which is the one thing this hook must not be.
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
waiting|done) ;;
|
waiting|done|running) ;;
|
||||||
*) exit 0 ;;
|
*) exit 0 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -32,4 +32,24 @@ esac
|
|||||||
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
|
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
|
||||||
[ -d "$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
|
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" {
|
@test "an invalid value is fatal and names the key" {
|
||||||
write_config '[audit]' 'keep = 0'
|
write_config '[audit]' 'keep = 0'
|
||||||
run lib config_validate
|
run lib config_validate
|
||||||
|
|||||||
@@ -485,6 +485,45 @@ make_cert() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "an oversized body is refused before the 100 Continue that invites it" {
|
||||||
|
# The test above is about what the client ends up with, and it can pass
|
||||||
|
# either way when the timing is kind: the proxy used to answer
|
||||||
|
# `100 Continue` first and refuse from the body reader afterwards, so
|
||||||
|
# whether a client read the 413 or only the 100 was a race. This one asks
|
||||||
|
# the question the race cannot reach - what the FIRST answer is - and never
|
||||||
|
# sends a body at all.
|
||||||
|
start_proxy
|
||||||
|
|
||||||
|
run python3 - "$SOCK" "$PLACEHOLDER" <<'PROBE'
|
||||||
|
import socket, sys
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(10)
|
||||||
|
sock.connect(sys.argv[1])
|
||||||
|
# Declared far over the cap, and not one byte of it sent: a client offering a
|
||||||
|
# body is entitled to be told no before it starts.
|
||||||
|
sock.sendall(
|
||||||
|
b"POST /v1/messages HTTP/1.1\r\n"
|
||||||
|
b"Host: x\r\n"
|
||||||
|
b"Authorization: Bearer " + sys.argv[2].encode() + b"\r\n"
|
||||||
|
b"Content-Type: application/json\r\n"
|
||||||
|
b"Content-Length: 99999999999\r\n"
|
||||||
|
b"Expect: 100-continue\r\n\r\n")
|
||||||
|
data = b""
|
||||||
|
while b"\r\n" not in data:
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
print(data.split(b"\r\n")[0].decode("ascii", "replace"))
|
||||||
|
PROBE
|
||||||
|
[ "$status" -eq 0 ] || { echo "the probe failed: $output"; return 1; }
|
||||||
|
[[ "$output" == *"413"* ]] \
|
||||||
|
|| { echo "the first answer was not a refusal: $output"; return 1; }
|
||||||
|
grep -q 'refused a request body' "$TD/stderr" \
|
||||||
|
|| { echo "nothing logged: $(cat "$TD/stderr")"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
@test "a normal-sized request body still passes through" {
|
@test "a normal-sized request body still passes through" {
|
||||||
# The cap must be far above anything real. A conversation with attachments
|
# The cap must be far above anything real. A conversation with attachments
|
||||||
# is the biggest thing a Messages request carries.
|
# is the biggest thing a Messages request carries.
|
||||||
|
|||||||
+669
-2
@@ -204,6 +204,18 @@ for arg in "$@"; do
|
|||||||
[ -z "${SANDBOX_TEST_SETTINGS_COPY:-}" ] || cp "$SANDBOX_SETTINGS_FILE" "$SANDBOX_TEST_SETTINGS_COPY"
|
[ -z "${SANDBOX_TEST_SETTINGS_COPY:-}" ] || cp "$SANDBOX_SETTINGS_FILE" "$SANDBOX_TEST_SETTINGS_COPY"
|
||||||
break
|
break
|
||||||
done
|
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
|
exit 0
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$LAUNCH_DIR/docker"
|
chmod +x "$LAUNCH_DIR/docker"
|
||||||
@@ -232,6 +244,7 @@ launch() {
|
|||||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||||
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
||||||
|
SANDBOX_TEST_LEVEL_PROBE="$LAUNCH_DIR/level-probe" \
|
||||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||||
}
|
}
|
||||||
@@ -610,7 +623,9 @@ stub_docker_run_id() {
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
case "\$1" in
|
case "\$1" in
|
||||||
ps) echo dejima-test-container ;;
|
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
|
esac
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
EOF
|
||||||
@@ -633,6 +648,9 @@ label=$3
|
|||||||
started=${4:-$(date +%s)}
|
started=${4:-$(date +%s)}
|
||||||
EOF
|
EOF
|
||||||
[ -z "${5:-}" ] || printf 'pid=%s\n' "$5" >> "$RUNTIME_DIR/dejima/sessions/$1"
|
[ -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
|
# 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"* ]]
|
[[ "$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" {
|
@test "--list marks a session whose pane is gone as stale" {
|
||||||
write_remote_config
|
write_remote_config
|
||||||
stub_docker_run_id 0123456789abcdef
|
stub_docker_run_id 0123456789abcdef
|
||||||
@@ -735,7 +1299,15 @@ stub_docker_sessions() {
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
case "\$1" in
|
case "\$1" in
|
||||||
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
|
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
|
esac
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
EOF
|
||||||
@@ -865,6 +1437,46 @@ start_tmux() {
|
|||||||
done
|
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" {
|
@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
|
# The positive direction, which is where every interesting tmux mistake
|
||||||
# lives: an invalid set-option target, or setting destroy-unattached while
|
# lives: an invalid set-option target, or setting destroy-unattached while
|
||||||
@@ -1371,6 +1983,46 @@ EOF
|
|||||||
rm -rf "$NOTIFY" "$payload_file"
|
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" {
|
@test "the notify hook refuses any event name outside the enum" {
|
||||||
NOTIFY="$(mktemp -d)"
|
NOTIFY="$(mktemp -d)"
|
||||||
for bad in 'error' '../escape' 'waiting done' '' '$(id)' 'waiting;done' '.'; do
|
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"
|
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
|
||||||
[[ "$output" != *operator-desktop-notify.sh* ]]
|
[[ "$output" != *operator-desktop-notify.sh* ]]
|
||||||
[[ "$output" != *operator-tts.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"
|
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
|
The rest — gestures, the viewport manager, the toolbar — needs a real browser
|
||||||
and a real phone. `docs/remote-access.md` has the manual checklist.
|
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
|
## Layout
|
||||||
|
|
||||||
| File | Role |
|
| 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
|
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. -->
|
the tab would fall back to the generic icon. -->
|
||||||
<link rel="icon" type="image/png" href="data:image/png;base64,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">
|
<link rel="icon" type="image/png" href="data:image/png;base64,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">
|
||||||
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
|
<!-- Generated by web/build.mjs - do not edit. Sources: c43f6331189abbbe08955f8548a631132008ba6051a6274088be6f85c0f7d2a4 -->
|
||||||
<style>
|
<style>
|
||||||
/**
|
/**
|
||||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
@@ -1011,6 +1011,7 @@ body.mono-keys {
|
|||||||
// onStatus(string), size() -> {cols, rows}}
|
// onStatus(string), size() -> {cols, rows}}
|
||||||
function createClient(opts) {
|
function createClient(opts) {
|
||||||
var urls = endpoints(opts.location);
|
var urls = endpoints(opts.location);
|
||||||
|
var location = opts.location;
|
||||||
var encoder = new TextEncoder();
|
var encoder = new TextEncoder();
|
||||||
var decoder = new TextDecoder();
|
var decoder = new TextDecoder();
|
||||||
var socket = null;
|
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 {
|
return {
|
||||||
connect: connect,
|
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,
|
isOpen: open,
|
||||||
sendInput: sendInput,
|
sendInput: sendInput,
|
||||||
sendBinary: sendBinary,
|
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
|
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
|
||||||
// because the target is a phone: soft keyboards overwhelmingly report
|
// because the target is a phone: soft keyboards overwhelmingly report
|
||||||
// keydown as "Unidentified" and deliver the real character through an input
|
// keydown as "Unidentified" and deliver the real character through an input
|
||||||
@@ -1938,7 +2000,7 @@ body.mono-keys {
|
|||||||
prefix: function () { return prefixByte; },
|
prefix: function () { return prefixByte; },
|
||||||
notify: notify,
|
notify: notify,
|
||||||
search: function () { return window.location.search; },
|
search: function () { return window.location.search; },
|
||||||
navigate: function (search) { window.location.search = search; },
|
navigate: goToPicker,
|
||||||
allowKeyboard: keyboard.allow
|
allowKeyboard: keyboard.allow
|
||||||
});
|
});
|
||||||
toolbar.attach();
|
toolbar.attach();
|
||||||
|
|||||||
+2
-2
@@ -5,12 +5,12 @@ f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.j
|
|||||||
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
||||||
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
||||||
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
||||||
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
|
05bc2e446c3baa57422c8fa036939a0df8cf35ac0cf02bb35233befafae5667f src/main.js
|
||||||
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
|
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
|
||||||
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
|
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
|
||||||
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
||||||
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
|
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
|
||||||
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
|
2490ec55a050d993364a62f590b51e63945ea5b1276832821d6d1d0854fd5c81 src/protocol.js
|
||||||
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
||||||
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
|
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
|
||||||
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
|
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
|
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
|
||||||
// because the target is a phone: soft keyboards overwhelmingly report
|
// because the target is a phone: soft keyboards overwhelmingly report
|
||||||
// keydown as "Unidentified" and deliver the real character through an input
|
// keydown as "Unidentified" and deliver the real character through an input
|
||||||
@@ -198,7 +223,7 @@
|
|||||||
prefix: function () { return prefixByte; },
|
prefix: function () { return prefixByte; },
|
||||||
notify: notify,
|
notify: notify,
|
||||||
search: function () { return window.location.search; },
|
search: function () { return window.location.search; },
|
||||||
navigate: function (search) { window.location.search = search; },
|
navigate: goToPicker,
|
||||||
allowKeyboard: keyboard.allow
|
allowKeyboard: keyboard.allow
|
||||||
});
|
});
|
||||||
toolbar.attach();
|
toolbar.attach();
|
||||||
|
|||||||
+38
-1
@@ -41,6 +41,7 @@
|
|||||||
// onStatus(string), size() -> {cols, rows}}
|
// onStatus(string), size() -> {cols, rows}}
|
||||||
function createClient(opts) {
|
function createClient(opts) {
|
||||||
var urls = endpoints(opts.location);
|
var urls = endpoints(opts.location);
|
||||||
|
var location = opts.location;
|
||||||
var encoder = new TextEncoder();
|
var encoder = new TextEncoder();
|
||||||
var decoder = new TextDecoder();
|
var decoder = new TextDecoder();
|
||||||
var socket = null;
|
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 {
|
return {
|
||||||
connect: connect,
|
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,
|
isOpen: open,
|
||||||
sendInput: sendInput,
|
sendInput: sendInput,
|
||||||
sendBinary: sendBinary,
|
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'
|
'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