Skip to content

Commit fe77410

Browse files
committed
fix(browser): recover display after SSH reconnect
Refresh workspace-local display and Xauthority state on each initialize run, prefer mounted Wayland when available, and probe SSH X11 before launching Chrome. Add parallel-workspace and stale-display smoke coverage while keeping headless behavior unchanged.
1 parent aac358e commit fe77410

14 files changed

Lines changed: 790 additions & 262 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
/compose.local.gen.yml
2+
/compose.local.gen.yml.*
23
/compose.user.gen.yml
4+
/compose.user.gen.yml.*
35
/Dockerfile.merged.gen
46
/.env
7+
/.env.*
8+
/.Xauthority.gen
9+
/.Xauthority.gen.*
510
/.devcontainer/.env
11+
/.devcontainer/.env.*
12+
/.devcontainer/.Xauthority.gen
13+
/.devcontainer/.Xauthority.gen.*
614
/.codegeist/.local.env
715
/.oc_local/
816
/.worktrees/

README.md

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ The first start creates local and generated files when missing:
116116
- `.codegeist/.local.env`
117117
- `.chrome/`
118118
- `.devcontainer/.env`
119+
- `.devcontainer/.Xauthority.gen`
119120
- `.devcontainer/Dockerfile.merged.gen`
120121
- `.devcontainer/compose.local.gen.yml`
121122
- `.devcontainer/compose.user.gen.yml`, an ignored bridge to optional
@@ -144,9 +145,10 @@ file does not exist. It does not delete the legacy files and does not migrate a
144145
root `Dockerfile`; move devcontainer image extensions to `.codegeist/Dockerfile`
145146
manually if needed.
146147

147-
Do not edit `.devcontainer/.env`, `.devcontainer/Dockerfile.merged.gen`, or
148-
`.devcontainer/compose.local.gen.yml` or `.devcontainer/compose.user.gen.yml`;
149-
they are regenerated by `initialize.sh`. Put manual runtime overrides in
148+
Do not edit `.devcontainer/.env`, `.devcontainer/.Xauthority.gen`,
149+
`.devcontainer/Dockerfile.merged.gen`, `.devcontainer/compose.local.gen.yml`, or
150+
`.devcontainer/compose.user.gen.yml`; they are regenerated by `initialize.sh`.
151+
Put manual runtime overrides in
150152
`.codegeist/.local.env`, local Compose overrides in `.codegeist/compose.local.yml`,
151153
and devcontainer image extensions in `.codegeist/Dockerfile` instead. Create the
152154
Compose and Dockerfile override files only when the repository needs them.
@@ -223,26 +225,34 @@ chrome https://example.test
223225
```
224226

225227
The visible command does not start VNC or noVNC, and it does not treat a non-empty
226-
display variable as proof that a display server is reachable. A Wayland candidate
227-
is usable only when `WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR` identify an existing
228-
Unix socket. When that socket exists, the launcher prefers it, removes an
229-
inherited invalid `DISPLAY`, and starts Chrome with `--ozone-platform=wayland`.
230-
A local X11 value such as `DISPLAY=:0` is usable only when the matching
231-
`/tmp/.X11-unix/X0` socket exists inside the container. The shared Compose config
232-
does not mount `/tmp/.X11-unix` by default. SSH-forwarded values such as
233-
`DISPLAY=localhost:10.0` remain supported through host networking and Xauthority
234-
normalization; explicitly configured remote X11 host values are passed through as
235-
caller-managed connections.
236-
237-
`initialize.sh` still writes the host-side `DISPLAY` visible to
238-
`initializeCommand` into `.devcontainer/.env` as `DEVCONTAINER_DISPLAY`, and
239-
Compose passes that candidate into the container on create. The launcher rereads
240-
the mounted file before starting visible Chrome, so a VS Code reopen can refresh
241-
SSH X11 state when the existing container is reused. If no usable Wayland or X11
242-
backend exists, the launcher exits before Google Chrome starts and reports the
243-
missing socket paths. Use `chrome --headless ...`, SSH X11 forwarding, or an
244-
explicit project-local socket mount instead of broad host access such as
245-
`xhost +`.
228+
display variable as proof that a display server is reachable. During
229+
`initializeCommand`, `initialize.sh` detects an existing host Wayland socket from
230+
`WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR` (or `/run/user/<uid>/wayland-0`) and adds a
231+
generated bind for only that socket. When the mounted socket is reachable, the
232+
launcher prefers it, removes inherited `DISPLAY`, and starts Chrome with
233+
`--ozone-platform=wayland`. A local X11 value such as `DISPLAY=:0` remains usable
234+
only when `/tmp/.X11-unix/X0` exists inside the container; the shared Compose
235+
config does not mount local X11 sockets by default.
236+
237+
VS Code SSH reconnects can allocate a new loopback display number while reusing
238+
an existing container. Each initialize run atomically refreshes the selected
239+
workspace's `.devcontainer/.env` and ignored `.devcontainer/.Xauthority.gen`.
240+
The launcher rereads those files on every visible start, probes
241+
`DISPLAY=localhost:N.0` or `127.0.0.1:N.0` with a short `xdpyinfo` check, and, if
242+
needed, normalizes the matching `/unix:N` cookie through unique temporary
243+
Xauthority aliases. It
244+
exits before Google Chrome starts when no candidate is reachable. Worktrees keep
245+
separate generated state and `.chrome` profiles, so multiple VS Code instances
246+
on one host do not overwrite each other's runtime display files. Explicit
247+
non-loopback X11 hosts remain caller-managed.
248+
249+
Wayland discovery can mount only a socket that exists when the container is
250+
created. `initialize.sh` cannot create a graphical host session, and a socket
251+
that appears later cannot be added to an already running container without
252+
recreation. SSH X11 reconnect recovery does not have that limitation because it
253+
uses host networking and workspace-local refreshed authority state. Use
254+
`chrome --headless ...` when no visible backend is available; broad host access
255+
such as `xhost +` is neither required nor recommended.
246256

247257
Plain visible `chrome` uses `$DEVCONTAINER_WORKSPACE_FOLDER/.chrome` unless the
248258
caller passes an explicit `--user-data-dir`. Visible Chrome also disables
@@ -543,10 +553,10 @@ The same generated root files must still be ignored by the consuming repository:
543553
/.chrome/
544554
```
545555

