Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions apps/server/src/device/sshDeviceScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import * as Effect from "effect/Effect";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { expect, it } from "@effect/vitest";
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeVM from "node:vm";
import * as NodeModule from "node:module";
import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeUtil from "node:util";
import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";

const require = NodeModule.createRequire(import.meta.url);

const exec = NodeUtil.promisify(NodeChildProcess.execFile);

it.effect("finds Android Studio Java for a non-interactive SSH session", () =>
Expand Down Expand Up @@ -46,6 +52,173 @@ it.effect("preserves shell metacharacters and newlines in remote arguments", ()
}),
);

it.each([
["start", "win32", "success"],
["agent-start", "win32", "success"],
["start", "linux", "success"],
["probe", "win32", "success"],
["start", "win32", "worker failure"],
["start", "win32", "worker exit"],
] as const)(
"%s on %s: %s preserves the response and cleans up the bootstrap",
async (mode, platform, scenario) => {
const home = await NodeFSP.mkdtemp(
NodePath.join(NodeOS.tmpdir(), "t3-ssh-handles-\u00e9-\u05d0-"),
);
const execPath = platform === "win32" ? "C:\\Users\\\u00e9-\u05d0\\node.exe" : process.execPath;
const owner = "test-owner";
const state = NodePath.join(home, ".t3/device/hosts", owner);
const tools = NodePath.join(home, ".t3/device/tools");
const messages: Array<string> = [];
const errors: Array<string> = [];
let launches = 0;
let workers = 0;
const run = (script: string, worker: boolean, directory = state): Promise<void> => {
if (worker) workers++;
return NodeVM.runInNewContext(script, {
__dirname: directory,
require: (name: string) => {
if (name === "node:fs")
return {
...NodeFS,
symlinkSync: (target: string, file: string) =>
NodeFS.writeFileSync(file, target, { flag: "wx" }),
readlinkSync: (file: string) => NodeFS.readFileSync(file, "utf8"),
};
if (name === "node:os") return { ...NodeOS, homedir: () => home };
if (name === "node:child_process")
return {
spawn: () => {
throw Error("Unexpected service restart");
},
spawnSync: (command: string, args: Array<string>) => {
if (command === "powershell.exe" && args.includes("-EncodedCommand")) {
const launchScript = Buffer.from(
args[args.indexOf("-EncodedCommand") + 1]!,
"base64",
).toString("utf16le");
expect(launchScript).toContain("Win32_ProcessStartup");
expect(launchScript).toContain(`CurrentDirectory = '${home}'`);
expect([command, ...args].join(" ").length + 1).toBeLessThan(30000);
launches++;
if (scenario === "worker exit")
return {
status: 0,
stdout: JSON.stringify({ ReturnValue: 0, ProcessId: 1234 }),
};
const directory = NodeFS.readdirSync(state).find((name) =>
name.startsWith(".bootstrap-"),
)!;
const file = NodePath.join(state, directory, "bootstrap.cjs");
expect(launchScript).toContain(`CommandLine = '"${execPath}" "${file}"'`);
void run(NodeFS.readFileSync(file, "utf8"), true, NodePath.dirname(file));
return { status: 0, stdout: JSON.stringify({ ReturnValue: 0, ProcessId: 1234 }) };
}
return {
status:
command === "xcrun" ||
(worker && scenario === "worker failure" && command === "adb")
? 1
: 0,
stdout: command === "powershell.exe" ? "[]" : "",
stderr: "",
};
},
};
return require(name);
},
process: {
platform,
execPath,
pid: worker ? 1234 : 5678,
versions: process.versions,
env: {},
cwd: () => home,
kill: () => {
if (scenario === "worker exit") throw Object.assign(Error("exited"), { code: "ESRCH" });
},
},
fetch: async () => ({ ok: true }),
AbortSignal,
Buffer,
setTimeout: (callback: () => void) => {
void Promise.resolve().then(callback);
},
console: {
log: (value: string) => {
messages.push(value);
},
error: (error: string) => {
errors.push(error);
},
},
});
};
try {
await NodeFSP.mkdir(state, { recursive: true });
for (const [name, version, entry] of [
["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"],
["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"],
] as const) {
const directory = NodePath.join(tools, `${name}@${version}`);
const entryPath = NodePath.join(directory, "node_modules", name, entry);
await NodeFSP.mkdir(NodePath.dirname(entryPath), { recursive: true });
await NodeFSP.writeFile(entryPath, "");
await NodeFSP.writeFile(NodePath.join(directory, ".install-complete"), version);
if (name === "expo-device-hub")
await NodeFSP.writeFile(
NodePath.join(state, "hub.json"),
JSON.stringify({
owner,
pid: 42,
port: 12345,
entryPath,
platform: "android",
emulatorExtraArgs: "",
}),
);
else {
await NodeFSP.writeFile(
NodePath.join(state, "agent.json"),
JSON.stringify({ entryPath }),
);
await NodeFSP.writeFile(
NodePath.join(state, "daemon.json"),
JSON.stringify({ pid: 43, httpPort: 23456, token: "test-token" }),
);
}
}
await run(remoteDeviceScript(owner, mode), false);
const isolated = platform === "win32" && (mode === "start" || mode === "agent-start");
expect(launches).toBe(isolated ? 1 : 0);
expect(workers).toBe(isolated && scenario !== "worker exit" ? 1 : 0);
if (scenario !== "success") {
expect(messages).toEqual([]);
expect(errors).toEqual([
expect.stringContaining(
scenario === "worker exit" ? "exited without a response" : "Android SDK missing",
),
]);
} else {
expect(errors).toEqual([]);
expect(messages).toHaveLength(1);
if (mode === "probe")
expect(JSON.parse(messages[0]!)).toMatchObject({ nodePath: execPath });
else
expect(JSON.parse(messages[0]!)).toMatchObject({
hubPort: 12345,
...(mode === "agent-start" ? { daemonPort: 23456, token: "test-token" } : {}),
});
}
expect(
(await NodeFSP.readdir(state)).filter((name) => name.startsWith(".bootstrap-")),
).toEqual([]);
} finally {
await NodeFSP.rm(home, { recursive: true, force: true });
}
},
);

