Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - Serves a fake GitHub Release over real HTTP to the published launcher.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/process";
11import * as NodeChildProcess from "node:child_process";
12import * as NodeCrypto from "node:crypto";
13import * as NodeEvents from "node:events";
14import * as NodeHttp from "node:http";
15
16import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease";
17
18import { buildNpmLauncher } from "./build-npm-launcher.ts";
19
20const VERSION = "1.2.3";
21const decodeManifest = Schema.decodeEffect(
22 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
23);
24
25const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
26 stream.pipe(
27 Stream.decodeText(),
28 Stream.runFold(
29 () => "",
30 (acc, chunk) => acc + chunk,
31 ),
32 );
33
34const run = Effect.fn("test.run")(function* (
35 command: string,
36 args: ReadonlyArray<string>,
37 options: { readonly cwd: string; readonly env: Record<string, string | undefined> },
38) {
39 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
40 const child = yield* spawner.spawn(
41 ChildProcess.make(command, args, { cwd: options.cwd, env: options.env, extendEnv: false }),
42 );
43 const [stdout, stderr, exitCode] = yield* Effect.all(
44 [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))],
45 { concurrency: "unbounded" },
46 );
47 return { stdout, stderr, exitCode };
48}, Effect.scoped);
49
50const serveFakeRelease = Effect.fn("test.serveFakeRelease")(function* (
51 root: string,
52 executable = `#!/bin/sh
53if [ -n "$T3CODE_TEST_CALLS" ]; then echo "$*|$T3CODE_NPM_LAUNCHER_PATH" >> "$T3CODE_TEST_CALLS"; fi
54if [ "$1" = "--version" ]; then echo "${VERSION}"; exit 0; fi
55echo "stub $* launcher=$T3CODE_NPM_LAUNCHER_PATH"
56exit 7
57`,
58) {
59 const fs = yield* FileSystem.FileSystem;
60 const path = yield* Path.Path;
61 const key = `${yield* HostProcess.Platform}-${yield* HostProcess.Architecture}`;
62 const stem = `t3-${VERSION}-${key}`;
63 const archiveName = `${stem}.tar.gz`;
64 yield* fs.makeDirectory(path.join(root, "release", stem, "node_modules/native"), {
65 recursive: true,
66 });
67 yield* fs.writeFileString(path.join(root, "release", stem, "t3"), executable);
68 yield* fs.chmod(path.join(root, "release", stem, "t3"), 0o755);
69 const tar = yield* run("tar", ["-czf", archiveName, stem], {
70 cwd: path.join(root, "release"),
71 env: { PATH: (yield* HostProcess.Environment).PATH },
72 });
73 assert.equal(tar.exitCode, 0, tar.stderr);
74 const archive = yield* fs.readFile(path.join(root, "release", archiveName));
75 let checksum = NodeCrypto.createHash("sha256").update(archive).digest("hex");
76 const requests: Array<string> = [];
77 const failures: Array<number> = [];
78 const server = NodeHttp.createServer((request, response) => {
79 requests.push(request.url ?? "");
80 const failure = failures.shift();
81 if (failure !== undefined) {
82 response.writeHead(failure).end("upstream timed out");
83 } else if (request.url === `/v${VERSION}/SHA256SUMS`) {
84 response.end(`${checksum} ${archiveName}\n`);
85 } else if (request.url === `/v${VERSION}/${archiveName}`) {
86 response.writeHead(200, { "Content-Length": archive.length }).end(archive);
87 } else {
88 response.writeHead(404).end();
89 }
90 });
91 yield* Effect.acquireRelease(
92 Effect.callback<void>((resume) => {
93 server.listen(0, "127.0.0.1", () => resume(Effect.void));
94 }),
95 () =>
96 Effect.callback<void>((resume) => {
97 server.close(() => resume(Effect.void));
98 }),
99 );
100 const address = server.address();
101 if (address === null || typeof address === "string") return yield* Effect.die("no TCP address");
102 return {
103 baseUrl: `http://127.0.0.1:${address.port}`,
104 requests,
105 corruptChecksum: () => {
106 checksum = "0".repeat(64);
107 },
108 failNextRequests: (...statuses: Array<number>) => {
109 failures.push(...statuses);
110 },
111 };
112});
113
114it.layer(NodeServices.layer)("build-npm-launcher", (it) => {
115 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
116 "publishes a launcher that downloads, verifies, caches, and runs the release executable",
117 () =>
118 Effect.gen(function* () {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-launcher-test-" });
122 const output = yield* buildNpmLauncher({
123 version: VERSION,
124 outputDir: path.join(root, "out"),
125 });
126
127 const manifest = yield* decodeManifest(
128 yield* fs.readFileString(path.join(output.packageDir, "package.json")),
129 );
130 assert.equal(manifest.name, "t3");
131 assert.equal(manifest.version, VERSION);
132 assert.deepStrictEqual(manifest.bin, { t3: "./bin/t3.mjs" });
133 assert.isUndefined(manifest.dependencies);
134 assert.isUndefined(manifest.optionalDependencies);
135 assert.isAbove(npmReleaseDatabaseMigration(manifest) ?? 0, 0);
136
137 const installed = path.join(root, "installed");
138 yield* fs.makeDirectory(installed);
139 const unpack = yield* run("tar", ["-xzf", output.tarball, "-C", installed], {
140 cwd: root,
141 env: { PATH: (yield* HostProcess.Environment).PATH },
142 });
143 assert.equal(unpack.exitCode, 0, unpack.stderr);
144 const packageDir = path.join(installed, "package");
145
146 const release = yield* serveFakeRelease(root);
147 // regression: #32897
148 release.failNextRequests(504, 524);
149 const home = path.join(root, "home");
150 const env = {
151 PATH: (yield* HostProcess.Environment).PATH,
152 T3CODE_HOME: home,
153 T3CODE_TEST_CALLS: path.join(root, "calls"),
154 T3CODE_RELEASE_BASE_URL: release.baseUrl,
155 };
156 const launcher = path.join(packageDir, "bin/t3.mjs");
157 const first = yield* run(process.execPath, [launcher, "serve", "a b"], {
158 cwd: root,
159 env,
160 });
161 assert.equal(first.stdout.trim(), `stub serve a b launcher=${launcher}`, first.stderr);
162 assert.equal(first.exitCode, 7);
163 assert.deepStrictEqual(
164 (yield* fs.readFileString(env.T3CODE_TEST_CALLS)).trim().split("\n"),
165 ["--version|", `serve a b|${launcher}`],
166 );
167 const versionDir = path.join(home, "runtime/versions", VERSION);
168 assert.equal(
169 (yield* fs.readFileString(path.join(versionDir, ".install-complete"))).trim(),
170 VERSION,
171 );
172 assert.isTrue(yield* fs.exists(path.join(versionDir, "node_modules/native")));
173 assert.deepStrictEqual(
174 (yield* fs.readDirectory(path.join(home, "runtime/versions"))).sort(),
175 [VERSION],
176 );
177
178 const requestsAfterFirst = release.requests.length;
179 const second = yield* run(process.execPath, [launcher, "--version"], { cwd: root, env });
180 assert.equal(second.exitCode, 0);
181 assert.equal(release.requests.length, requestsAfterFirst);
182 assert.deepStrictEqual(
183 (yield* fs.readFileString(env.T3CODE_TEST_CALLS)).trim().split("\n"),
184 ["--version|", `serve a b|${launcher}`, `--version|${launcher}`],
185 );
186
187 yield* fs.writeFileString(path.join(versionDir, "t3"), "#!/t3-test-missing-loader\n");
188 for (const _attempt of [1, 2]) {
189 const cached = yield* run(process.execPath, [launcher, "serve"], { cwd: root, env });
190 assert.equal(cached.exitCode, 1, cached.stderr);
191 assert.include(cached.stderr, "could not run");
192 assert.equal(release.requests.length, requestsAfterFirst);
193 assert.equal(
194 yield* fs.readFileString(path.join(versionDir, ".install-complete")),
195 `${VERSION}\n`,
196 );
197 }
198
199 const ipcServer = path.join(home, "runtime/versions", VERSION, "t3");
200 yield* fs.writeFileString(
201 ipcServer,
202 `#!${process.execPath}\nprocess.on("SIGTERM", () => process.exit(23));\nprocess.on("message", (m) => process.send({ reply: m }));\nprocess.send({ args: process.argv.slice(2) });\n`,
203 );
204 const legacy = NodeChildProcess.fork(
205 path.join(packageDir, "dist/bin.mjs"),
206 ["__service-preflight", "a b"],
207 { env, silent: true },
208 );
209 yield* Effect.promise(async () => {
210 assert.deepStrictEqual((await NodeEvents.EventEmitter.once(legacy, "message"))[0], {
211 args: ["__service-preflight", "a b"],
212 });
213 const reply = NodeEvents.EventEmitter.once(legacy, "message");
214 legacy.send({ type: "trial-accepted" });
215 assert.deepStrictEqual((await reply)[0], { reply: { type: "trial-accepted" } });
216 const exit = NodeEvents.EventEmitter.once(legacy, "exit");
217 legacy.kill("SIGTERM");
218 assert.deepStrictEqual(await exit, [23, null]);
219 });
220
221 const raceHome = path.join(root, "race-home");
222 const launch = run(process.execPath, [launcher, "--version"], {
223 cwd: root,
224 env: { ...env, T3CODE_HOME: raceHome },
225 });
226 const [one, two] = yield* Effect.all([launch, launch], { concurrency: "unbounded" });
227 assert.equal(one.exitCode, 0, one.stderr);
228 assert.equal(two.exitCode, 0, two.stderr);
229 assert.deepStrictEqual(yield* fs.readDirectory(path.join(raceHome, "runtime/versions")), [
230 VERSION,
231 ]);
232
233 // A peer's executable and sidecar stay available after every later rename or removal,
234 // even when it publishes after our missing-marker read.
235 const staleHome = path.join(root, "stale-home");
236 const staleDir = path.join(staleHome, "runtime/versions", VERSION);
237 const peerDir = path.join(root, "peer-runtime");
238 yield* fs.makeDirectory(staleDir, { recursive: true });
239 yield* fs.writeFileString(path.join(staleDir, "t3"), "");
240 yield* fs.makeDirectory(peerDir);
241 yield* fs.writeFileString(path.join(peerDir, "t3"), '#!/bin/sh\necho "peer $*"\nexit 7\n');
242 yield* fs.chmod(path.join(peerDir, "t3"), 0o755);
243 yield* fs.writeFileString(path.join(peerDir, ".install-complete"), `${VERSION}\n`);
244 yield* fs.writeFileString(path.join(peerDir, "sidecar"), "peer sidecar");
245 const peerChecks = path.join(root, "peer-checks.txt");
246 // The peer publishes after the launcher's rename fails and it reads the marker.
247 const peerHook = path.join(root, "peer-publishes.mjs");
248 yield* fs.writeFileString(
249 peerHook,
250 `import fs from "node:fs";
251import { spawnSync } from "node:child_process";
252const dir = ${JSON.stringify(staleDir)};
253const { readFileSync, renameSync, rmSync } = fs;
254let checking = false;
255let published = false;
256const checkPeer = () => {
257 const child = spawnSync(dir + "/t3", ["--version"], { encoding: "utf8" });
258 let sidecar;
259 try {
260 sidecar = readFileSync(dir + "/sidecar", "utf8");
261 } catch (error) {
262 sidecar = error.code;
263 }
264 fs.appendFileSync(${JSON.stringify(peerChecks)},
265 [child.status, child.error?.code ?? child.stdout.trim(), sidecar].join("|") + "\\n");
266};
267fs.renameSync = (from, to) => {
268 try {
269 return renameSync(from, to);
270 } catch (error) {
271 checking ||= to === dir;
272 throw error;
273 } finally {
274 if (published) checkPeer();
275 }
276};
277fs.rmSync = (...args) => {
278 try {
279 return rmSync(...args);
280 } finally {
281 if (published) checkPeer();
282 }
283};
284fs.readFileSync = (file, ...rest) => {
285 try {
286 return readFileSync(file, ...rest);
287 } finally {
288 if (checking && file === dir + "/.install-complete") {
289 checking = false;
290 rmSync(dir, { recursive: true });
291 renameSync(${JSON.stringify(peerDir)}, dir);
292 published = true;
293 checkPeer();
294 }
295 }
296};
297`,
298 );
299 const recovered = yield* run(
300 process.execPath,
301 ["--import", peerHook, launcher, "--version"],
302 { cwd: root, env: { ...env, T3CODE_HOME: staleHome } },
303 );
304 const checks = (yield* fs.readFileString(peerChecks)).trim().split("\n");
305 assert.isAtLeast(checks.length, 2);
306 assert.deepStrictEqual([...new Set(checks)], ["7|peer --version|peer sidecar"]);
307 assert.equal(recovered.stdout.trim(), "peer --version", recovered.stderr);
308 assert.equal(recovered.exitCode, 7);
309 assert.deepStrictEqual(yield* fs.readDirectory(path.join(staleHome, "runtime/versions")), [
310 VERSION,
311 ]);
312
313 // While another launch holds the lock, a stale folder is left alone and the error says
314 // how to clear a lock left by a crash.
315 const staleOnlyHome = path.join(root, "stale-only-home");
316 const staleOnlyDir = path.join(staleOnlyHome, "runtime/versions", VERSION);
317 yield* fs.makeDirectory(staleOnlyDir, { recursive: true });
318 yield* fs.writeFileString(path.join(staleOnlyDir, "t3"), "incomplete");
319 yield* fs.makeDirectory(`${staleOnlyDir}.lock`);
320 const locked = yield* run(process.execPath, [launcher, "--version"], {
321 cwd: root,
322 env: { ...env, T3CODE_HOME: staleOnlyHome },
323 });
324 assert.equal(locked.exitCode, 1);
325 assert.include(locked.stderr, `delete ${staleOnlyDir}.lock`);
326 assert.equal(yield* fs.readFileString(path.join(staleOnlyDir, "t3")), "incomplete");
327
328 // Without a peer, the stale folder is replaced by our own download.
329 yield* fs.remove(`${staleOnlyDir}.lock`, { recursive: true });
330 const replaced = yield* run(process.execPath, [launcher, "--version"], {
331 cwd: root,
332 env: { ...env, T3CODE_HOME: staleOnlyHome },
333 });
334 assert.equal(replaced.stdout.trim(), VERSION, replaced.stderr);
335 assert.equal(replaced.exitCode, 0);
336 assert.deepStrictEqual(
337 yield* fs.readDirectory(path.join(staleOnlyHome, "runtime/versions")),
338 [VERSION],
339 );
340
341 release.corruptChecksum();
342 const corrupt = yield* run(process.execPath, [launcher, "--version"], {
343 cwd: root,
344 env: { ...env, T3CODE_HOME: path.join(root, "other-home") },
345 });
346 assert.equal(corrupt.exitCode, 1);
347 assert.include(corrupt.stderr, "checksum mismatch");
348 assert.deepStrictEqual(
349 yield* fs.readDirectory(path.join(root, "other-home/runtime/versions")),
350 [],
351 );
352 }),
353 );
354 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
355 "does not publish an unusable release",
356 () =>
357 Effect.gen(function* () {
358 const fs = yield* FileSystem.FileSystem;
359 const path = yield* Path.Path;
360 for (const executable of [
361 "#!/t3-test-missing-loader\n",
362 '#!/bin/sh\necho "runtime loader failed" >&2\nexit 42\n',
363 "#!/bin/sh\nkill -TERM $$\n",
364 '#!/bin/sh\ntrap \'echo caught > "$T3CODE_TEST_TERM"; exit 42\' TERM\necho ready > "$T3CODE_TEST_READY"\nwhile :; do :; done\n',
365 ]) {
366 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-validation-test-" });
367 const output = yield* buildNpmLauncher({
368 version: VERSION,
369 outputDir: path.join(root, "out"),
370 });
371 const release = yield* serveFakeRelease(root, executable);
372 const home = path.join(root, "home");
373 const dir = path.join(home, "runtime/versions", VERSION);
374 const probeTimeoutHook = path.join(root, "probe-timeout.mjs");
375 yield* fs.writeFileString(
376 probeTimeoutHook,
377 `import childProcess from "node:child_process";
378import { syncBuiltinESMExports } from "node:module";
379const { spawnSync } = childProcess;
380childProcess.spawnSync = (command, args, options) => spawnSync(command, args,
381 options?.timeout === undefined ? options : { ...options, timeout: 500 });
382syncBuiltinESMExports();
383`,
384 );
385 const env = {
386 PATH: (yield* HostProcess.Environment).PATH,
387 T3CODE_HOME: home,
388 T3CODE_RELEASE_BASE_URL: release.baseUrl,
389 T3CODE_TEST_READY: path.join(root, "probe-ready"),
390 T3CODE_TEST_TERM: path.join(root, "probe-term"),
391 };
392 const launcher = path.join(output.packageDir, "bin/t3.mjs");
393 const failed = yield* run(
394 process.execPath,
395 ["--import", probeTimeoutHook, launcher, "serve"],
396 { cwd: root, env },
397 );
398 assert.equal(failed.exitCode, 1, failed.stderr);
399 assert.deepStrictEqual(yield* fs.readDirectory(path.dirname(dir)), [], failed.stderr);
400 assert.include(failed.stderr, "could not run");
401 if (executable.includes("runtime loader failed")) {
402 assert.include(failed.stderr, "runtime loader failed");
403 }
404 if (executable.includes("trap")) {
405 assert.isTrue(yield* fs.exists(env.T3CODE_TEST_READY));
406 assert.isFalse(yield* fs.exists(env.T3CODE_TEST_TERM));
407 }
408 if ((yield* HostProcess.Platform) === "linux") {
409 assert.include(failed.stderr, "glibc");
410 assert.include(failed.stderr, "musl");
411 }
412 }
413 }),
414 );
415});