546-
The `.devcontainer/.env`, `.devcontainer/Dockerfile.merged.gen`,
547-
`.devcontainer/compose.local.gen.yml`, and `.devcontainer/compose.user.gen.yml`
548-
files are generated by the kit inside the submodule checkout and intentionally
549-
ignored there.
556+
The `.devcontainer/.env`, `.devcontainer/.Xauthority.gen`,
557+
`.devcontainer/Dockerfile.merged.gen`, `.devcontainer/compose.local.gen.yml`, and
558+
`.devcontainer/compose.user.gen.yml` files are generated by the kit inside the
559+
submodule checkout and intentionally ignored there.
550560

551561
The kit also creates root `.oc_local/` for `OPENCODE_CONFIG_DIR` so OpenCode can
552562
bootstrap in fresh devcontainers. When the consuming repository does not track a
@@ -620,9 +630,10 @@ It may:
620630
`compose.local.yml` there when it already exists and the new file is missing;
621631
this file is not ignored automatically
622632
- create root `.oc_local/` for workspace-local OpenCode config
623-
- write `.devcontainer/.env`, `.devcontainer/Dockerfile.merged.gen`, and
624-
`.devcontainer/compose.local.gen.yml` generated runtime values such as the
625-
container hostname, hostname loopback resolution, and numeric runtime user
633+
- write `.devcontainer/.env`, `.devcontainer/.Xauthority.gen`,
634+
`.devcontainer/Dockerfile.merged.gen`, and `.devcontainer/compose.local.gen.yml`
635+
generated runtime values such as display state, container hostname, hostname
636+
loopback resolution, and numeric runtime user
626637
- update generated local env values when their content changed
627638
- compute host UID
628639
- compute a stable project name
@@ -641,22 +652,22 @@ Preferred pattern:
641652
1. `initialize.sh` writes `.codegeist/.local.env`.
642653
2. `initialize.sh` migrates a legacy root `compose.local.yml` into
643654
`.codegeist/compose.local.yml` only when that legacy file exists.
644-
3. `initialize.sh` writes `.devcontainer/.env`, including the host-side
645-
`DISPLAY` value as `DEVCONTAINER_DISPLAY` when one is present.
646-
4. `initialize.sh` writes `.devcontainer/Dockerfile.merged.gen`.
647-
5. `initialize.sh` writes `.devcontainer/compose.local.gen.yml`.
648-
6. `initialize.sh` writes `.devcontainer/compose.user.gen.yml`, either empty or
655+
3. `initialize.sh` writes `.devcontainer/.env`, including host display state.
656+
4. `initialize.sh` atomically refreshes `.devcontainer/.Xauthority.gen`.
657+
5. `initialize.sh` writes `.devcontainer/Dockerfile.merged.gen`.
658+
6. `initialize.sh` writes `.devcontainer/compose.local.gen.yml`.
659+
7. `initialize.sh` writes `.devcontainer/compose.user.gen.yml`, either empty or
649660
copied from `.codegeist/compose.local.yml`.
650-
7. `.devcontainer/docker-compose.yml` reads `.env` and
661+
8. `.devcontainer/docker-compose.yml` reads `.env` and
651662
`../.codegeist/.local.env` with `env_file`.
652-
8. `devcontainer.json` includes `compose.local.gen.yml` and
663+
9. `devcontainer.json` includes `compose.local.gen.yml` and
653664
`compose.user.gen.yml`.
654-
9. `.devcontainer/docker-compose.yml` owns the workspace and parent Git mounts.
655-
10. `devcontainer.json` uses `${localEnv:USER}` for `remoteUser` and
656-
`containerUser`.
657-
11. `docker-compose.yml` passes `DEVCONTAINER_DISPLAY` into the container as
658-
`DISPLAY` so SSH X11 forwarding follows the initialize-time environment.
659-
12. Container-side tools read normal environment variables from Compose.
665+
10. `.devcontainer/docker-compose.yml` owns the workspace and parent Git mounts.
666+
11. `devcontainer.json` uses `${localEnv:USER}` for `remoteUser` and
667+
`containerUser`.
668+
12. `docker-compose.yml` passes initial display values into the container; the
669+
Chrome launcher rereads the workspace files after SSH reconnects.
670+
13. Container-side tools read normal environment variables from Compose.
660671

