Remote access follow-ups: three silent-failure traps and the mobile typing bug #22

Merged
branderson merged 4 commits from remote-access-followups into main 2026-07-29 22:49:33 -07:00
Owner

Summary generated by LLM agent

Four follow-ups to #21. The first three were written before that PR merged
but never made it in — it merged at acfc81f, so they are still absent from
main. Rebased onto the merge, so this is the full remainder.

The theme of the first three is the same: a change to the mobile fix from #21
can be applied correctly and still have no visible effect, with nothing in
the path reporting a problem.

remote.conf is read once, at process start

dejima-ttyd reads TTYD_FONT_SIZE and execs ttyd with it in argv, so
editing the file without restarting the unit leaves the old size serving. The
README never said to restart.

A failed nginx reload keeps the old config and still reports success

systemctl reload nginx succeeds when the new config does not parse — the old
one keeps running. nginx-light is built without the sub module, so an
unknown sub_filter directive is indistinguishable from a directive that ran
and did nothing. The docs claimed the opposite; they now point at nginx -t.

nginx -V and nginx -t say "command not found" on a healthy box

Debian and Ubuntu install to /usr/sbin, which is on root's PATH and not on
an ordinary user's. The bare commands these docs told the operator to run
point at a missing package when the real problem is a missing path — the
wrong thing to go and check. Absolute paths given throughout.

ttyd's page has no cache validator, and the proxy now rewrites the body

src/http.c sets status, content-type, content-encoding and content-length
and nothing else — no Cache-Control, no ETag, no Last-Modified — so a
browser may cache it indefinitely. Harmless while the body is served
verbatim; a trap once sub_filter rewrites it, because a phone holding the
un-injected copy renders it forever and no later change to the injection ever
reaches a browser that has already been there. Nothing is given up by
refusing the cache: with no validator upstream a revalidation could never come
back 304, and the page is still gzipped to the client, since clearing
Accept-Encoding applies only to the request made to ttyd. Covered in
test/remote.bats alongside the Accept-Encoding assertion, because the
filter and the things that keep it observable are only correct together.

Typing from a phone replays text you already typed

New README section. Two things place this in the browser rather than in
anything here: the duplicates are real input you can backspace over, not a
redraw artifact, and the transport is TCP, which cannot deliver the same bytes
twice. It reproduces on demand — a long run of digits never duplicates, while
ordinary words duplicate on every correction the keyboard makes.

The mechanism is xterm.js re-sending the entire contents of its hidden input
element when a soft keyboard replaces a span it has already committed,
instead of sending the difference. Nothing on this side reaches it:
CoreBrowserTerminal.ts already sets autocorrect, autocapitalize and
spellcheck to off on that element as it creates it, and Android ignores all
three — so injecting those same attributes via sub_filter, which is the
obvious-looking fix, would have been a no-op that looked like one. This is
documented rather than worked around deliberately: it is a general
ttyd/xterm.js interaction with predictive keyboards, not a dejima bug.

The recommendation is Unexpected Keyboard, and it earns it on the modifier
keys more than on the bug — without a real Ctrl there is no tmux prefix, and
Esc and the arrows are hard to do without in anything interactive. Its word
suggestions are opt-in behind a dictionary you download yourself, which is
why the wording says that rather than "no prediction": the feature does ship,
and claiming otherwise in a public README would be false.

Testing

bats test/audit.bats test/project.bats test/proxy.bats test/remote.bats test/static.bats → 301 passing, 0 failures on the rebased branch. The
pre-existing BW01 warning from test/static.bats:248 is unchanged.

The cache-control assertion was verified the only way that means anything:
by removing the directive and watching the test fail.

test/runtime.bats was not run — it builds the Docker image in
setup_file and there is no docker binary in the sandbox this was written
in. It needs a run on the host before merge.

The ttyd claims are checked against committed source rather than inferred:
src/html.h at 1.7.7 for the single literal lowercase <head> and zero
viewport metas that make the sub_filter pattern correct, and src/http.c
for the absent cache headers.

