Public: anyone can read this; the crew can change it.
An app that names Rust gets cargo on its install, test and run steps from a pinned cached toolchain, and a Rust hello-world app serves #10592
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10525-an-app-that-names-rust-gets-cargo-on-its → main 1 commit fork
9 files
changed +299
−7
docs/APPS.md +2 −2
| @@ -553,8 +553,8 @@ Only `text/html` responses the app sends uncompressed with a `Content-Length` | ||
| 553 | 553 | `src/apps/toolchain.ts`. An unknown toolchain, a version that is not |
| 554 | 554 | `major.minor`, or one this forge does not provide fails the deploy in one |
| 555 | 555 | line ("rust 1.70 is not provided here; this forge has rust 1.82"). Without the |
| 556 | field nothing changes. For now the field is only read and checked; nothing is | |
| 557 | installed from it yet. | |
| 556 | field nothing changes. The pinned toolchain is put on `PATH` for the app's install, test and run | |
| 557 | steps only ([](features/toolchains.md)). | |
| 558 | 558 | |
| 559 | 559 | `adapter`, at the top level, says the app is an adapter the forge pulls |
| 560 | 560 | from on a schedule (#437): `{ "adapter": { "schedule": { "every": 900 } } }`. |
docs/features/toolchains.md added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | # Pinned toolchains (rust) | |
| 2 | ||
| 3 | An app names a toolchain in `forge.json`: `{ "toolchain": { "rust": "1.82" } }`. | |
| 4 | Its **install** and **test** steps and its **run** command then see `cargo` and | |
| 5 | `rustc` on `PATH`, with `CARGO_HOME` and `RUSTUP_HOME` set. Apps that do not | |
| 6 | name it see none of this. | |
| 7 | ||
| 8 | ## Where it lives (and why) | |
| 9 | ||
| 10 | A directory on the host, `$FORGE_TOOLCHAINS/rust/<version>/`, mounted read-only | |
| 11 | in the cell. Chosen over a nix store path because it needs no daemon and no new | |
| 12 | dependency: an admin unpacks a release once, and every deploy only reads it. | |
| 13 | Nothing is downloaded per build: resolving a pin is a file check | |
| 14 | (`toolchainUse`, `src/apps/toolchain.ts`), and the test asserts two deploys make | |
| 15 | no fetch. `CARGO_HOME` is a writable `.cargo` in the environment's home (the | |
| 16 | toolchain stays read-only); `RUSTUP_HOME` points into the toolchain. | |
| 17 | The default install step is `cargo build --release --offline`; a dependency | |
| 18 | fetch is the app's own, through its declared `egress`. | |
| 19 | ||
| 20 | Layout: `bin/` (cargo, rustc, direct binaries, not rustup proxies), `rustup/`. | |
| 21 | ||
| 22 | ## An admin adds a Rust version | |
| 23 | ||
| 24 | 1. Unpack a standalone Rust release (`rust-<ver>-x86_64-unknown-linux-gnu`) and | |
| 25 | run its `install.sh --prefix=$FORGE_TOOLCHAINS/rust/<ver>`; `bin/cargo` must | |
| 26 | exist there. Make it read-only for the forge's user. | |
| 27 | 2. Add the `major.minor` string to `TOOLCHAINS.rust` in `src/apps/toolchain.ts`. | |
| 28 | 3. A pin the host lacks fails the deploy at install: "rust 1.82 is pinned but | |
| 29 | not installed on this host". | |
| 30 | ||
| 31 | The fixture `test/fixtures/rust-hello/` is the minimal app; its test is skipped | |
| 32 | by name where `cargo` is absent. |
src/apps/process.ts +19 −5
| @@ -20,6 +20,7 @@ import { isFeatureVarName } from "./feature_env.ts"; | ||
| 20 | 20 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 21 | 21 | import { spellings, stripForgeDirs } from "../runs/paths.ts"; |
| 22 | 22 | import { seconds } from "./timing.ts"; |
| 23 | import { toolchainUse, type ToolchainUse } from "./toolchain.ts"; | |
| 23 | 24 | import { notFoundHint, shellCommand } from "./run_command.ts"; |
| 24 | 25 | import { cellInstall, meteredInstall, prefillInstallCache, withLock } from "../checks/install.ts"; |
| 25 | 26 | import { |
| @@ -175,6 +176,8 @@ export function appEnv(input: { | ||
| 175 | 176 | feature?: Record<string, string> | undefined; |
| 176 | 177 | /** A read-only forge token for this process (`apps/token.ts`). */ |
| 177 | 178 | forgeToken?: string | undefined; |
| 179 | /** A pinned toolchain's PATH and homes (`apps/toolchain.ts`). */ | |
| 180 | toolchain?: Record<string, string> | undefined; | |
| 178 | 181 | }): Record<string, string> { |
| 179 | 182 | const pull = envPull(input.env); |
| 180 | 183 | // The operator's variables go underneath everything the forge names, so |
| @@ -185,6 +188,7 @@ export function appEnv(input: { | ||
| 185 | 188 | ...operatorVars(input.vars), |
| 186 | 189 | ...featureVars(input.feature), |
| 187 | 190 | ...childEnv({ |
| 191 | ...input.toolchain, | |
| 188 | 192 | PORT: String(input.port), |
| 189 | 193 | HOST: "127.0.0.1", |
| 190 | 194 | NODE_ENV: "production", |
| @@ -399,10 +403,11 @@ export async function runStep( | ||
| 399 | 403 | } |
| 400 | 404 | |
| 401 | 405 | /** The cell a step of this deploy runs in: the checkout and the home, the registry and what the app declared. */ |
| 402 | export function stepCell(input: StartInput): CellSpec | undefined { | |
| 406 | export function stepCell(input: StartInput, use?: ToolchainUse): CellSpec | undefined { | |
| 403 | 407 | const base = input.cell; |
| 404 | 408 | if (base === undefined) return undefined; |
| 405 | 409 | return { |
| 410 | ...(use === undefined ? {} : { reveal: use.reveal }), | |
| 406 | 411 | dir: input.dir, |
| 407 | 412 | writable: [], |
| 408 | 413 | home: base.home, |
| @@ -443,9 +448,10 @@ export function installPlan( | ||
| 443 | 448 | input: StartInput, |
| 444 | 449 | env: Record<string, string>, |
| 445 | 450 | filled: boolean, |
| 451 | use?: ToolchainUse, | |
| 446 | 452 | ): InstallPlan { |
| 447 | 453 | const cache = input.installCache; |
| 448 | const cell = stepCell(input); | |
| 454 | const cell = stepCell(input, use); | |
| 449 | 455 | if (cache === undefined || !filled) return { command, env, cell, lock: undefined }; |
| 450 | 456 | if (cell !== undefined) |
| 451 | 457 | return { ...cellInstall(command, env, cache), cell: { ...cell, reveal: [...(cell.reveal ?? []), cache] }, lock: undefined }; |
| @@ -461,6 +467,7 @@ async function install( | ||
| 461 | 467 | command: string, |
| 462 | 468 | input: StartInput, |
| 463 | 469 | env: Record<string, string>, |
| 470 | use?: ToolchainUse, | |
| 464 | 471 | ): Promise<Result<void, string>> { |
| 465 | 472 | let filled = false; |
| 466 | 473 | if (input.installCache !== undefined) { |
| @@ -475,7 +482,7 @@ async function install( | ||
| 475 | 482 | if (fill.ok) filled = fill.value; |
| 476 | 483 | else input.log(`cache not filled: ${fill.error}`); |
| 477 | 484 | } |
| 478 | const plan = installPlan(command, input, env, filled); | |
| 485 | const plan = installPlan(command, input, env, filled, use); | |
| 479 | 486 | const step = (): Promise<Result<void, string>> => |
| 480 | 487 | runStep("install", plan.command, { cwd: input.dir, env: plan.env, log: input.log, signal: input.signal, cell: plan.cell }); |
| 481 | 488 | if (plan.lock !== undefined) { |
| @@ -499,7 +506,12 @@ export async function startProcess( | ||
| 499 | 506 | input: StartInput, |
| 500 | 507 | ): Promise<Result<StartedProcess, StartError>> { |
| 501 | 508 | const { manifest, log } = input; |
| 509 | const scratch = input.cell?.home ?? input.dir; | |
| 510 | const used = toolchainUse(manifest.toolchain, scratch); | |
| 511 | if (!used.ok) return err({ stage: "install", message: used.error }); | |
| 512 | const use = used.value; | |
| 502 | 513 | const stepEnv = childEnv({ |
| 514 | ...use?.env, | |
| 503 | 515 | CI: "1", |
| 504 | 516 | FORGE_APP_ENV: input.env === "production" ? "production" : "preview", |
| 505 | 517 | // A mirror the forge itself is pointed at is the one the fill and the |
| @@ -508,7 +520,7 @@ export async function startProcess( | ||
| 508 | 520 | }); |
| 509 | 521 | |
| 510 | 522 | if (manifest.install !== null && input.warm !== true) { |
| 511 | const installed = await install(manifest.install, input, stepEnv); | |
| 523 | const installed = await install(manifest.install, input, stepEnv, use); | |
| 512 | 524 | if (!installed.ok) |
| 513 | 525 | return err({ stage: "install", message: installed.error }); |
| 514 | 526 | } |
| @@ -532,7 +544,7 @@ export async function startProcess( | ||
| 532 | 544 | env: stepEnv, |
| 533 | 545 | log, |
| 534 | 546 | signal: testAbort.signal, |
| 535 | cell: stepCell(input), | |
| 547 | cell: stepCell(input, use), | |
| 536 | 548 | }); |
| 537 | 549 | |
| 538 | 550 | // The port is chosen, then released, then bound by the app: another |
| @@ -552,6 +564,7 @@ export async function startProcess( | ||
| 552 | 564 | writable: [input.dataDir], |
| 553 | 565 | home: input.cell.home, |
| 554 | 566 | hide: input.cell.hide, |
| 567 | ...(use === undefined ? {} : { reveal: use.reveal }), | |
| 555 | 568 | egress: manifest.egress ?? [], |
| 556 | 569 | granted: input.cell.granted, |
| 557 | 570 | reach: [input.forgeUrl], |
| @@ -565,6 +578,7 @@ export async function startProcess( | ||
| 565 | 578 | ); |
| 566 | 579 | const command = shellCommand(manifest.run); |
| 567 | 580 | const env = appEnv({ |
| 581 | toolchain: use?.env, | |
| 568 | 582 | port, |
| 569 | 583 | dataDir: input.dataDir, |
| 570 | 584 | env: input.env, |
src/apps/toolchain.ts +45
| @@ -43,3 +43,48 @@ export function parseToolchain(value: unknown): Result<ToolchainPin | undefined, | ||
| 43 | 43 | } |
| 44 | 44 | return ok(Object.keys(pin).length === 0 ? undefined : pin); |
| 45 | 45 | } |
| 46 | ||
| 47 | /** | |
| 48 | * Where the host keeps its toolchains: `$FORGE_TOOLCHAINS/<name>/<version>/`, | |
| 49 | * read-only, laid out as `bin/` (cargo, rustc), `cargo/` and `rustup/`. | |
| 50 | * An admin adds a version by unpacking it there (docs/features/toolchains.md); | |
| 51 | * nothing is ever fetched at deploy time. | |
| 52 | */ | |
| 53 | export function toolchainRoot(): string | undefined { | |
| 54 | const root = process.env["FORGE_TOOLCHAINS"]; | |
| 55 | return root === undefined || root === "" ? undefined : root; | |
| 56 | } | |
| 57 | ||
| 58 | /** What a pinned toolchain adds to a step or the app: env, and the directory a cell must show. */ | |
| 59 | export interface ToolchainUse { | |
| 60 | readonly env: Record<string, string>; | |
| 61 | readonly reveal: readonly string[]; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * The environment for a pin, from files already on the host. Synchronous | |
| 66 | * and offline by construction: it only checks the directory is there. | |
| 67 | * `scratch` is a writable place for CARGO_HOME (the registry cache and | |
| 68 | * locks cargo writes), since the toolchain itself is read-only. | |
| 69 | */ | |
| 70 | export function toolchainUse( | |
| 71 | pin: ToolchainPin | undefined, | |
| 72 | scratch: string, | |
| 73 | root: string | undefined = toolchainRoot(), | |
| 74 | ): Result<ToolchainUse | undefined, string> { | |
| 75 | const version = pin?.rust; | |
| 76 | if (version === undefined) return ok(undefined); | |
| 77 | const dir = root === undefined ? undefined : `${root.replace(/\/+$/, "")}/rust/${version}`; | |
| 78 | if (dir === undefined || !Bun.file(`${dir}/bin/cargo`).size) | |
| 79 | return err(`rust ${version} is pinned but not installed on this host`); | |
| 80 | return ok({ | |
| 81 | env: { | |
| 82 | PATH: `${dir}/bin:${process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"}`, | |
| 83 | CARGO_HOME: `${scratch.replace(/\/+$/, "")}/.cargo`, | |
| 84 | RUSTUP_HOME: `${dir}/rustup`, | |
| 85 | // Never let a proxy binary go and fetch another toolchain. | |
| 86 | RUSTUP_TOOLCHAIN: version, | |
| 87 | }, | |
| 88 | reveal: [dir], | |
| 89 | }); | |
| 90 | } |
test/fixtures/rust-hello/.gitignore added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | target/ |
test/fixtures/rust-hello/Cargo.toml added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | [package] | |
| 2 | name = "rust-hello" | |
| 3 | version = "0.1.0" | |
| 4 | edition = "2021" | |
| 5 | ||
| 6 | [dependencies] |
test/fixtures/rust-hello/forge.json added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | { | |
| 2 | "toolchain": { "rust": "1.82" }, | |
| 3 | "app": { | |
| 4 | "install": "cargo build --release --offline", | |
| 5 | "run": "./target/release/rust-hello", | |
| 6 | "health": "/", | |
| 7 | "ready": 30 | |
| 8 | } | |
| 9 | } |
test/fixtures/rust-hello/src/main.rs added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | use std::io::{Read, Write}; | |
| 2 | use std::net::TcpListener; | |
| 3 | ||
| 4 | fn main() { | |
| 5 | let port = std::env::var("PORT").expect("PORT"); | |
| 6 | let listener = TcpListener::bind(format!("127.0.0.1:{port}")).expect("bind"); | |
| 7 | for stream in listener.incoming().flatten() { | |
| 8 | let mut stream = stream; | |
| 9 | let mut buf = [0u8; 1024]; | |
| 10 | let _ = stream.read(&mut buf); | |
| 11 | let body = "hello from rust\n"; | |
| 12 | let _ = write!( | |
| 13 | stream, | |
| 14 | "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", | |
| 15 | body.len(), | |
| 16 | body | |
| 17 | ); | |
| 18 | } | |
| 19 | } |
test/toolchain-rust.test.ts added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 2 | import { chmod, cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { parseManifest } from "../src/apps/manifest.ts"; | |
| 6 | import { startProcess } from "../src/apps/process.ts"; | |
| 7 | import { toolchainUse } from "../src/apps/toolchain.ts"; | |
| 8 | ||
| 9 | const dirs: string[] = []; | |
| 10 | const tmp = async (): Promise<string> => { | |
| 11 | const d = await mkdtemp(join(tmpdir(), "forge-rust-")); | |
| 12 | dirs.push(d); | |
| 13 | return d; | |
| 14 | }; | |
| 15 | afterEach(async () => { | |
| 16 | for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }); | |
| 17 | }); | |
| 18 | ||
| 19 | const start = (dir: string, json: string, lines: string[]) => { | |
| 20 | const m = parseManifest(json, undefined); | |
| 21 | if (!m.ok) throw new Error(JSON.stringify(m.error)); | |
| 22 | return startProcess({ | |
| 23 | dir, | |
| 24 | dataDir: dir, | |
| 25 | manifest: m.value, | |
| 26 | env: "production", | |
| 27 | url: "http://app.test", | |
| 28 | commitOid: "0".repeat(40), | |
| 29 | forgeUrl: "http://forge.test", | |
| 30 | identityKey: "key", | |
| 31 | log: (l) => void lines.push(l), | |
| 32 | signal: new AbortController().signal, | |
| 33 | }); | |
| 34 | }; | |
| 35 | ||
| 36 | /** A toolchain root whose `cargo` is a script that says where it was told to live. */ | |
| 37 | async function fakeRoot(): Promise<string> { | |
| 38 | const root = await tmp(); | |
| 39 | const bin = join(root, "rust", "1.82", "bin"); | |
| 40 | await mkdir(bin, { recursive: true }); | |
| 41 | await writeFile( | |
| 42 | join(bin, "cargo"), | |
| 43 | '#!/bin/sh\necho "cargo CARGO_HOME=$CARGO_HOME RUSTUP_HOME=$RUSTUP_HOME"\n', | |
| 44 | ); | |
| 45 | await chmod(join(bin, "cargo"), 0o755); | |
| 46 | return root; | |
| 47 | } | |
| 48 | ||
| 49 | describe("a pinned rust toolchain", () => { | |
| 50 | test("is put on PATH with CARGO_HOME and RUSTUP_HOME, read-only dir revealed", async () => { | |
| 51 | const root = await fakeRoot(); | |
| 52 | const r = toolchainUse({ rust: "1.82" }, "/scratch", root); | |
| 53 | expect(r.ok && r.value?.env["PATH"]).toStartWith(join(root, "rust", "1.82", "bin") + ":"); | |
| 54 | expect(r.ok && r.value?.env["CARGO_HOME"]).toBe("/scratch/.cargo"); | |
| 55 | expect(r.ok && r.value?.env["RUSTUP_HOME"]).toBe(join(root, "rust", "1.82", "rustup")); | |
| 56 | expect(r.ok && r.value?.reveal).toEqual([join(root, "rust", "1.82")]); | |
| 57 | }); | |
| 58 | ||
| 59 | test("no pin adds nothing; a pin the host lacks is one plain line", async () => { | |
| 60 | expect(toolchainUse(undefined, "/s", "/nowhere")).toEqual({ ok: true, value: undefined }); | |
| 61 | const r = toolchainUse({ rust: "1.82" }, "/s", "/nowhere"); | |
| 62 | expect(!r.ok && r.error).toBe("rust 1.82 is pinned but not installed on this host"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("install and run see cargo only when the app names rust", async () => { | |
| 66 | const root = await fakeRoot(); | |
| 67 | const old = process.env["FORGE_TOOLCHAINS"]; | |
| 68 | process.env["FORGE_TOOLCHAINS"] = root; | |
| 69 | const realFetch = globalThis.fetch; | |
| 70 | let fetched = 0; | |
| 71 | // Only the loopback readiness probe may use fetch; anything else is a download. | |
| 72 | globalThis.fetch = ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => { | |
| 73 | if (String(input).startsWith("http://127.0.0.1:")) return realFetch(input, init); | |
| 74 | fetched++; | |
| 75 | throw new Error("no network"); | |
| 76 | }) as unknown as typeof fetch; | |
| 77 | try { | |
| 78 | const server = "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch:()=>new Response('ok')})"; | |
| 79 | for (let i = 0; i < 2; i++) { | |
| 80 | const dir = await tmp(); | |
| 81 | await writeFile(join(dir, "server.ts"), server); | |
| 82 | const lines: string[] = []; | |
| 83 | const s = await start( | |
| 84 | dir, | |
| 85 | JSON.stringify({ | |
| 86 | toolchain: { rust: "1.82" }, | |
| 87 | app: { install: "cargo > out.txt", run: "bun server.ts", health: "/", ready: 8 }, | |
| 88 | }), | |
| 89 | lines, | |
| 90 | ); | |
| 91 | expect(s.ok ? "" : s.error.message + lines.join("\n")).toBe(""); | |
| 92 | if (!s.ok) return; | |
| 93 | await s.value.handle.stop(); | |
| 94 | expect(await readFile(join(dir, "out.txt"), "utf8")).toContain("RUSTUP_HOME="); | |
| 95 | } | |
| 96 | expect(fetched).toBe(0); | |
| 97 | ||
| 98 | // An app that does not name rust sees no cargo. | |
| 99 | const dir = await tmp(); | |
| 100 | await writeFile(join(dir, "server.ts"), server); | |
| 101 | const s = await start( | |
| 102 | dir, | |
| 103 | JSON.stringify({ app: { install: "echo $PATH:$CARGO_HOME > out.txt", run: "bun server.ts", health: "/", ready: 8 } }), | |
| 104 | [], | |
| 105 | ); | |
| 106 | expect(s.ok).toBe(true); | |
| 107 | if (!s.ok) return; | |
| 108 | await s.value.handle.stop(); | |
| 109 | expect(await readFile(join(dir, "out.txt"), "utf8")).not.toContain(root); | |
| 110 | } finally { | |
| 111 | globalThis.fetch = realFetch; | |
| 112 | if (old === undefined) delete process.env["FORGE_TOOLCHAINS"]; | |
| 113 | else process.env["FORGE_TOOLCHAINS"] = old; | |
| 114 | } | |
| 115 | }, 60_000); | |
| 116 | ||
| 117 | test("an unavailable pin fails the install stage in one line", async () => { | |
| 118 | const old = process.env["FORGE_TOOLCHAINS"]; | |
| 119 | delete process.env["FORGE_TOOLCHAINS"]; | |
| 120 | try { | |
| 121 | const dir = await tmp(); | |
| 122 | const s = await start(dir, JSON.stringify({ toolchain: { rust: "1.82" }, app: { run: "true" } }), []); | |
| 123 | expect(!s.ok && s.error.stage).toBe("install"); | |
| 124 | expect(!s.ok && s.error.message).toContain("not installed on this host"); | |
| 125 | } finally { | |
| 126 | if (old !== undefined) process.env["FORGE_TOOLCHAINS"] = old; | |
| 127 | } | |
| 128 | }); | |
| 129 | }); | |
| 130 | ||
| 131 | // The hello-world needs a real cargo; where the check host has none, this is | |
| 132 | // reported as skipped by name, never passed. | |
| 133 | const sysroot = (() => { | |
| 134 | if (Bun.which("cargo") === null || Bun.which("rustc") === null) return undefined; | |
| 135 | const p = Bun.spawnSync(["rustc", "--print", "sysroot"]); | |
| 136 | return p.exitCode === 0 ? p.stdout.toString().trim() : undefined; | |
| 137 | })(); | |
| 138 | ||
| 139 | describe("rust hello-world fixture", () => { | |
| 140 | (sysroot === undefined ? test.skip : test)( | |
| 141 | "deploys with cargo build in install and answers /", | |
| 142 | async () => { | |
| 143 | const root = await tmp(); | |
| 144 | const version = join(root, "rust", "1.82"); | |
| 145 | await mkdir(version, { recursive: true }); | |
| 146 | await symlink(join(sysroot ?? "", "bin"), join(version, "bin")); | |
| 147 | const old = process.env["FORGE_TOOLCHAINS"]; | |
| 148 | process.env["FORGE_TOOLCHAINS"] = root; | |
| 149 | try { | |
| 150 | const dir = await tmp(); | |
| 151 | await cp(join(import.meta.dir, "fixtures", "rust-hello"), dir, { recursive: true }); | |
| 152 | const lines: string[] = []; | |
| 153 | const json = JSON.parse(await readFile(join(dir, "forge.json"), "utf8")); | |
| 154 | const s = await start(dir, JSON.stringify(json), lines); | |
| 155 | if (!s.ok) throw new Error(`${s.error.message}\n${lines.join("\n")}`); | |
| 156 | const res = await fetch(`http://127.0.0.1:${s.value.handle.port}/`); | |
| 157 | expect(await res.text()).toBe("hello from rust\n"); | |
| 158 | await s.value.handle.stop(); | |
| 159 | } finally { | |
| 160 | if (old === undefined) delete process.env["FORGE_TOOLCHAINS"]; | |
| 161 | else process.env["FORGE_TOOLCHAINS"] = old; | |
| 162 | } | |
| 163 | }, | |
| 164 | 120_000, | |
| 165 | ); | |
| 166 | }); |