661672
Example Compose shape:
662673

@@ -744,7 +755,8 @@ The tests should verify at least:
744755
- `.codegeist/.local.env` is created or preserved by `initializeCommand`
745756
- root `.oc_local/` is created for OpenCode and ignored unless the repository
746757
tracks a project overlay there
747-
- `.devcontainer/.env`, `.devcontainer/Dockerfile.merged.gen`,
758+
- `.devcontainer/.env`, `.devcontainer/.Xauthority.gen`,
759+
`.devcontainer/Dockerfile.merged.gen`,
748760
`.devcontainer/compose.local.gen.yml`, and
749761
`.devcontainer/compose.user.gen.yml` are regenerated by `initializeCommand`
750762
- the generated container hostname matches host, repo, and branch context
@@ -893,7 +905,8 @@ migrates a legacy root `.local.env` into `.codegeist/.local.env` when needed,
893905
and links the worktree `.codegeist/.local.env` back to the main root file. When
894906
the devcontainer starts from an already selected checkout, `initializeCommand`
895907
writes that checkout's
896-
`.devcontainer/.env`, `.devcontainer/Dockerfile.merged.gen`,
908+
`.devcontainer/.env`, `.devcontainer/.Xauthority.gen`,
909+
`.devcontainer/Dockerfile.merged.gen`,
897910
`.devcontainer/compose.local.gen.yml`, and `.devcontainer/compose.user.gen.yml`
898911
without nesting another worktree for the same branch.
899912

@@ -959,6 +972,7 @@ Typical examples:
959972

960973
- `.codegeist/.local.env`
961974
- `.devcontainer/.env`
975+
- `.devcontainer/.Xauthority.gen`
962976
- `.devcontainer/Dockerfile.merged.gen`
963977
- `.devcontainer/compose.local.gen.yml`
964978
- `.devcontainer/compose.user.gen.yml`

README_release.md

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ repository's root `.gitignore`. It never writes generated-file ignores to
5757
`.git/info/exclude`, so review and commit intentional `.gitignore` changes like
5858
normal repository state.
5959

60-
The generated `.devcontainer/.env`, `.devcontainer/Dockerfile.merged.gen`,
61-
`.devcontainer/compose.local.gen.yml`, and `.devcontainer/compose.user.gen.yml`
62-
files are written inside the submodule checkout and are ignored by the release
63-
kit itself.
60+
The generated `.devcontainer/.env`, `.devcontainer/.Xauthority.gen`,
61+
`.devcontainer/Dockerfile.merged.gen`, `.devcontainer/compose.local.gen.yml`, and
62+
`.devcontainer/compose.user.gen.yml` files are written inside the submodule
63+
checkout and are ignored by the release kit itself.
6464

