Public: anyone can read this; the crew can change it.
/capacity and MCP `capacity` say whether this forge can swap itself, and why not: tcp_migrate_req, SSH held, or FORGE_SELF_SWAP off #8403
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8383-capacity-and-mcp-capacity-say-whether-th → main 3 commits fork
8 files
changed +253
−10
docs/features/ship-itself.md +2
| @@ -42,3 +42,5 @@ A child needs `FORGE_REPO`, `FORGE_SELF_SWAP=1`, `FORGE_RELEASE_KEEPER=swap`. A | ||
| 42 | 42 | The drill, reading `/releases` after each step (version, landed to live, probes, seconds dark): (1) land a pull and wait for the cut; (2) it swaps and verifies; (3) land a pull that boots but fails the verify (`/healthz` answers 500) and watch it swap back by name and hold; (4) restart the pod and see it return on the last good tree (`release/good.json`). The four dark-second figures go into the pull that proves it. |
| 43 | 43 | |
| 44 | 44 | **`tcp_migrate_req`.** Both processes bind the port with SO_REUSEPORT; when the old closes its listener the kernel resets what was queued on it unless `tcp_migrate_req=1` moves those connections to the other listener (Linux 5.14+). Without it `swapBlocked` refuses the cut and the release goes out by restart. The setting is per network namespace; a pod gets it through `securityContext.sysctls` (the kubelet needs `--allowed-unsafe-sysctls=net.ipv4.tcp_migrate_req`) or a node-level setting. This is not verified here. |
| 45 | ||
| 46 | **To check a child, open its /capacity.** The admin page's Self-swap row says `ready` or why not (the SSH terminal held, `tcp_migrate_req` off, or `FORGE_SELF_SWAP` off), whether `FORGE_SELF_SWAP` is set, and the kernel's `tcp_migrate_req` as read: `1`, `0` or `unreadable`. The terminal's Capacity screen shows the same row, and the MCP `capacity` tool carries it as `selfSwap: { enabled, migrateReq, blocked }` (#8383). |
src/actions/capacity.ts +6 −1
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | } from "../core/capacity.ts"; |
| 25 | 25 | import { egressTotals, npmBytesLastHour, npmTopRepoLastHour, type EgressTotal } from "../apps/egress.ts"; |
| 26 | 26 | import { getRepoById } from "../core/repos.ts"; |
| 27 | import { selfSwapState, type SelfSwap } from "../release/swap.ts"; | |
| 27 | 28 | import { passkeyIds } from "../core/passkeys.ts"; |
| 28 | 29 | import type { User } from "../core/users.ts"; |
| 29 | 30 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -100,7 +101,10 @@ export function changedOf(db: Database, key: CapacityKey): { changed?: { at: str | ||
| 100 | 101 | export function listCapacity( |
| 101 | 102 | app: App, |
| 102 | 103 | actor: User | undefined, |
| 103 | ): Result<{ settings: CapacityRow[]; changes: CapacityChange[]; egress: EgressHour }, "not_found"> { | |
| 104 | ): Result< | |
| 105 | { settings: CapacityRow[]; changes: CapacityChange[]; egress: EgressHour; selfSwap: SelfSwap }, | |
| 106 | "not_found" | |
| 107 | > { | |
| 104 | 108 | const who = admin(actor); |
| 105 | 109 | if (!who.ok) return who; |
| 106 | 110 | return ok({ |
| @@ -116,6 +120,7 @@ export function listCapacity( | ||
| 116 | 120 | })), |
| 117 | 121 | changes: capacityChanges(app.db), |
| 118 | 122 | egress: egressHour(app.db), |
| 123 | selfSwap: selfSwapState(app.config.selfSwap === true), | |
| 119 | 124 | }); |
| 120 | 125 | } |
| 121 | 126 |
src/http/mcp/tools/capacity.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "capacity", |
| 8 | doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", | |
| 8 | doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`, and `selfSwap: { enabled, migrateReq, blocked }`: whether `FORGE_SELF_SWAP=1`, the kernel's `tcp_migrate_req` (`1`, `0`, `unreadable`), and why this forge cannot swap itself, null when it can; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", | |
| 9 | 9 | description: |
| 10 | 10 | "Forge capacity and guards, live. With `key` and `value` (\"\" restores default), plans.", |
| 11 | 11 | inputSchema: { |
src/release/swap.ts +28 −3
| @@ -145,14 +145,39 @@ const MIGRATE_SYSCTL = "/proc/sys/net/ipv4/tcp_migrate_req"; | ||
| 145 | 145 | * the swap would fail requests. `handover.migrate` stands in for the file in tests. |
| 146 | 146 | */ |
| 147 | 147 | export function migratesQueuedConnections(): boolean { |
| 148 | if (handover.migrate !== undefined) return handover.migrate; | |
| 148 | return migrateReq() === "1"; | |
| 149 | } | |
| 150 | ||
| 151 | /** The kernel's `tcp_migrate_req` as read: `1`, `0` (or any other value), or `unreadable`. */ | |
| 152 | export function migrateReq(): "1" | "0" | "unreadable" { | |
| 153 | if (handover.migrate !== undefined) return handover.migrate ? "1" : "0"; | |
| 149 | 154 | try { |
| 150 | return readFileSync(MIGRATE_SYSCTL, "utf8").trim() === "1"; | |
| 155 | return readFileSync(MIGRATE_SYSCTL, "utf8").trim() === "1" ? "1" : "0"; | |
| 151 | 156 | } catch { |
| 152 | return false; | |
| 157 | return "unreadable"; | |
| 153 | 158 | } |
| 154 | 159 | } |
| 155 | 160 | |
| 161 | export interface SelfSwap { | |
| 162 | /** Whether `FORGE_SELF_SWAP=1` was set at boot. */ | |
| 163 | readonly enabled: boolean; | |
| 164 | readonly migrateReq: "1" | "0" | "unreadable"; | |
| 165 | /** Why this forge cannot swap itself; null when it can. */ | |
| 166 | readonly blocked: string | null; | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Whether this forge can swap itself, as plain data for /capacity and the | |
| 171 | * MCP `capacity` tool (#8383): what `beginSwap` would refuse, and why, before | |
| 172 | * a landing is refused for it. `enabled` is the app's `selfSwap`. | |
| 173 | */ | |
| 174 | export function selfSwapState(enabled: boolean): SelfSwap { | |
| 175 | const blocked = | |
| 176 | swapBlocked() ?? | |
| 177 | (enabled ? null : "FORGE_SELF_SWAP is off, so a landed release waits for a restart; set FORGE_SELF_SWAP=1 to swap"); | |
| 178 | return { enabled, migrateReq: migrateReq(), blocked }; | |
| 179 | } | |
| 180 | ||
| 156 | 181 | let underWay = false; |
| 157 | 182 | |
| 158 | 183 | /** |
src/tui/screens/capacity.ts +18 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * through the same actions as MCP `capacity`. The actions decide who may. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import type { SelfSwap } from "../../release/swap.ts"; | |
| 8 | 9 | import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; |
| 9 | 10 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| 10 | 11 | import { CAPACITY, validCapacity, type CapacityKey } from "../../core/capacity.ts"; |
| @@ -34,6 +35,16 @@ const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_ | ||
| 34 | 35 | ? SETTING_WORDS.outOfRange |
| 35 | 36 | : "no such capacity setting"; |
| 36 | 37 | |
| 38 | /** Whether this forge can swap itself (#8383), as /settings/capacity says it. */ | |
| 39 | export function selfSwapRow(s: SelfSwap): ListRow { | |
| 40 | return { | |
| 41 | title: "Self-swap", | |
| 42 | tone: s.blocked === null ? "success" : "attn", | |
| 43 | meta: `${s.blocked === null ? "ready" : "blocked"} · FORGE_SELF_SWAP ${s.enabled ? "set" : "off"} · tcp_migrate_req ${s.migrateReq}`, | |
| 44 | ...(s.blocked === null ? {} : { below: [s.blocked] }), | |
| 45 | }; | |
| 46 | } | |
| 47 | ||
| 37 | 48 | const shown = (v: string): string => (v === "" ? "auto" : v); |
| 38 | 49 | |
| 39 | 50 | export function capacityScreen(f: Session, ctx: Ctx): Screen { |
| @@ -42,11 +53,16 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen { | ||
| 42 | 53 | const listed = listCapacity(f.app, f.user); |
| 43 | 54 | return listed.ok ? listed.value.settings : []; |
| 44 | 55 | }; |
| 45 | const lines = (): ListRow[] => | |
| 46 | rows().map((r) => ({ | |
| 56 | const lines = (): ListRow[] => { | |
| 57 | const listed = listCapacity(f.app, f.user); | |
| 58 | if (!listed.ok) return []; | |
| 59 | const guards = listed.value.settings.map((r) => ({ | |
| 47 | 60 | title: `${r.label} (${r.key})`, |
| 48 | 61 | meta: `${shown(r.value)} · ${r.source} · takes ${boundsOf(r.key)}${r.source === "setting" ? ` · default ${shown(r.default)}` : ""}`, |
| 49 | 62 | })); |
| 63 | // After the guards, so the cursor (bounded by the settings) never lands on it. | |
| 64 | return [...guards, selfSwapRow(listed.value.selfSwap)]; | |
| 65 | }; | |
| 50 | 66 | |
| 51 | 67 | const plan = (row: CapacityRow, typed: string): void => { |
| 52 | 68 | const to = typed === "" ? row.default : typed; |
src/web/pages/capacity.ts +24 −3
| @@ -16,7 +16,7 @@ import { notFoundPage, signInFirst } from "../chrome.ts"; | ||
| 16 | 16 | import { field, readForm } from "../form.ts"; |
| 17 | 17 | import { html, when } from "../html.ts"; |
| 18 | 18 | import { flashTo } from "../layout.ts"; |
| 19 | import { btn, card, icon, row, time } from "../ui.ts"; | |
| 19 | import { badge, btn, card, icon, row, time } from "../ui.ts"; | |
| 20 | 20 | import { usageShell } from "./usage-tabs.ts"; |
| 21 | 21 | |
| 22 | 22 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -76,7 +76,7 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 76 | 76 | listCapacity(ctx.app, ctx.auth.user), |
| 77 | 77 | ); |
| 78 | 78 | if (!listed.ok) return notFoundPage(ctx); |
| 79 | const { settings, changes, egress } = listed.value; | |
| 79 | const { settings, changes, egress, selfSwap } = listed.value; | |
| 80 | 80 | const errs = timePhase(ctx.request, "errors", () => |
| 81 | 81 | ctx.app.signals.errors.recent(10), |
| 82 | 82 | ); |
| @@ -193,7 +193,28 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 193 | 193 | pad: false, |
| 194 | 194 | }, |
| 195 | 195 | ); |
| 196 | const body = html`${happened}${connections}${when( | |
| 196 | // Whether a landing here can swap the forge in place (#8383): the reason, before one is refused. | |
| 197 | const swap = card( | |
| 198 | html`<ul class="list"> | |
| 199 | ${row({ | |
| 200 | id: "self-swap", | |
| 201 | title: "Self-swap", | |
| 202 | meta: html`${selfSwap.blocked ?? "A landed release of the forge takes over the port in place."} | |
| 203 | · <span class="mono">FORGE_SELF_SWAP</span> ${selfSwap.enabled ? "is set" : "is off"} | |
| 204 | · <span class="mono">net.ipv4.tcp_migrate_req</span> reads | |
| 205 | <span class="mono">${selfSwap.migrateReq}</span>`, | |
| 206 | trail: badge(selfSwap.blocked === null ? "ready" : "blocked", { | |
| 207 | tone: selfSwap.blocked === null ? "success" : "attn", | |
| 208 | }), | |
| 209 | })} | |
| 210 | </ul>`, | |
| 211 | { | |
| 212 | head: html`${icon("activity")} | |
| 213 | <h2>Self-swap</h2>`, | |
| 214 | pad: false, | |
| 215 | }, | |
| 216 | ); | |
| 217 | const body = html`${happened}${connections}${swap}${when( | |
| 197 | 218 | reported, |
| 198 | 219 | () => html`<p class="muted small mb" id="egress-reported"> |
| 199 | 220 | ${icon("activity")} Egress is reported, not enforced: every new app, |
test/capacity-selfswap.test.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** | |
| 2 | * /capacity and MCP `capacity` say whether this forge can swap itself, and | |
| 3 | * why not (#8383): ready, the kernel not migrating queued connections, the | |
| 4 | * SSH terminal held, or FORGE_SELF_SWAP off. `handover` stands in for the | |
| 5 | * kernel file and the SSH listener. | |
| 6 | */ | |
| 7 | import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { handover, selfSwapState } from "../src/release/swap.ts"; | |
| 16 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 17 | ||
| 18 | let dataDir = ""; | |
| 19 | let app: App, servers: Servers, bearer = ""; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dataDir = await mkdtemp(join(tmpdir(), "forge-selfswap-")); | |
| 23 | app = createApp({ dataDir, port: 0, selfSwap: true }); | |
| 24 | servers = await serve(app); | |
| 25 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 26 | if (!admin.ok) throw new Error(admin.error); | |
| 27 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 28 | method: "POST", | |
| 29 | headers: { "content-type": "application/json" }, | |
| 30 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 31 | }); | |
| 32 | bearer = ((await minted.json()) as { token: string }).token; | |
| 33 | }); | |
| 34 | ||
| 35 | afterEach(() => { | |
| 36 | delete handover.migrate; | |
| 37 | delete handover.ssh; | |
| 38 | }); | |
| 39 | ||
| 40 | afterAll(async () => { | |
| 41 | servers.stop(); | |
| 42 | app.close(); | |
| 43 | await rm(dataDir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | const page = async (url = servers.url, token = bearer): Promise<string> => { | |
| 47 | const res = await fetch(`${url}/settings/capacity`, { headers: { authorization: `Bearer ${token}` } }); | |
| 48 | expect(res.status).toBe(200); | |
| 49 | const body = await res.text(); | |
| 50 | const at = body.indexOf('id="self-swap"'); | |
| 51 | expect(at).toBeGreaterThan(-1); | |
| 52 | return body.slice(at, body.indexOf("</li>", at)); | |
| 53 | }; | |
| 54 | ||
| 55 | const call = async (url: string, token: string) => { | |
| 56 | const res = await fetch(`${url}/mcp`, { | |
| 57 | method: "POST", | |
| 58 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, | |
| 59 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "capacity", arguments: {} } }), | |
| 60 | }); | |
| 61 | return ((await res.json()) as { result: { isError?: boolean; content: { text: string }[] } }).result; | |
| 62 | }; | |
| 63 | ||
| 64 | const tool = async (url = servers.url, token = bearer): Promise<{ enabled: boolean; migrateReq: string; blocked: string | null }> => { | |
| 65 | const result = await call(url, token); | |
| 66 | expect(result.isError).not.toBe(true); | |
| 67 | return (JSON.parse(result.content[0]!.text) as { selfSwap: never }).selfSwap; | |
| 68 | }; | |
| 69 | ||
| 70 | describe("self-swap on /capacity and MCP capacity", () => { | |
| 71 | test("ready: enabled, the kernel migrates, no SSH held", async () => { | |
| 72 | handover.migrate = true; | |
| 73 | handover.ssh = false; | |
| 74 | expect(await tool()).toEqual({ enabled: true, migrateReq: "1", blocked: null }); | |
| 75 | const row = await page(); | |
| 76 | expect(row).toContain("ready"); | |
| 77 | expect(row).toContain("is set"); | |
| 78 | expect(row).toMatch(/tcp_migrate_req<\/span> reads\s*<span class="mono">1</); | |
| 79 | }); | |
| 80 | ||
| 81 | test("kernel off: tcp_migrate_req 0 and the sentence that names it", async () => { | |
| 82 | handover.migrate = false; | |
| 83 | const s = await tool(); | |
| 84 | expect(s.migrateReq).toBe("0"); | |
| 85 | expect(s.blocked).toContain("net.ipv4.tcp_migrate_req=1"); | |
| 86 | const row = await page(); | |
| 87 | expect(row).toContain("blocked"); | |
| 88 | expect(row).toContain("net.ipv4.tcp_migrate_req=1 (Linux 5.14+)"); | |
| 89 | expect(row).toMatch(/reads\s*<span class="mono">0</); | |
| 90 | }); | |
| 91 | ||
| 92 | test("SSH held: the terminal is named, whatever the kernel says", async () => { | |
| 93 | handover.migrate = true; | |
| 94 | handover.ssh = true; | |
| 95 | const s = await tool(); | |
| 96 | expect(s.migrateReq).toBe("1"); | |
| 97 | expect(s.blocked).toContain("holds the SSH terminal"); | |
| 98 | expect(await page()).toContain("holds the SSH terminal"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("FORGE_SELF_SWAP off is a reason of its own, on the page and the tool", async () => { | |
| 102 | handover.migrate = true; | |
| 103 | handover.ssh = false; | |
| 104 | expect(selfSwapState(false).blocked).toContain("FORGE_SELF_SWAP is off"); | |
| 105 | const dir = await mkdtemp(join(tmpdir(), "forge-selfswap-off-")); | |
| 106 | const off = createApp({ dataDir: dir, port: 0 }); | |
| 107 | const offServers = await serve(off); | |
| 108 | try { | |
| 109 | const made = await seedAdmin(off, { username: "dana", email: "[email protected]", password: "correct-horse-battery" }); | |
| 110 | if (!made.ok) throw new Error(made.error); | |
| 111 | const token = createToken(off.db, made.value.id, "t", "admin").token; | |
| 112 | const s = await tool(offServers.url, token); | |
| 113 | expect(s.enabled).toBe(false); | |
| 114 | expect(s.migrateReq).toBe("1"); | |
| 115 | expect(s.blocked).toContain("FORGE_SELF_SWAP is off"); | |
| 116 | const row = await page(offServers.url, token); | |
| 117 | expect(row).toContain("FORGE_SELF_SWAP is off"); | |
| 118 | expect(row).toContain("is off"); | |
| 119 | expect(row).toContain("blocked"); | |
| 120 | } finally { | |
| 121 | offServers.stop(); | |
| 122 | off.close(); | |
| 123 | await rm(dir, { recursive: true, force: true }); | |
| 124 | } | |
| 125 | }); | |
| 126 | ||
| 127 | test("a member gets no selfSwap from the tool and a 404 from the page", async () => { | |
| 128 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 129 | if (!bob.ok) throw new Error(bob.error); | |
| 130 | const token = createToken(app.db, bob.value.id, "t", "admin").token; | |
| 131 | const result = await call(servers.url, token); | |
| 132 | expect(result.isError).toBe(true); | |
| 133 | expect(result.content[0]!.text).not.toContain("selfSwap"); | |
| 134 | const res = await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${token}` } }); | |
| 135 | expect(res.status).toBe(404); | |
| 136 | expect(await res.text()).not.toContain("self-swap"); | |
| 137 | }); | |
| 138 | }); |
test/tui-capacity.test.ts +36
| @@ -15,6 +15,7 @@ import { CAPACITY_KEYS, capacityValue } from "../src/core/capacity.ts"; | ||
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | 16 | import { createForgeApp, forgeCommands } from "../src/tui/forge.ts"; |
| 17 | 17 | import { capacityScreen } from "../src/tui/screens/capacity.ts"; |
| 18 | import { handover } from "../src/release/swap.ts"; | |
| 18 | 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 19 | 20 | import { quietLogs } from "./helpers/globals.ts"; |
| 20 | 21 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -182,4 +183,39 @@ describe("tui capacity", () => { | ||
| 182 | 183 | h.stop(); |
| 183 | 184 | } |
| 184 | 185 | }); |
| 186 | ||
| 187 | test("shows whether the forge can swap itself, and why not (#8383)", async () => { | |
| 188 | try { | |
| 189 | handover.migrate = true; | |
| 190 | handover.ssh = false; | |
| 191 | let h = boot(carol); | |
| 192 | await h.press(); | |
| 193 | // This app was built without selfSwap: FORGE_SELF_SWAP is off. | |
| 194 | expect(h.text()).toContain("Self-swap"); | |
| 195 | expect(h.text()).toContain("blocked · FORGE_SELF_SWAP off · tcp_migrate_req 1"); | |
| 196 | expect(h.text()).toContain("FORGE_SELF_SWAP is off"); | |
| 197 | h.stop(); | |
| 198 | handover.migrate = false; | |
| 199 | h = boot(carol); | |
| 200 | await h.press(); | |
| 201 | expect(h.text()).toContain("tcp_migrate_req 0"); | |
| 202 | expect(h.text()).toContain("net.ipv4.tcp_migrate_req=1"); | |
| 203 | h.stop(); | |
| 204 | handover.migrate = true; | |
| 205 | handover.ssh = true; | |
| 206 | h = boot(carol); | |
| 207 | await h.press(); | |
| 208 | expect(h.text()).toContain("holds the SSH terminal"); | |
| 209 | h.stop(); | |
| 210 | for (const who of [bob, bot]) { | |
| 211 | const m = boot(who); | |
| 212 | await m.press(); | |
| 213 | expect(m.text()).not.toContain("Self-swap"); | |
| 214 | m.stop(); | |
| 215 | } | |
| 216 | } finally { | |
| 217 | delete handover.migrate; | |
| 218 | delete handover.ssh; | |
| 219 | } | |
| 220 | }); | |
| 185 | 221 | }); |
new above
offline