**Summary generated by LLM agent** Four follow-ups to #21. The first three were written before that PR merged but never made it in — it merged at `acfc81f`, so they are still absent from `main`. Rebased onto the merge, so this is the full remainder. The theme of the first three is the same: a change to the mobile fix from #21 can be applied *correctly* and still have no visible effect, with nothing in the path reporting a problem. ### `remote.conf` is read once, at process start `dejima-ttyd` reads `TTYD_FONT_SIZE` and execs `ttyd` with it in argv, so editing the file without restarting the unit leaves the old size serving. The README never said to restart. ### A failed nginx reload keeps the old config and still reports success `systemctl reload nginx` succeeds when the new config does not parse — the old one keeps running. `nginx-light` is built without the sub module, so an unknown `sub_filter` directive is indistinguishable from a directive that ran and did nothing. The docs claimed the opposite; they now point at `nginx -t`. ### `nginx -V` and `nginx -t` say "command not found" on a healthy box Debian and Ubuntu install to `/usr/sbin`, which is on root's PATH and not on an ordinary user's. The bare commands these docs told the operator to run point at a missing *package* when the real problem is a missing *path* — the wrong thing to go and check. Absolute paths given throughout. ### ttyd's page has no cache validator, and the proxy now rewrites the body `src/http.c` sets status, content-type, content-encoding and content-length and nothing else — no `Cache-Control`, no `ETag`, no `Last-Modified` — so a browser may cache it indefinitely. Harmless while the body is served verbatim; a trap once `sub_filter` rewrites it, because a phone holding the un-injected copy renders it forever and no later change to the injection ever reaches a browser that has already been there. Nothing is given up by refusing the cache: with no validator upstream a revalidation could never come back 304, and the page is still gzipped to the client, since clearing `Accept-Encoding` applies only to the request made to ttyd. Covered in `test/remote.bats` alongside the `Accept-Encoding` assertion, because the filter and the things that keep it observable are only correct together. ### Typing from a phone replays text you already typed New README section. Two things place this in the browser rather than in anything here: the duplicates are real input you can backspace over, not a redraw artifact, and the transport is TCP, which cannot deliver the same bytes twice. It reproduces on demand — a long run of digits never duplicates, while ordinary words duplicate on every correction the keyboard makes. The mechanism is xterm.js re-sending the entire contents of its hidden input element when a soft keyboard *replaces* a span it has already committed, instead of sending the difference. Nothing on this side reaches it: `CoreBrowserTerminal.ts` already sets `autocorrect`, `autocapitalize` and `spellcheck` to off on that element as it creates it, and Android ignores all three — so injecting those same attributes via `sub_filter`, which is the obvious-looking fix, would have been a no-op that looked like one. This is documented rather than worked around deliberately: it is a general ttyd/xterm.js interaction with predictive keyboards, not a dejima bug. The recommendation is Unexpected Keyboard, and it earns it on the modifier keys more than on the bug — without a real Ctrl there is no tmux prefix, and Esc and the arrows are hard to do without in anything interactive. Its word suggestions are opt-in behind a dictionary you download yourself, which is why the wording says that rather than "no prediction": the feature does ship, and claiming otherwise in a public README would be false. ## Testing `bats test/audit.bats test/project.bats test/proxy.bats test/remote.bats test/static.bats` → **301 passing, 0 failures** on the rebased branch. The pre-existing `BW01` warning from `test/static.bats:248` is unchanged. The cache-control assertion was verified the only way that means anything: by removing the directive and watching the test fail. `test/runtime.bats` was **not** run — it builds the Docker image in `setup_file` and there is no docker binary in the sandbox this was written in. It needs a run on the host before merge. The ttyd claims are checked against committed source rather than inferred: `src/html.h` at 1.7.7 for the single literal lowercase `<head>` and zero viewport metas that make the `sub_filter` pattern correct, and `src/http.c` for the absent cache headers.
branderson added 4 commits 2026-07-29 21:49:04 -07:00
Both halves of the mobile fix can be applied correctly and still have no
visible effect, for reasons nothing reports:

- remote.conf is read once, at process start. dejima-ttyd reads
  TTYD_FONT_SIZE and execs ttyd with it in argv, so editing the file
  without restarting the unit leaves the old size serving. The README
  never said to restart, and there is no warning anywhere in the path.

- an nginx reload whose config does not parse keeps the OLD config
  running while systemctl reload still reports success. An unknown
  sub_filter directive - nginx-light is built without the sub module -
  is therefore indistinguishable from a directive that ran and did
  nothing. The docs claimed the opposite; point at nginx -t instead.

Verified against ttyd 1.7.7's committed src/html.h: the served page
contains exactly one literal lowercase <head> and zero viewport metas,
so the sub_filter pattern itself is correct.
Debian and Ubuntu install nginx to /usr/sbin, which is on root's PATH and
not on an ordinary user's, so the bare 'nginx -V' and 'nginx -t' these
docs told the operator to run answer 'command not found' on a box that is
serving happily - pointing at a missing package rather than a missing
path, which is the wrong thing to go and check.
ttyd sends its page with no Cache-Control, no ETag and no Last-Modified -
src/http.c sets status, content-type, content-encoding, content-length and
nothing else - so a browser may cache it for as long as it likes. That is
harmless while the body is served verbatim, and a trap once sub_filter
rewrites it: a phone holding the un-injected copy renders it forever, so
the viewport fix reads as broken long after it works, and no later change
to the injection ever reaches a browser that has already been there.

Nothing is given up by refusing it. With no validator upstream a
revalidation could never come back 304, and the page is still gzipped to
the client - clearing Accept-Encoding applies only to the request made to
ttyd, not to the response nginx sends on.

Coupled in the test alongside Accept-Encoding, for the same reason: the
filter and the things that keep it observable are only correct together.
Verified by removing the directive and watching the test fail.
Typing into the web front door from a phone replays text you already
typed, a batch at a time. Two things place it in the browser rather than
in anything here: the duplicates are real input you can backspace over,
not a redraw artifact, and the transport is TCP, which cannot deliver the
same bytes twice. It also reproduces on demand - a long run of digits
never duplicates, while ordinary words duplicate on every correction the
keyboard makes.

The mechanism is xterm.js re-sending the whole contents of its hidden
input element when a soft keyboard *replaces* a span it has already
committed, rather than sending the difference. Nothing on this side
reaches it: CoreBrowserTerminal.ts already sets autocorrect,
autocapitalize and spellcheck to off on that element as it creates it,
and Android ignores all three - so injecting those same attributes via
sub_filter, the obvious-looking fix, would have been a no-op that looked
like one.

So document it and recommend a keyboard with nothing to correct against.
Unexpected Keyboard earns the recommendation on the modifier keys more
than on the bug - without a real Ctrl there is no tmux prefix, and Esc
and the arrows are hard to do without in anything interactive. Its
suggestions are opt-in behind a dictionary you download yourself, which
is why the wording says that and not "no prediction": the feature does
ship.
branderson merged commit 772041eda0 into main 2026-07-29 22:49:33 -07:00
Sign in to join this conversation.