6565
## OpenCode Agent Setup
6666

@@ -183,6 +183,7 @@ The first start creates local runtime files when missing:
183183
- root `.worktrees/`; `.worktrees/<branch>` as a worktree or current-branch
184184
symlink alias when `BRANCH` is set
185185
- `.devcontainer/.env`
186+
- `.devcontainer/.Xauthority.gen`
186187
- `.devcontainer/Dockerfile.merged.gen`
187188
- `.devcontainer/compose.local.gen.yml`
188189
- `.devcontainer/compose.user.gen.yml`, an ignored bridge to optional
@@ -273,24 +274,34 @@ chrome https://example.test
273274
```
274275

275276
The visible command does not start VNC or noVNC, and it validates display
276-
transport before starting Google Chrome. A Wayland candidate is usable only when
277-
`WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR` identify an existing Unix socket. When
278-
that socket exists, the launcher prefers it, removes an inherited invalid
279-
`DISPLAY`, and starts Chrome with `--ozone-platform=wayland`. A local X11 value
280-
such as `DISPLAY=:0` is usable only when `/tmp/.X11-unix/X0` exists inside the
281-
container; the shared Compose config does not mount `/tmp/.X11-unix` by default.
282-
SSH-forwarded values such as `DISPLAY=localhost:10.0` remain supported through
283-
host networking and Xauthority normalization. Explicit remote X11 host values
284-
remain caller-managed and are passed through.
285-
286-
`initialize.sh` still writes the host-side `DISPLAY` visible to
287-
`initializeCommand` into `.devcontainer/.env` as `DEVCONTAINER_DISPLAY`, and
288-
Compose passes that candidate into the container on create. The launcher rereads
289-
the mounted file before starting visible Chrome, so a VS Code reopen can refresh
290-
SSH X11 state when the existing container is reused. If no usable backend exists,
291-
the launcher exits before Google Chrome starts and reports the missing socket
292-
paths. Use `chrome --headless ...`, SSH X11 forwarding, or an explicit
293-
project-local socket mount instead of broad host access such as `xhost +`.
277+
transport before starting Google Chrome. During `initializeCommand`,
278+
`initialize.sh` detects an existing host Wayland socket from `WAYLAND_DISPLAY`
279+
and `XDG_RUNTIME_DIR` (or `/run/user/<uid>/wayland-0`) and adds a generated bind
280+
for only that socket. When the mounted socket is reachable, the launcher prefers
281+
it, removes inherited `DISPLAY`, and starts Chrome with
282+
`--ozone-platform=wayland`. A local X11 value such as `DISPLAY=:0` is usable only
283+
when `/tmp/.X11-unix/X0` exists; the shared Compose config does not mount local
284+
X11 sockets by default.
285+
286+
VS Code SSH reconnects can allocate a new loopback display number while reusing
287+
an existing container. Each initialize run atomically refreshes the selected
288+
workspace's `.devcontainer/.env` and ignored `.devcontainer/.Xauthority.gen`.
289+
The launcher rereads those files on every visible start, probes
290+
`DISPLAY=localhost:N.0` or `127.0.0.1:N.0` with a short `xdpyinfo` check, and, if
291+
needed, normalizes the matching `/unix:N` cookie through unique temporary
292+
Xauthority aliases. It
293+
exits before Google Chrome starts when no candidate is reachable. Worktrees keep
294+
separate generated state and `.chrome` profiles, so multiple VS Code instances
295+
on one host do not overwrite each other's runtime display files. Explicit
296+
non-loopback X11 hosts remain caller-managed.
297+
298+
Wayland discovery can mount only a socket that exists when the container is
299+
created. `initialize.sh` cannot create a graphical host session, and a socket
300+
that appears later cannot be added to an existing container without recreation.
301+
SSH X11 reconnect recovery does not have that limitation because it uses host
302+
networking and refreshed workspace-local authority state. Use
303+
`chrome --headless ...` when no visible backend is available; broad host access
304+
such as `xhost +` is neither required nor recommended.
294305

295306
Plain visible `chrome` uses `$DEVCONTAINER_WORKSPACE_FOLDER/.chrome` unless the
296307
caller passes an explicit `--user-data-dir`. Visible Chrome also disables
@@ -422,9 +433,10 @@ not as ordinary project source.
422433

423434
- Do not edit files inside `.devcontainer/` directly to customize one consuming
424435
project.
425-
- Do not commit `.devcontainer/.env`, `.devcontainer/Dockerfile.merged.gen`,
426-
`.devcontainer/compose.local.gen.yml`, `.devcontainer/compose.user.gen.yml`,
427-
`.codegeist/.local.env`, or generated `.worktrees/` files. Keep
436+
- Do not commit `.devcontainer/.env`, `.devcontainer/.Xauthority.gen`,
437+
`.devcontainer/Dockerfile.merged.gen`, `.devcontainer/compose.local.gen.yml`,
438+
`.devcontainer/compose.user.gen.yml`, `.codegeist/.local.env`, or generated
439+
`.worktrees/` files. Keep
428440
`.codegeist/compose.local.yml` visible to Git and keep `.codegeist/Dockerfile`
429441
visible to Git without `FROM`; commit either only when its overrides are
430442
intentional repository state.

docker-compose.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,14 @@
3434
# - DEVCONTAINER_DISPLAY is generated by initialize.sh from the host-side DISPLAY
3535
# visible to initializeCommand. Compose injects it when the container is
3636
# created; the Chrome launcher rereads and validates the candidate so VS Code
37-
# reopen can refresh SSH X11 state without a rebuild. This Compose file does not
38-
# mount host-local `/tmp/.X11-unix` sockets automatically.
37+
# SSH reconnect can refresh X11 state without recreating the container.
38+
# - DEVCONTAINER_XAUTHORITY points into the selected workspace. initialize.sh
39+
# atomically refreshes that ignored file after an SSH reconnect, avoiding a
40+
# stale bind mount when the host replaces its original Xauthority file.
41+
# - When initialize.sh sees a live host Wayland socket, compose.local.gen.yml
42+
# mounts only that socket and sets its container-side runtime variables. Static
43+
# Wayland mounts are avoided so SSH-only hosts never receive an invalid bind.
44+
# This Compose file does not mount host-local `/tmp/.X11-unix` sockets.
3945
# - OPENCODE_DIR_CONFIG, OPENCODE_DIR_SHARE, and OPENCODE_DIR_STATE select the
4046
# host-side OpenCode state directories mounted into the container.
4147
# - initialize.sh writes `.devcontainer/Dockerfile.merged.gen` before Compose
@@ -88,12 +94,11 @@ services:
8894
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER:-true}
8995
DEVCONTAINER_WORKSPACE_FOLDER: ${DEVCONTAINER_WORKSPACE_FOLDER:?DEVCONTAINER_WORKSPACE_FOLDER must be generated by initialize.sh}
9096
DISPLAY: ${DEVCONTAINER_DISPLAY:-}
91-
XAUTHORITY: /home/${USER:-vscode}/.Xauthority
97+
XAUTHORITY: ${DEVCONTAINER_XAUTHORITY:-${DEVCONTAINER_WORKSPACE_FOLDER}/.devcontainer/.Xauthority.gen}
9298
QT_X11_NO_MITSHM: "1"
9399
volumes:
94100
- ${DEVCONTAINER_WORKSPACE_FOLDER:?DEVCONTAINER_WORKSPACE_FOLDER must be generated by initialize.sh}:${DEVCONTAINER_WORKSPACE_FOLDER:?DEVCONTAINER_WORKSPACE_FOLDER must be generated by initialize.sh}:cached
95101
- ${DEVCONTAINER_REPO_ROOT:?DEVCONTAINER_REPO_ROOT must be generated by initialize.sh}:${DEVCONTAINER_REPO_ROOT:?DEVCONTAINER_REPO_ROOT must be generated by initialize.sh}:cached
96102
- ${OPENCODE_DIR_CONFIG:-/home/${USER:-vscode}/.config/opencode}:/home/${USER:-vscode}/.config/opencode
97103
- ${OPENCODE_DIR_SHARE:-/home/${USER:-vscode}/.local/share/opencode}:/home/${USER:-vscode}/.local/share/opencode
98104
- ${OPENCODE_DIR_STATE:-/home/${USER:-vscode}/.local/state/opencode}:/home/${USER:-vscode}/.local/state/opencode
99-
- ${HOME}/.Xauthority:/home/${USER:-vscode}/.Xauthority

0 commit comments

Comments
 (0)