Open History to see the last commit
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import type { ManagedPolicyDocument } from "@t3tools/contracts";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as Deferred from "effect/Deferred";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16
17import packageJson from "../../package.json" with { type: "json" };
18import * as ServerConfig from "../config.ts";
19import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
20import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
21import * as ProcessRunner from "../processRunner.ts";
22import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
23import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
24import * as ServerSelfUpdate from "./selfUpdate.ts";
25
26interface HarnessOptions {
27 readonly mode?: "web" | "desktop";
28 readonly managed?: boolean;
29 readonly launcherProtocol?: 3 | typeof SERVICE_LAUNCHER_PROTOCOL;
30 readonly preflight?: "ready" | "blocked";
31 readonly preflightDuration?: Duration.Input;
32 readonly preflightStarted?: Deferred.Deferred<void>;
33 readonly beforeDownload?: Effect.Effect<void>;
34 readonly httpClient?: HttpClient.HttpClient;
35 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
36 readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
37 readonly policy?: ManagedPolicyDocument;
38}
39
40const archiveBytes = new TextEncoder().encode("not really a tarball");
41const releaseHttpClient = (order: string[], urls: string[], beforeDownload?: Effect.Effect<void>) =>
42 HttpClient.make((request) =>
43 Effect.gen(function* () {
44 urls.push(request.url);
45 if (request.url.endsWith("/SHA256SUMS")) {
46 const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes));
47 const hex = Array.from(new Uint8Array(digest), (byte) =>
48 byte.toString(16).padStart(2, "0"),
49 ).join("");
50 return HttpClientResponse.fromWeb(
51 request,
52 new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`),
53 );
54 }
55 if (beforeDownload) yield* beforeDownload;
56 order.push("download");
57 return HttpClientResponse.fromWeb(request, new Response(archiveBytes));
58 }),
59 );
60
61const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
62 options: HarnessOptions = {},
63) {
64 const fs = yield* FileSystem.FileSystem;
65 const path = yield* Path.Path;
66 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
67 const order: string[] = [];
68 const urls: string[] = [];
69 const preflightProtocols: Array<string | undefined> = [];
70 const runner = ProcessRunner.ProcessRunner.of({
71 run: (input) =>
72 Effect.gen(function* () {
73 if (input.command === "busctl") {
74 // No user bus: the unit directory falls back to XDG_CONFIG_HOME or ~/.config.
75 return {
76 stdout: "",
77 stderr: "",
78 code: ChildProcessSpawner.ExitCode(1),
79 timedOut: false,
80 stdoutTruncated: false,
81 stderrTruncated: false,
82 stdoutInvalidUtf8: false,
83 stderrInvalidUtf8: false,
84 };
85 }
86 if (input.command === "tar") {
87 order.push("extract");
88 const stagingDir = input.args[input.args.indexOf("-C") + 1];
89 if (stagingDir === undefined) return yield* Effect.die("missing tar target");
90 yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie);
91 return {
92 stdout: "",
93 stderr: "",
94 code: ChildProcessSpawner.ExitCode(0),
95 timedOut: false,
96 stdoutTruncated: false,
97 stderrTruncated: false,
98 stdoutInvalidUtf8: false,
99 stderrInvalidUtf8: false,
100 };
101 }
102 if (options.preflightStarted) yield* Deferred.succeed(options.preflightStarted, undefined);
103 if (options.preflightDuration !== undefined) {
104 // Like the real runner, fail once the caller's timeout elapses.
105 const timeout = Duration.fromInputUnsafe(input.timeout ?? "60 seconds");
106 const finished = yield* Effect.sleep(options.preflightDuration).pipe(
107 Effect.timeoutOption(timeout),
108 );
109 if (finished._tag === "None") {
110 return yield* new ProcessRunner.ProcessTimeoutError({
111 command: input.command,
112 argumentCount: input.args.length,
113 timeoutMs: Duration.toMillis(timeout),
114 });
115 }
116 }
117 order.push("preflight");
118 // Like a release, report back the launcher protocol the server named.
119 const named = input.args[input.args.indexOf("--launcher-protocol") + 1];
120 preflightProtocols.push(named);
121 const result =
122 options.preflight === "blocked"
123 ? { status: "blocked", version: "1.1.0", reason: "local update required" }
124 : { status: "ready", version: "1.1.0", launcherProtocol: Number(named) };
125 return {
126 stdout: JSON.stringify(result),
127 stderr: "",
128 code: ChildProcessSpawner.ExitCode(0),
129 timedOut: false,
130 stdoutTruncated: false,
131 stderrTruncated: false,
132 stdoutInvalidUtf8: false,
133 stderrInvalidUtf8: false,
134 };
135 }),
136 });
137 const managed = options.managed ?? true;
138 const launcher = ServiceLauncherClient.ServiceLauncherClient.of({
139 managed,
140 protocol: managed ? (options.launcherProtocol ?? SERVICE_LAUNCHER_PROTOCOL) : undefined,
141 requestUpdate:
142 options.requestUpdate ??
143 (() =>
144 Effect.sync(() => {
145 order.push("accept");
146 return "launcher-id";
147 })),
148 prepareTrial: () => Effect.undefined,
149 });
150 const config = yield* ServerConfig.ServerConfig.pipe(
151 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
152 );
153 const selfUpdate = yield* ServerSelfUpdate.make().pipe(
154 Effect.provideService(ProcessRunner.ProcessRunner, runner),
155 Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),
156 Effect.provideService(
157 DesktopAppUpdate.DesktopAppUpdate,
158 options.desktopAppUpdate ?? {
159 available: false,
160 run: () => Effect.die("unexpected desktop app update run"),
161 },
162 ),
163 Effect.provideService(
164 HttpClient.HttpClient,
165 options.httpClient ?? releaseHttpClient(order, urls, options.beforeDownload),
166 ),
167 Effect.provideService(HostProcess.Platform, "linux"),
168 Effect.provideService(HostProcess.Architecture, "x64"),
169 Effect.provide(
170 Layer.merge(
171 ServerConfig.layer({ ...config, mode: options.mode ?? "web" }),
172 ManagedPolicy.layerTest(options.policy),
173 ),
174 ),
175 );
176 return { selfUpdate, order, urls, baseDir, preflightProtocols };
177});
178
179it.layer(NodeServices.layer)("server self update", (it) => {
180 it.effect.each([
181 { asset: "checksums", status: 504 },
182 { asset: "archive", status: 503 },
183 { asset: "checksums", status: 403 },
184 ])("reports an HTTP $status downloading $asset and allows retry", ({ asset, status }) =>
185 Effect.gen(function* () {
186 let failDownload = true;
187 const downloadFailed = yield* Deferred.make<void>();
188 const { selfUpdate, order, baseDir } = yield* makeHarness({
189 httpClient: releaseHttpClient([], []).pipe(
190 HttpClient.transform((response, request) => {
191 if (!failDownload || request.url.endsWith("/SHA256SUMS") !== (asset === "checksums"))
192 return response;
193 Deferred.doneUnsafe(downloadFailed, Effect.void);
194 return Effect.succeed(
195 HttpClientResponse.fromWeb(
196 HttpClientRequest.get("https://user:password@cdn.example/private?token=secret"),
197 new Response("private response body", { status }),
198 ),
199 );
200 }),
201 ),
202 });
203 const failing = yield* selfUpdate
204 .update({ targetVersion: "1.1.0" })
205 .pipe(Effect.flip, Effect.forkScoped);
206 yield* Deferred.await(downloadFailed);
207 // A 5xx is retried after 2, 4, then 8 seconds before the update gives up.
208 yield* TestClock.adjust("14 seconds");
209 const error = yield* Fiber.join(failing);
210 expect(error.reason).toBe(
211 `Could not prepare t3@1.1.0 while downloading the t3 release ${asset} (HTTP ${status} from cdn.example).`,
212 );
213 expect(order).toEqual([]);
214 const fs = yield* FileSystem.FileSystem;
215 const path = yield* Path.Path;
216 expect(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions"))).toEqual([]);
217 failDownload = false;
218 expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id");
219 expect(order).toEqual(["extract", "preflight", "accept"]);
220 }),
221 );
222
223 // regression: #34502 a remote update never replaces the launcher, so it can be older than the server
224 it.effect("names its launcher's protocol to the staged preflight, not its own", () =>
225 Effect.gen(function* () {
226 const { selfUpdate, preflightProtocols } = yield* makeHarness({ launcherProtocol: 3 });
227 expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id");
228 expect(preflightProtocols).toEqual(["3"]);
229 }),
230 );
231
232 // regression: #5181 make remote updates rollback-safe
233 it.effect("rejects invalid versions and desktop-managed servers before staging", () =>
234 Effect.gen(function* () {
235 const web = yield* makeHarness();
236 expect(
237 (yield* web.selfUpdate.update({ targetVersion: "latest" }).pipe(Effect.flip)).reason,
238 ).toBe("'latest' is not an exact t3 version.");
239 const desktop = yield* makeHarness({ mode: "desktop" });
240 expect(
241 (yield* desktop.selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason,
242 ).toContain("desktop app");
243 expect([...web.order, ...desktop.order]).toEqual([]);
244 }),
245 );
246
247 it.effect("refuses every update when the managed policy turns updates off", () =>
248 Effect.gen(function* () {
249 const policy: ManagedPolicyDocument = {
250 schemaVersion: 1,
251 organization: "Example Corp",
252 limits: { updates: { mode: "off" } },
253 };
254 const service = yield* makeHarness({ policy });
255 const desktop = yield* makeHarness({
256 mode: "desktop",
257 policy,
258 desktopAppUpdate: {
259 available: true,
260 run: () => Effect.die("unexpected desktop app update run"),
261 commit: () => Effect.never,
262 },
263 });
264 for (const harness of [service, desktop]) {
265 expect(
266 (yield* harness.selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason,
267 ).toBe("Example Corp manages T3 Code updates.");
268 }
269 expect([...service.order, ...desktop.order]).toEqual([]);
270 }),
271 );
272
273 it.effect("lets a user start an update, but only to stable without nightly", () =>
274 Effect.gen(function* () {
275 const { selfUpdate, order, urls } = yield* makeHarness({
276 policy: {
277 schemaVersion: 1,
278 organization: "Example Corp",
279 limits: {
280 updates: {
281 mode: "auto",
282 allowNightly: false,
283 feedUrl: "https://updates.example.com/t3code/",
284 },
285 },
286 },
287 });
288 expect(
289 (yield* selfUpdate.update({ targetVersion: "1.2.0-nightly.20261028.1" }).pipe(Effect.flip))
290 .reason,
291 ).toBe("Example Corp manages T3 Code updates. Only stable releases can be installed.");
292 expect(order).toEqual([]);
293 // The mirror lists only 1.1.0's archive.
294 expect((yield* selfUpdate.update({ targetVersion: "1.2.0" }).pipe(Effect.flip)).reason).toBe(
295 "Example Corp's update mirror has no t3@1.2.0 for this machine.",
296 );
297 expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id");
298 // The release comes from the policy's mirror, not GitHub.
299 expect(urls.length).toBeGreaterThan(0);
300 for (const url of urls)
301 expect(url).toMatch(/^https:\/\/updates\.example\.com\/t3code\/v1\.[12]\.0\//);
302 }),
303 );
304
305 it.effect("delegates desktop-managed updates to the desktop app when available", () =>
306 Effect.gen(function* () {
307 const stages: string[] = [];
308 const { selfUpdate, order } = yield* makeHarness({
309 mode: "desktop",
310 desktopAppUpdate: {
311 available: true,
312 run: (reportProgress) =>
313 reportProgress("downloading").pipe(
314 Effect.andThen(reportProgress("installing")),
315 Effect.as({ targetVersion: "1.2.0", method: "desktop-app" as const }),
316 ),
317 commit: () => Effect.never,
318 },
319 });
320 const result = yield* selfUpdate.update({ targetVersion: "1.1.0" }, (stage) =>
321 Effect.sync(() => void stages.push(stage)),
322 );
323 expect(result).toEqual({ targetVersion: "1.2.0", method: "desktop-app" });
324 expect(stages).toEqual(["downloading", "installing"]);
325 expect(order).toEqual([]);
326 }),
327 );
328
329 it.effect("allows only one update at a time", () =>
330 Effect.gen(function* () {
331 const requested = yield* Deferred.make<void>();
332 const accepted = yield* Deferred.make<string>();
333 const { selfUpdate } = yield* makeHarness({
334 requestUpdate: () =>
335 Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(accepted))),
336 });
337 const first = yield* Effect.forkChild(selfUpdate.update({ targetVersion: "1.1.0" }), {
338 startImmediately: true,
339 });
340 yield* Deferred.await(requested);
341 expect((yield* selfUpdate.update({ targetVersion: "1.1.1" }).pipe(Effect.flip)).reason).toBe(
342 "A server update is already in progress.",
343 );
344 yield* Deferred.succeed(accepted, "launcher-id");
345 expect((yield* Fiber.join(first)).updateId).toBe("launcher-id");
346 }),
347 );
348
349 // regression: #28198 a slow staged preflight on a loaded host no longer fails the update
350 it.effect("waits for a staged preflight that takes a minute on a loaded host", () =>
351 Effect.gen(function* () {
352 const preflightStarted = yield* Deferred.make<void>();
353 const { selfUpdate, order } = yield* makeHarness({
354 preflightDuration: "60 seconds",
355 preflightStarted,
356 });
357 const update = yield* Effect.forkChild(selfUpdate.update({ targetVersion: "1.1.0" }), {
358 startImmediately: true,
359 });
360 yield* Deferred.await(preflightStarted);
361 yield* TestClock.adjust("60 seconds");
362 const exit = yield* Fiber.await(update);
363 expect(exit._tag === "Failure" ? String(exit.cause) : "launcher handoff").toBe(
364 "launcher handoff",
365 );
366 expect(order).toEqual(["download", "extract", "preflight", "accept"]);
367 }),
368 );
369
370 it.effect("keeps preparing after the progress stream closes", () =>
371 Effect.gen(function* () {
372 const releaseDownload = yield* Deferred.make<void>();
373 const sawDownloading = yield* Deferred.make<void>();
374 const accepted = yield* Deferred.make<void>();
375 const { selfUpdate, order } = yield* makeHarness({
376 beforeDownload: Deferred.await(releaseDownload),
377 requestUpdate: () =>
378 Effect.sync(() => {
379 order.push("accept");
380 }).pipe(Effect.andThen(Deferred.succeed(accepted, undefined)), Effect.as("launcher-id")),
381 });
382
383 const consumed = yield* ServerSelfUpdate.updateProgressStream(selfUpdate, {
384 targetVersion: "1.1.0",
385 }).pipe(
386 Stream.tap((event) =>
387 event.type === "progress" && event.stage === "downloading"
388 ? Deferred.succeed(sawDownloading, undefined)
389 : Effect.void,
390 ),
391 Stream.runDrain,
392 Effect.forkChild,
393 );
394
395 yield* Deferred.await(sawDownloading);
396 yield* Fiber.interrupt(consumed);
397 expect((yield* selfUpdate.update({ targetVersion: "1.2.0" }).pipe(Effect.flip)).reason).toBe(
398 "A server update is already in progress.",
399 );
400
401 yield* Deferred.succeed(releaseDownload, undefined);
402 yield* Deferred.await(accepted);
403 expect(order).toEqual(["download", "extract", "preflight", "accept"]);
404 expect((yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason).toBe(
405 "A server update is already in progress.",
406 );
407 }),
408 );
409});
410
411it.layer(NodeServices.layer)("pruning runtimes on start", (it) => {
412 const staleVersion = "0.0.1";
413 const otherProcesses = ProcessRunner.ProcessRunner.of({
414 run: () =>
415 Effect.succeed({
416 stdout: "p42\nftxt\nn/bin/sh\n",
417 stderr: "",
418 code: ChildProcessSpawner.ExitCode(0),
419 timedOut: false,
420 stdoutTruncated: false,
421 stderrTruncated: false,
422 stdoutInvalidUtf8: false,
423 stderrInvalidUtf8: false,
424 }),
425 });
426
427 const pruneOnStart = Effect.fn("test.prune_on_start")(function* (input: {
428 readonly executable: "own runtime" | "elsewhere";
429 readonly managed?: boolean;
430 readonly symlinkedHome?: boolean;
431 readonly servicePlistStarts?: string;
432 readonly servicePlistThroughLink?: boolean;
433 }) {
434 const fs = yield* FileSystem.FileSystem;
435 const path = yield* Path.Path;
436 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-prune-on-start-" });
437 const realHome = path.join(tempDir, "home");
438 const baseDir = input.symlinkedHome ? path.join(tempDir, "linked-home") : realHome;
439 const versionsDir = path.join(baseDir, "runtime", "versions");
440 yield* TestClock.setTime(24 * 60 * 60 * 1000);
441 for (const version of [staleVersion, packageJson.version]) {
442 const directory = path.join(realHome, "runtime", "versions", version);
443 yield* fs.makeDirectory(directory, { recursive: true });
444 yield* fs.writeFileString(path.join(directory, "t3"), "");
445 const sentinel = path.join(directory, ".install-complete");
446 yield* fs.writeFileString(sentinel, `${version}\n`);
447 yield* fs.utimes(sentinel, 0, 0);
448 }
449 if (input.symlinkedHome) yield* fs.symlink(realHome, baseDir);
450 const userHome = path.join(tempDir, "user");
451 if (input.servicePlistStarts !== undefined) {
452 const agentsDir = path.join(userHome, "Library", "LaunchAgents");
453 yield* fs.makeDirectory(agentsDir, { recursive: true });
454 const programHome = input.servicePlistThroughLink
455 ? path.join(tempDir, "alias-home")
456 : baseDir;
457 if (input.servicePlistThroughLink) yield* fs.symlink(realHome, programHome);
458 const program = path.join(programHome, "runtime", "versions", input.servicePlistStarts, "t3");
459 yield* fs.writeFileString(
460 path.join(agentsDir, "com.t3tools.t3code.service.plist"),
461 `<plist><dict>\n<key>ProgramArguments</key>\n<array>\n<string>${program}</string>\n<string>__service-launcher</string>\n</array>\n</dict></plist>\n`,
462 );
463 }
464 // Like `process.execPath`, the running runtime is reported by its real path.
465 const executablePath =
466 input.executable === "own runtime"
467 ? yield* fs.realPath(path.join(versionsDir, packageJson.version, "t3"))
468 : "/usr/local/bin/node";
469 const removed = yield* ServerSelfUpdate.pruneRuntimesOnStart({
470 baseDir,
471 managed: input.managed ?? false,
472 }).pipe(
473 Effect.provideService(HostProcess.ExecutablePath, executablePath),
474 Effect.provideService(HostProcess.Platform, "darwin"),
475 Effect.provideService(HostProcess.Environment, {}),
476 Effect.provideService(HostProcess.HomeDirectory, userHome),
477 Effect.provideService(ProcessRunner.ProcessRunner, otherProcesses),
478 );
479 return { removed, remaining: (yield* fs.readDirectory(versionsDir)).toSorted() };
480 });
481
482 // The SSH launcher runs `t3 serve` straight from the pinned runtime, with no boot service.
483 it.effect("prunes for a server started from its home's pinned runtime", () =>
484 Effect.gen(function* () {
485 const { removed, remaining } = yield* pruneOnStart({ executable: "own runtime" });
486 expect(removed).toEqual([staleVersion]);
487 expect(remaining).toEqual([packageJson.version]);
488 }),
489 );
490
491 it.effect("recognizes its runtime through a symlinked T3 home", () =>
492 Effect.gen(function* () {
493 const { removed } = yield* pruneOnStart({ executable: "own runtime", symlinkedHome: true });
494 expect(removed).toEqual([staleVersion]);
495 }),
496 );
497
498 it.effect("prunes for a boot-service server wherever it runs from", () =>
499 Effect.gen(function* () {
500 const { removed } = yield* pruneOnStart({ executable: "elsewhere", managed: true });
501 expect(removed).toEqual([staleVersion]);
502 }),
503 );
504
505 // In-place updates move the active version but leave the launch agent on the launcher's runtime.
506 it.effect.each([
507 { name: "", throughLink: false },
508 { name: " through a symlinked T3 home", throughLink: true },
509 ])("keeps the runtime a stopped service's launch agent starts$name", ({ throughLink }) =>
510 Effect.gen(function* () {
511 const { removed, remaining } = yield* pruneOnStart({
512 executable: "own runtime",
513 servicePlistStarts: staleVersion,
514 servicePlistThroughLink: throughLink,
515 });
516 expect(removed).toEqual([]);
517 expect(remaining).toEqual([staleVersion, packageJson.version].toSorted());
518 }),
519 );
520
521 it.effect("leaves runtimes alone for an unmanaged server started elsewhere", () =>
522 Effect.gen(function* () {
523 const { removed, remaining } = yield* pruneOnStart({ executable: "elsewhere" });
524 expect(removed).toEqual([]);
525 expect(remaining).toEqual([staleVersion, packageJson.version].toSorted());
526 }),
527 );
528});
529
530it("a host crosses tracks only to a release that knows its whole database", () => {
531 const cross = (
532 serverVersion: string,
533 targetVersion: string,
534 targetDatabaseMigration: number | undefined,
535 databaseMigration = 72,
536 ) =>
537 ServerSelfUpdate.trackSwitchBlockReason({
538 serverVersion,
539 targetVersion,
540 targetDatabaseMigration,
541 databaseMigration: () => databaseMigration,
542 });
543 const nightly = "0.1.4-nightly.20261027.3796";
544
545 expect(cross(nightly, "0.1.4", 72)).toBeNull();
546 // Another client moved this host to a newer Nightly after the switch was planned.
547 expect(cross(nightly, "0.1.4", 72, 73)).toContain("does not support yet");
548 expect(cross(nightly, "0.1.4", undefined)).toContain("Cannot confirm");
549 // A Stable tag can carry a migration the latest Nightly lacks.
550 expect(cross("0.1.4", nightly, 72, 73)).toContain("does not support yet");
551 expect(cross("0.1.3", "0.1.4", undefined)).toBeNull();
552 // A database that exists but cannot be read proves nothing.
553 expect(
554 ServerSelfUpdate.trackSwitchBlockReason({
555 serverVersion: nightly,
556 targetVersion: "0.1.4",
557 targetDatabaseMigration: 72,
558 databaseMigration: () => undefined,
559 }),
560 ).toContain("Cannot confirm");
561});