| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as NodePathLayer from "@effect/platform-node/NodePath"; |
| 3 | import * as ProcessRunner from "../processRunner.ts"; |
| 4 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 5 | // @effect-diagnostics nodeBuiltinImport:off - tests the same standalone script used by local and SSH hosts. |
| 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as NodeFSP from "node:fs/promises"; |
| 8 | import * as NodeOS from "node:os"; |
| 9 | import * as NodePath from "node:path"; |
| 10 | import * as NodeChildProcess from "node:child_process"; |
| 11 | import * as NodeUtil from "node:util"; |
| 12 | import { pruneLocalDeviceTools, deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; |
| 13 | |
| 14 | const exec = NodeUtil.promisify(NodeChildProcess.execFile); |
| 15 | |
| 16 | describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { |
| 17 | it("keeps current, previous, active and incomplete installs, pruning unused completed versions", async () => { |
| 18 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); |
| 19 | const name = "expo-device-hub"; |
| 20 | const directory = (version: string) => |
| 21 | flat ? NodePath.join(root, `${name}@${version}`) : NodePath.join(root, name, version); |
| 22 | try { |
| 23 | for (const version of ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0"]) { |
| 24 | await NodeFSP.mkdir(directory(version), { recursive: true }); |
| 25 | if (version === "0.5.0") continue; |
| 26 | const sentinel = NodePath.join(directory(version), ".install-complete"); |
| 27 | await NodeFSP.writeFile(sentinel, version); |
| 28 | await NodeFSP.utimes( |
| 29 | sentinel, |
| 30 | Number(version.split(".")[1]), |
| 31 | Number(version.split(".")[1]), |
| 32 | ); |
| 33 | } |
| 34 | const script = |
| 35 | deviceToolMaintenanceScript + |
| 36 | ` |
| 37 | (async () => { |
| 38 | const root = ${JSON.stringify(root)}; |
| 39 | await pruneTools(root, [['${name}', '0.6.0']], ${flat}); |
| 40 | })().catch(error => { console.error(error); process.exitCode = 1; });`; |
| 41 | await exec(process.execPath, [ |
| 42 | "-e", |
| 43 | script, |
| 44 | NodePath.join(directory("0.2.0"), "active-helper.cjs"), |
| 45 | ]); |
| 46 | await expect(NodeFSP.stat(directory("0.1.0"))).rejects.toThrow(); |
| 47 | await expect(NodeFSP.stat(directory("0.3.0"))).rejects.toThrow(); |
| 48 | for (const version of ["0.2.0", "0.4.0", "0.5.0", "0.6.0"]) |
| 49 | expect((await NodeFSP.stat(directory(version))).isDirectory()).toBe(true); |
| 50 | } finally { |
| 51 | await NodeFSP.rm(root, { recursive: true, force: true }); |
| 52 | } |
| 53 | }); |
| 54 | |
| 55 | it("keeps every install when the process scan fails", async () => { |
| 56 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-scan-")); |
| 57 | try { |
| 58 | for (const version of ["0.1.0", "0.2.0", "0.3.0"]) { |
| 59 | const dir = flat |
| 60 | ? NodePath.join(root, `expo-device-hub@${version}`) |
| 61 | : NodePath.join(root, "expo-device-hub", version); |
| 62 | await NodeFSP.mkdir(dir, { recursive: true }); |
| 63 | await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), version); |
| 64 | } |
| 65 | await exec(process.execPath, [ |
| 66 | "-e", |
| 67 | deviceToolMaintenanceScript + |
| 68 | ` |
| 69 | require('node:child_process').spawnSync = () => ({ status: 1, stdout: '' }); |
| 70 | pruneTools(${JSON.stringify(root)}, [['expo-device-hub', '0.3.0']], ${flat}).catch(() => process.exitCode = 1); |
| 71 | `, |
| 72 | ]); |
| 73 | const parent = flat ? root : NodePath.join(root, "expo-device-hub"); |
| 74 | expect((await NodeFSP.readdir(parent)).length).toBe(3); |
| 75 | } finally { |
| 76 | await NodeFSP.rm(root, { recursive: true, force: true }); |
| 77 | } |
| 78 | }); |
| 79 | |
| 80 | it("does not prune before the required version has completed installation", async () => { |
| 81 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); |
| 82 | try { |
| 83 | const dir = flat |
| 84 | ? NodePath.join(root, "expo-device-hub@0.1.0") |
| 85 | : NodePath.join(root, "expo-device-hub/0.1.0"); |
| 86 | await NodeFSP.mkdir(dir, { recursive: true }); |
| 87 | await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), "0.1.0"); |
| 88 | await exec(process.execPath, [ |
| 89 | "-e", |
| 90 | deviceToolMaintenanceScript + |
| 91 | `pruneTools(${JSON.stringify(root)}, [['expo-device-hub','0.6.0']], ${flat}).catch(() => process.exitCode = 1);`, |
| 92 | ]); |
| 93 | expect((await NodeFSP.stat(dir)).isDirectory()).toBe(true); |
| 94 | } finally { |
| 95 | await NodeFSP.rm(root, { recursive: true, force: true }); |
| 96 | } |
| 97 | }); |
| 98 | }); |
| 99 | |
| 100 | it.effect("maintenance failures retain safe context and the original process result", () => |
| 101 | Effect.gen(function* () { |
| 102 | const output = { |
| 103 | code: ChildProcessSpawner.ExitCode(1), |
| 104 | stdout: "", |
| 105 | stderr: "private child diagnostics", |
| 106 | timedOut: false, |
| 107 | stdoutTruncated: false, |
| 108 | stderrTruncated: false, |
| 109 | stdoutInvalidUtf8: false, |
| 110 | stderrInvalidUtf8: false, |
| 111 | }; |
| 112 | for (const [operation, run] of [["prune", pruneLocalDeviceTools]] as const) { |
| 113 | const error = yield* run("/tools", process.execPath, "hub").pipe( |
| 114 | Effect.provideService(ProcessRunner.ProcessRunner, { run: () => Effect.succeed(output) }), |
| 115 | Effect.flip, |
| 116 | ); |
| 117 | expect(error).toMatchObject({ |
| 118 | _tag: "DeviceToolMaintenanceError", |
| 119 | operation, |
| 120 | tool: "hub", |
| 121 | exitCode: 1, |
| 122 | cause: output, |
| 123 | }); |
| 124 | expect(error.message).toBe(`Device tool ${operation} failed for hub (exit code 1).`); |
| 125 | expect(error.message).not.toContain(output.stderr); |
| 126 | } |
| 127 | }).pipe(Effect.provide(NodePathLayer.layer)), |
| 128 | ); |
| 129 | |
| 130 | it("serializes competing maintenance processes after reclaiming a stale lock", async () => { |
| 131 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-contention-")); |
| 132 | try { |
| 133 | const lock = NodePath.join(root, ".maintenance-lock"); |
| 134 | await NodeFSP.mkdir(lock); |
| 135 | await NodeFSP.writeFile( |
| 136 | NodePath.join(lock, "stale-owner.json"), |
| 137 | JSON.stringify({ pid: 2147483647, identity: "dead" }), |
| 138 | ); |
| 139 | const script = |
| 140 | deviceToolMaintenanceScript + |
| 141 | ` |
| 142 | (async () => { |
| 143 | const root = ${JSON.stringify(root)}; |
| 144 | const marker = maintenancePath.join(root, 'critical-section'); |
| 145 | for (let attempt = 0; attempt < 8; attempt++) await withToolMaintenance(root, () => { |
| 146 | maintenanceFs.writeFileSync(marker, String(process.pid), { flag: 'wx' }); |
| 147 | for (let check = 0; check < 100; check++) { |
| 148 | if (maintenanceFs.readFileSync(marker, 'utf8') !== String(process.pid)) throw Error('Overlapping maintenance'); |
| 149 | } |
| 150 | maintenanceFs.unlinkSync(marker); |
| 151 | }); |
| 152 | })().catch(error => { console.error(error); process.exitCode = 1; });`; |
| 153 | await Promise.all(Array.from({ length: 6 }, () => exec(process.execPath, ["-e", script]))); |
| 154 | await expect(NodeFSP.stat(lock)).rejects.toThrow(); |
| 155 | expect(await NodeFSP.readdir(root)).toEqual([]); |
| 156 | } finally { |
| 157 | await NodeFSP.rm(root, { recursive: true, force: true }); |
| 158 | } |
| 159 | }); |