it.effect("stops a daemon left for its owner when starting without agent tools", () =>
Effect.gen(function* () {
if ((yield* HostProcess.Platform) === "win32") return;
Expand Down
52 changes: 47 additions & 5 deletions apps/server/src/device/sshDeviceScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const remoteDeviceScript = (
mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
) =>
`
async function bootstrap() {
const owner = ${JSON.stringify(owner)};
const mode = ${JSON.stringify(mode)};
const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)};
Expand Down Expand Up @@ -131,7 +132,6 @@ async function install(name, version, entry) {
release();
}
}
(async () => {
const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0;
const android = run('adb', ['version']).status === 0;
const platforms = [
Expand All @@ -141,7 +141,7 @@ async function install(name, version, entry) {
if (mode === 'probe') {
if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.');
if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.');
console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return;
return { nodePath: process.execPath, platforms, tools: versions() };
}
fs.mkdirSync(state, { recursive: true, mode: 0o700 });
// Serialize starts and stops for this environment/host owner, including agent startup.
Expand Down Expand Up @@ -239,8 +239,50 @@ async function install(name, version, entry) {
const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist');
const optional = file => fs.existsSync(file) ? file : null;
await pruneTools(path.join(root, 'tools'), [['expo-device-hub', hubVersion], ...(mode === 'agent-start' ? [['agent-device', agentVersion]] : [])], true).catch(() => {});
console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult,
helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } }));
return { nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult,
helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } };
} finally { releaseHost(); }
})().catch(error => { console.error(error.message); process.exitCode = 1; });
}
async function start() {
if (process.platform !== 'win32' || !['start', 'agent-start'].includes(${JSON.stringify(mode)})) return bootstrap();
// Windows detached children inherit SSH's pipe handles even with redirected stdio.
// WMI creates the bootstrap outside that process tree, so its services cannot keep SSH open.
const fs = require('node:fs');
const path = require('node:path');
const state = path.join(require('node:os').homedir(), '.t3', 'device', 'hosts', ${JSON.stringify(owner)});
fs.mkdirSync(state, { recursive: true, mode: 0o700 });
const directory = fs.mkdtempSync(path.join(state, '.bootstrap-'));
const script = path.join(directory, 'bootstrap.cjs');
const result = path.join(directory, 'result.json');
const finish = value => {
const fs = require('node:fs');
const path = require('node:path');
const file = path.join(__dirname, 'result.json');
fs.writeFileSync(file + '.tmp', JSON.stringify(value), { mode: 0o600 });
fs.renameSync(file + '.tmp', file);
};
try {
fs.writeFileSync(script, bootstrap.toString() + '\nconst finish = ' + finish.toString() + ';\nbootstrap().then(value => finish({ value }), error => finish({ error: error.message }));', { mode: 0o600 });
const quote = value => "'" + value.replaceAll("'", "''") + "'";
const command = '"' + process.execPath + '" "' + script + '"';
const launchScript = "$ErrorActionPreference = 'Stop'\n" +
"$startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0; CreateFlags = [uint32]1024; EnvironmentVariables = [string[]]@([Environment]::GetEnvironmentVariables().GetEnumerator() | ForEach-Object { $_.Key + '=' + $_.Value }) }\n" +
"Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = " + quote(command) + "; CurrentDirectory = " + quote(process.cwd()) + "; ProcessStartupInformation = $startup } | Select-Object ReturnValue,ProcessId | ConvertTo-Json -Compress\n";
const launched = require('node:child_process').spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(launchScript, 'utf16le').toString('base64')], {
encoding: 'utf8', timeout: 30000,
});
if (launched.status !== 0) throw Error('Could not isolate Windows device startup: ' + (launched.error?.message || launched.stderr));
const launch = JSON.parse(launched.stdout);
if (launch.ReturnValue !== 0 || !Number.isSafeInteger(launch.ProcessId) || launch.ProcessId <= 0) throw Error('Could not isolate Windows device startup (WMI result ' + launch.ReturnValue + ').');
while (!fs.existsSync(result)) {
try { process.kill(launch.ProcessId, 0); }
catch (error) { if (error.code !== 'ESRCH') throw error; if (!fs.existsSync(result)) throw Error('Windows device bootstrap exited without a response.'); }
await new Promise(resolve => setTimeout(resolve, 100));
}
const response = JSON.parse(fs.readFileSync(result, 'utf8'));
if (response.error) throw Error(response.error);
return response.value;
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
}
start().then(value => { if (value) console.log(JSON.stringify(value)); }).catch(error => { console.error(error.message); process.exitCode = 1; });
`;
Loading