Add X11 forwarding for interactive jobs (xcecli -I -X, ssh -X style)

Render -X jobs' windows on the user's own X server with zero client
install, mirroring the ssh -X experience: the job command starts with
DISPLAY pointed at a per-job TCP listener on the worker, and the user's
xcecli relays that listener's byte stream to their local X server.

- xcecli: new -X/--x11 flag (interactive=2 + exdata "x11":1). After
  submit it fetches the per-job MIT-MAGIC-COOKIE-1, registers it in the
  user's Xauthority, then bridges one xattach websocket to the local X
  server address parsed from $DISPLAY (unix socket or TCP). The cookie
  entry is removed when the session ends. Pre-flight checks report
  missing DISPLAY/xauth before submit.
- apiserver: GET /xce/v1/jobs/:id/xattach relays websocket binary frames
  to the worker's forwarding port (raw TCP, no ttyd). Close codes match
  the ttyd attach (4000 unreachable, 4001 ended). The job-finished event
  ends the relay. Teardown closes both hops as soon as either pump
  exits, so a client disconnect propagates instead of deadlocking in a
  blocking read.
- worker: XJOB jobs with exdata x11=1 skip VNC, listen on 6100-6199,
  generate a per-job xauth file (/tmp/job_<id>_xauth, worker-side since
  the worker has no session), export DISPLAY/XAUTHORITY to the command,
  and bridge the app connection (loopback) with the relay connection
  (non-loopback; roles classified by source address, not arrival order).
  A 60 s no-client watchdog reaps jobs nothing ever attached to; the
  v1 single-connection boundary refuses a second X connection loudly.

t1-verified per the plan matrix: window rendering (xclock/xeyes on
Xvfb), normal end -> DONE + xauth removed, no-client 60 s reap (137),
kill -> immediate teardown with no residue, -I/-I --browse regression,
daemon health, missing-DISPLAY error, second-connection refusal.
(--xjob VNC on t1 fails for lack of a vncserver binary; pre-existing.)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-17 02:16:06 +00:00
parent 4f188310ba
commit 85a22271c1
19 changed files with 1744 additions and 50 deletions

View file

@ -478,6 +478,52 @@ func TestJobHandler_SubmitInteractiveAttachExdata(t *testing.T) {
}
}
// TestJobHandler_SubmitX11Exdata checks the submit handler packs x11 into
// the job's ExtendData for -X jobs: the key selects the X11-forwarding
// backend worker-side (DISPLAY to the forwarding port instead of VNC).
func TestJobHandler_SubmitX11Exdata(t *testing.T) {
cases := []struct {
name string
req map[string]interface{}
wantX11 bool
}{
{"x11 job", map[string]interface{}{
"job_name": "a", "command": "xclock", "interactive": 2, "x11": 1,
}, true},
{"plain xjob (VNC backend)", map[string]interface{}{
"job_name": "a", "command": "xclock", "interactive": 2,
}, false},
{"tty job without x11", map[string]interface{}{
"job_name": "a", "command": "sleep 1", "interactive": 1,
}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newHandlerFixture(t)
f.seedQueue("normal")
w := f.submit(tc.req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
subs := f.submissions()
if len(subs) != 1 {
t.Fatalf("submitJob calls = %d, want 1", len(subs))
}
var ex map[string]interface{}
if raw := subs[0].Job.ExtendData.String; raw != "" {
if err := json.Unmarshal([]byte(raw), &ex); err != nil {
t.Fatalf("ExtendData %q is not JSON: %v", raw, err)
}
}
_, hasX11 := ex["x11"]
if hasX11 != tc.wantX11 {
t.Errorf("ExtendData x11 present = %v, want %v (exdata: %s)", hasX11, tc.wantX11, subs[0].Job.ExtendData.String)
}
})
}
}
// TestJobHandler_SmokeControlByID controls one seeded ordinary job by ID
// and checks the dispatch is recorded once with its unique_job_id.
func TestJobHandler_SmokeControlByID(t *testing.T) {