From 47a5faf744823640f5232d0e54b149260e28e220 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sat, 14 Nov 2026 03:36:51 -0300 Subject: [PATCH] test: the dashboard's link brings the open window, a move, and windows quit as a person quits reuse: with a window connected to spin+e2e-running, the dashboard's link for it, by its id, is handed to the running VS Code as the OS hands it. Spinbox's spin code answers the same folder, and VS Code brings that window (doOpenFilesInExistingWindow) though Spinbox asks openFolder for a new one: no second window, which is what spin#1119 asked of the extension. The lane holds it. VS Code's once-asked 'Allow Spinbox to open this URI?' is answered in the lane's settings, as the person who said yes to the dashboard's first link. moved: spin workspace move under a connected window, given minutes. The window is back in 12 s with the same shell, but on the host it had: the reconnect's spin code --start resumes the workspace while the move has it suspended, and the move aborts (spin#1225). Run only with CASES=moved, after spin's task e2e:hosts:up. Each case now quits VS Code through the probe (workbench.action.quit) and kills it only when it does not quit. Killed, a window never told the workspace's server it went, and the server kept its extension host for the reconnection's grace: about 150-400 MB a window, until a 2 GB workspace could start no process (spawn EAGAIN, ssh sessions closed after login, spin exec failing). Quit, memory holds at the server's own after each window. Against spin's e2e stack (f15122137), VS Code 1.146.0, Spinbox Remote with open-remote-ssh#17: test:spin-remote 19/19, test:remote-ssh 14/14. Co-Authored-By: Claude Opus 5.5 --- Taskfile.yml | 7 +- test/lane/lane.mjs | 29 +++++- test/lane/testdata/probe/extension.cjs | 4 + test/spin-remote/run.mjs | 133 +++++++++++++++++++++---- 4 files changed, 146 insertions(+), 27 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index bb7d751..ff86165 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -78,11 +78,12 @@ tasks: The packaged VSIX and Spinbox Remote in a real VS Code stable, its proposed API enabled by argv.json, opening the workspaces of a spin e2e stack as vscode-remote://spin+ (test/spin-remote/run.mjs): running, stopped, a connection cut under the window and back - with the same terminal, and a refused key said once. Needs the stack - SPIN_STACK= after its `task e2e:up` and `task e2e:workspaces` - bwrap, and SPINBOX_REMOTE_VSIX=, so no workflow runs it. CASES=moved - adds a move to another host, after spin's `task e2e:hosts:up`. Under xvfb-run when it is - installed. + adds a move to another host, after spin's `task e2e:hosts:up`; it fails until spin#1225. + Under xvfb-run when it is installed. vars: XVFB: sh: command -v xvfb-run || true diff --git a/test/lane/lane.mjs b/test/lane/lane.mjs index 8c85454..6e3e7c4 100644 --- a/test/lane/lane.mjs +++ b/test/lane/lane.mjs @@ -167,6 +167,9 @@ function spinWrapper(dir, spin) { return { wrapper, calls } } +// How long VS Code may take to quit when the probe asks it to. +const QUIT_MS = 15_000 + export const SETTINGS = { "spinbox.telemetry": false, "telemetry.telemetryLevel": "off", @@ -176,8 +179,11 @@ export const SETTINGS = { "workbench.startupEditor": "none", "security.workspace.trust.enabled": false, // Every case opens the same workspaces, whose server keeps the terminals earlier windows left: - // a new window would revive them all, and its own terminal was seen to exit among them. + // a new window would revive them all, every earlier case's probe terminal in this one's. "terminal.integrated.enablePersistentSessions": false, + // VS Code asks once whether an extension may open a vscode:// link, in a modal nobody answers + // here: the person who said yes, as they would to the dashboard's first link. + "extensions.confirmedUriHandlerExtensionIds": ["spin-stack.spinbox"], } // openCase starts VS Code on folder in a home of the case's own and waits up to waitMs until @@ -206,6 +212,8 @@ export async function openCase(lane, name, o) { probe: join(dir, "probe.jsonl"), dismiss: join(dir, "dismiss"), again: join(dir, "again"), + quit: join(dir, "quit"), + userData, } const started = Date.now() const home = homedir() @@ -249,6 +257,7 @@ export async function openCase(lane, name, o) { SPINBOX_PROBE_OUT: files.probe, SPINBOX_PROBE_DISMISS: files.dismiss, SPINBOX_PROBE_AGAIN: files.again, + SPINBOX_PROBE_QUIT: files.quit, }, }, ) @@ -277,10 +286,20 @@ export async function openCase(lane, name, o) { s = seen() } say(`${name}: ${why}`) - try { - process.kill(-code.pid, "SIGTERM") - } catch {} - await sleep(2_000) + // Quit as a person does, so VS Code tells the workspace's server the window is gone and its + // extension host ends: killed, the server keeps it for the reconnection's grace, three hours, + // and a few cases filled the workspace's memory until it could start no process. + if (!why.startsWith("VS Code exited")) { + writeFileSync(files.quit, "") + const quit = await Promise.race([exited, sleep(QUIT_MS).then(() => undefined)]) + if (quit === undefined) { + say(`${name}: VS Code did not quit within ${QUIT_MS / 1000} s; ended`) + try { + process.kill(-code.pid, "SIGTERM") + } catch {} + await sleep(2_000) + } + } return seen() } diff --git a/test/lane/testdata/probe/extension.cjs b/test/lane/testdata/probe/extension.cjs index 1381e4b..d7230a6 100644 --- a/test/lane/testdata/probe/extension.cjs +++ b/test/lane/testdata/probe/extension.cjs @@ -8,6 +8,8 @@ // read back over the remote's file system; // - again: the same terminal asked for another command, when the lane creates // SPINBOX_PROBE_AGAIN - after a reconnect, whether the terminal outlived it; +// - quit: VS Code quits when the lane creates SPINBOX_PROBE_QUIT, as a person quits it, so the +// workspace's server is told each window went; // - dismissed: the notifications closed when the lane asked, by creating SPINBOX_PROBE_DISMISS, // as a person closes one: a notification nobody answers holds its caller forever. const fs = require("node:fs") @@ -16,6 +18,7 @@ const vscode = require("vscode") const out = process.env.SPINBOX_PROBE_OUT const dismiss = process.env.SPINBOX_PROBE_DISMISS const again = process.env.SPINBOX_PROBE_AGAIN +const quit = process.env.SPINBOX_PROBE_QUIT const REMOTES = ["ssh-remote", "spin"] function say(event, fields = {}) { @@ -30,6 +33,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) exports.activate = () => { const folder = vscode.workspace.workspaceFolders?.[0]?.uri say("activate", { remoteName: vscode.env.remoteName ?? null, folder: folder?.toString() ?? null }) + if (quit) void whenAsked(quit, () => vscode.commands.executeCommand("workbench.action.quit")) if (dismiss) void whenAsked(dismiss, async () => { await vscode.commands.executeCommand("notifications.clearAll") diff --git a/test/spin-remote/run.mjs b/test/spin-remote/run.mjs index adeb3de..0b08cfa 100644 --- a/test/spin-remote/run.mjs +++ b/test/spin-remote/run.mjs @@ -14,17 +14,21 @@ // it had is the same shell; // - unenrolled: with a key the account does not have, the resolver refuses with // key_not_enrolled, says it once, and VS Code neither retries nor connects; +// - reuse: the dashboard's link for the workspace open in the window, by its id, handed to the +// running VS Code: Spinbox opens it, and VS Code brings that window rather than another +// (spin#1119: the dashboard learns nothing back, so the window has to be found here); // - moved (only when CASES names it, with spin's `task e2e:hosts:up` run first): the workspace // moved to another host under a connected window: VS Code resolves again, to the new host's -// key, and the terminal is the same shell. +// key, and the terminal is the same shell. It fails until spin#1225: the reconnect's start +// resumes the workspace where it was, and the move aborts. // // SPIN_STACK names the spin checkout whose stack is up (test/lane/lane.mjs says what it runs each // case in); SPINBOX_REMOTE_VSIX, Spinbox Remote's VSIX (its `task package`, or a release's). -// CASES=running,stopped,reconnect,unenrolled picks some. `task test:spin-remote` runs it. -import { spawnSync } from "node:child_process" -import { appendFileSync } from "node:fs" -import { resolve } from "node:path" -import { lane, openCase, root } from "../lane/lane.mjs" +// CASES=running,stopped,reconnect,unenrolled,reuse picks some. `task test:spin-remote` runs it. +import { spawn, spawnSync } from "node:child_process" +import { appendFileSync, existsSync, openSync, readdirSync } from "node:fs" +import { join, resolve } from "node:path" +import { lane, logOf, openCase, root } from "../lane/lane.mjs" const RESOLVER = "spin-stack.spinbox-remote" const vsix = process.env.SPINBOX_VSIX ?? resolve(root, "spinbox.vsix") @@ -117,8 +121,8 @@ async function connects(name, workspace) { } // A connected window has its connection cut by cut(seen), then is asked for another line from its -// terminal once the resolver has opened the workspace again. -async function survives(name, workspace, cut) { +// terminal once the resolver has opened the workspace again, which it claims within backMs. +async function survives(name, workspace, cut, backMs = RECONNECT_MS) { let cutAt let askedAt const s = await open(name, workspace, { @@ -136,7 +140,7 @@ async function survives(name, workspace, cut) { } // Not back in the time the case claims, or back without the terminal answering soon // after: failed, and waiting longer would only make it slower to say so. - if (askedAt === undefined) return Date.now() - cutAt > RECONNECT_MS + if (askedAt === undefined) return Date.now() - cutAt > backMs return terminal(s, "again")?.output != null || Date.now() - askedAt > ANSWER_MS }, }) @@ -149,8 +153,8 @@ async function survives(name, workspace, cut) { [...resolving(s), ...reopened], ) check( - `${name}: the window was back within ${RECONNECT_MS / 1000} s`, - askedAt !== undefined && askedAt - cutAt <= RECONNECT_MS, + `${name}: the window was back within ${backMs / 1000} s`, + askedAt !== undefined && askedAt - cutAt <= backMs, `cut at ${cutAt}, opened again at ${askedAt}: ${askedAt - cutAt} ms`, ) check( @@ -222,20 +226,111 @@ const cases = { }, } -// A move needs a second host (spin's task e2e:hosts:up), so it runs when asked for. +// The windows VS Code opened in a case: each has a log directory of its own. +function windowsOf(userData) { + const logs = join(userData, "logs") + if (!existsSync(logs)) return [] + return readdirSync(logs).flatMap((session) => + readdirSync(join(logs, session)).filter((d) => d.startsWith("window")), + ) +} + +// The dashboard's link for a workspace, handed to the VS Code that is running, as the OS hands it. +function openLink(userData, workspace) { + const out = openSync(join(userData, "open-url.log"), "a") + spawn( + lab.vscode.exe, + [ + "--no-sandbox", + "--disable-gpu", + `--user-data-dir=${userData}`, + `--extensions-dir=${lab.vscode.extensions}`, + "--open-url", + `vscode://spin-stack.spinbox/open?workspace=${workspace}`, + ], + { detached: true, stdio: ["ignore", out, out], env: process.env }, + ).unref() +} + +// How long the reuse case watches for a second window after the link. +const LINK_MS = 30_000 + +cases.reuse = async () => { + // By its id, as the dashboard's link names it: the window was opened by its name. + const id = JSON.parse(asPerson("-o", "json", "workspace", "get", "e2e-running").out).id + let sentAt + let userData + const s = await open("reuse", "e2e-running", { + until: (s, files) => { + if (!answered(s)) return false + if (!connected(s)) return true + if (sentAt === undefined) { + sentAt = Date.now() + userData = files.userData + openLink(userData, id) + } + return windowsOf(userData).length > 1 || Date.now() - sentAt > LINK_MS + }, + }) + const handled = s.spinbox.filter((l) => l.includes("spin code")) + check( + "reuse: Spinbox handled the dashboard's link", + s.calls.some((l) => l.includes(` end 0 code ${id} `) && Number(l.split(" ")[0]) >= sentAt), + [...s.calls.filter((l) => / code /.test(l)), ...handled], + ) + const windows = windowsOf(userData) + // VS Code's own word that the folder asked for was the open window's. + const reused = logOf(userData, "main.log").filter((l) => + l.includes("doOpenFilesInExistingWindow"), + ) + check( + "reuse: the link brought the window already open to e2e-running, and opened no other", + windows.length === 1 && reused.length > 0, + [...windows, ...reused.map((l) => l.slice(0, 100))], + ) +} + +// A move needs a second host (spin's task e2e:hosts:up), so it runs when asked for. It pauses the +// workspace while its memory is stored and its disk published, which takes minutes. +const MOVE_MS = Number(process.env.MOVE_MS ?? 600_000) if (process.env.CASES?.split(",").includes("moved")) { cases.moved = async () => { - const hostOf = () => JSON.parse(asPerson("-o", "json", "workspace", "get", "e2e-running").out) + const hostOf = () => + JSON.parse(asPerson("-o", "json", "workspace", "get", "e2e-running").out).hostId const before = hostOf() - await survives("moved", "e2e-running", () => { - const r = asPerson("workspace", "move", "e2e-running") - if (!r.ok) console.log(` spin workspace move: ${r.err}`) - }) + let moved + await survives( + "moved", + "e2e-running", + () => { + // Not waited for here: the window is watched while the workspace moves under it. + moved = new Promise((done) => { + const p = spawn(lab.spin, ["workspace", "move", "e2e-running"], { + env: { + ...process.env, + HOME: lab.person, + SSL_CERT_DIR: `/etc/ssl/certs:${lab.person}/.trust`, + }, + }) + let said = "" + p.stdout.on("data", (d) => { + said += d + }) + p.stderr.on("data", (d) => { + said += d + }) + p.on("close", (code) => done({ code, said: said.trim(), at: Date.now() })) + }) + }, + MOVE_MS, + ) + const m = await moved + check("moved: spin workspace move succeeded", m?.code === 0, m?.said) const after = hostOf() check( "moved: e2e-running runs on another host", - before.host_id !== undefined && after.host_id !== before.host_id, - `${before.host_id} -> ${after.host_id}`, + before !== undefined && after !== undefined && after !== before, + `${before} -> ${after}`, ) } }