Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 140 additions & 36 deletions apps/server/src/provider/AntigravityInstallation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientResponse } from "effect/http";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

Expand Down Expand Up @@ -127,6 +128,7 @@ interface HarnessOptions {
readonly contentLength?: number;
readonly contentEncoding?: string;
readonly platform?: NodeJS.Platform;
readonly arch?: NodeJS.Architecture;
readonly path?: string;
readonly previous?: boolean;
readonly fileSystem?: FileSystem.FileSystem;
Expand All @@ -142,10 +144,11 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* (
const baseDir =
options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" }));
const platform = options.platform ?? hostPlatform;
const arch = options.arch ?? "x64";
const archive = options.archive ?? completeArchive;
const asset =
options.asset === undefined ? yield* releaseAsset(archive, platform) : options.asset;
const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`);
const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-${arch}`);
if (options.previous) {
yield* writeRelease(managedDirectory, {
...(yield* releaseAsset(archive, platform)),
Expand Down Expand Up @@ -185,7 +188,7 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* (
}).pipe(
Effect.provideService(FileSystem.FileSystem, trackedFs),
Effect.provideService(HostProcess.Platform, platform),
Effect.provideService(HostProcess.Architecture, "x64"),
Effect.provideService(HostProcess.Architecture, arch),
Effect.provideService(HostProcess.Environment, { PATH: options.path ?? "" }),
Effect.provideService(
HttpClient.HttpClient,
Expand Down Expand Up @@ -305,38 +308,76 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
}),
);

it.effect.each([
{
name: "the expected release with protocol 1",
protocolVersion: 1,
agentName: "antigravity-acp",
version: "fixture-new",
valid: true,
},
{
name: "the expected release with protocol 2 and legacy fields",
protocolVersion: 2,
agentName: "antigravity-acp",
version: "fixture-new",
valid: true,
},
{
name: "an unsupported protocol version",
protocolVersion: 3,
agentName: "antigravity-acp",
version: "fixture-new",
valid: false,
},
{ name: "a different agent", agentName: "other-agent", version: "fixture-new", valid: false },
{
name: "a different version",
agentName: "antigravity-acp",
version: "other-version",
valid: false,
},
])("validates $name with initialize only and removes the disposable profile", (testCase) =>
it.effect.each(
[
{
name: "the expected release with protocol 1",
protocolVersion: 1,
agentName: "antigravity-acp",
version: "fixture-new",
valid: true,
},
{
name: "the expected release with protocol 2 and legacy fields",
protocolVersion: 2,
agentName: "antigravity-acp",
version: "fixture-new",
valid: true,
},
{
name: "an unsupported protocol version",
protocolVersion: 3,
agentName: "antigravity-acp",
version: "fixture-new",
valid: false,
},
{ name: "a different agent", agentName: "other-agent", version: "fixture-new", valid: false },
{
name: "a different version",
agentName: "antigravity-acp",
version: "other-version",
valid: false,
},
{
name: "the Windows ARM release with locked child processes",
platform: "win32" as const,
arch: "arm64" as const,
agentName: "antigravity-acp",
version: "fixture-new",
valid: true,
},
{
name: "an invalid Windows ARM release with locked child processes",
platform: "win32" as const,
arch: "arm64" as const,
agentName: "antigravity-acp",
version: "other-version",
valid: false,
},
{
name: "a cancelled Windows ARM verification with locked child processes",
platform: "win32" as const,
arch: "arm64" as const,
agentName: "antigravity-acp",
version: "fixture-new",
cancel: true,
valid: false,
},
].flatMap((testCase) => {
const cleanupErrorTags =
testCase.platform === "win32"
? (["Unknown", "PermissionDenied", "Busy"] as const)
: (["Unknown"] as const);
return cleanupErrorTags.map((cleanupErrorTag) => ({
...testCase,
cleanupErrorTag,
name: `${cleanupErrorTag}: ${testCase.name}`,
}));
}),
)("validates $name with initialize only and removes the disposable profile", (testCase) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const platform = testCase.platform ?? hostPlatform;
const encoder = new TextEncoder();
const decodeRequest = Schema.decodeUnknownEffect(
Schema.fromJsonString(
Expand All @@ -348,6 +389,10 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
);
const methods: string[] = [];
const profiles = new Set<string>();
const cleanupRetryStarted = yield* Deferred.make<void>();
const initializationStarted = yield* Deferred.make<void>();
let childProcessesRunning = false;
let profileRemovalAttempts = 0;
let closedRuntimes = 0;
const spawner = ChildProcessSpawner.make(
Effect.fn("test.spawnAntigravityValidator")(function* (command) {
Expand All @@ -358,8 +403,16 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
if (!profile) return yield* Effect.die("Expected a disposable validation profile.");
profiles.add(profile);
const helper = command.args[0] === "-e";
if (!helper) childProcessesRunning = true;
// The runtime unpacks straight into the disposable profile.
if (!helper) expect(command.options.env?.TMPDIR).toBe(profile);
if (!helper) {
if (platform === "win32") {
expect(command.options.env?.TEMP).toBe(profile);
expect(command.options.env?.TMP).toBe(profile);
} else {
expect(command.options.env?.TMPDIR).toBe(profile);
}
}
const output = yield* Queue.unbounded<Uint8Array>();
const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe(
Expand All @@ -381,14 +434,21 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
? Effect.succeed(ChildProcessSpawner.ExitCode(0))
: Deferred.await(exited),
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
kill: () => terminate,
kill: () =>
Effect.sync(() => {
if (!helper) childProcessesRunning = false;
}).pipe(Effect.andThen(terminate)),
unref: Effect.succeed(Effect.void),
stdin: Sink.forEach((bytes: Uint8Array) =>
Effect.gen(function* () {
const request = yield* decodeRequest(new TextDecoder().decode(bytes)).pipe(
Effect.orDie,
);
methods.push(request.method);
if (testCase.cancel) {
yield* Deferred.succeed(initializationStarted, undefined);
return;
}
yield* Queue.offer(
output,
encoder.encode(
Expand Down Expand Up @@ -431,15 +491,59 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
const { installation, stagingReleased } = yield* makeHarness({
previous: true,
useDefaultValidation: true,
platform,
...(testCase.arch ? { arch: testCase.arch } : {}),
archive:
platform === "win32" ? Buffer.from(zipFixtures.windows, "base64") : completeArchive,
fileSystem: FileSystem.FileSystem.of({
...fs,
remove: (target, options) =>
Effect.suspend(() => {
if (platform === "win32" && profiles.has(target)) {
profileRemovalAttempts += 1;
expect(childProcessesRunning).toBe(false);
// Even after the tree exits, Windows can briefly retain a DLL handle.
if (profileRemovalAttempts === 1) {
return Deferred.succeed(cleanupRetryStarted, undefined).pipe(
Effect.andThen(
Effect.fail(
PlatformError.systemError({
_tag: testCase.cleanupErrorTag,
module: "FileSystem",
method: "remove",
description: "Unpacked DLL is in use",
}),
),
),
);
}
}
return fs.remove(target, options);
}),
}),
}).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
yield* installation.start;
const started = yield* installation.start;
const cancellation = testCase.cancel
? yield* Deferred.await(initializationStarted).pipe(
Effect.andThen(installation.cancel(started.operationId!)),
Effect.forkChild,
)
: undefined;
if (platform === "win32") {
yield* Effect.raceFirst(
Deferred.await(cleanupRetryStarted).pipe(Effect.andThen(TestClock.adjust("100 millis"))),
terminalState(installation),
);
}
if (cancellation) yield* Fiber.join(cancellation);
expect((yield* terminalState(installation)).phase).toBe(
testCase.valid ? "succeeded" : "failed",
testCase.cancel ? "cancelled" : testCase.valid ? "succeeded" : "failed",
);
yield* Deferred.await(stagingReleased);
expect(methods).toEqual(["initialize"]);
expect(closedRuntimes).toBe(1);
expect(profiles.size).toBe(1);
if (platform === "win32") expect(profileRemovalAttempts).toBe(2);
for (const profile of profiles) {
expect(yield* fs.exists(profile)).toBe(false);
}
Expand Down
38 changes: 34 additions & 4 deletions apps/server/src/provider/AntigravityInstallation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Schedule from "effect/Schedule";
import * as Scope from "effect/Scope";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -349,9 +350,23 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
options.validate ??
Effect.fn("AntigravityInstallation.validate")(
function* (executable: AntigravityExecutable, expectedVersion: string) {
const profileDirectory = yield* fs.makeTempDirectoryScoped({
prefix: "t3-antigravity-validate-",
});
const validationScope = yield* Effect.scope;
const profileDirectory = yield* Effect.acquireRelease(
fs.makeTempDirectory({ prefix: "t3-antigravity-validate-" }),
(directory) =>
fs.remove(directory, { recursive: true, force: true }).pipe(
// Windows may retain DLL handles briefly after taskkill returns.
Effect.retry({
times: platform === "win32" ? 10 : 0,
schedule: Schedule.spaced("100 millis"),
while: (error) =>
error.reason._tag === "PermissionDenied" ||
error.reason._tag === "Busy" ||
error.reason._tag === "Unknown",
}),
Effect.orDie,
),
);
const profile = yield* prepareAntigravityProfile({
profileDirectory,
platform,
Expand All @@ -368,7 +383,22 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
baseEnv: environment,
}),
cwd: profileDirectory,
childProcessSpawner: spawner,
childProcessSpawner:
platform === "win32"
? ChildProcessSpawner.make(
Effect.fnUntraced(function* (command) {
const child = yield* spawner.spawn(command);
// Stop PyInstaller's tree before ACP closes its pipes. Once
// the launcher exits, taskkill cannot reach its children,
// which keep the executable and unpacked DLLs locked.
yield* Scope.addFinalizer(
validationScope,
child.kill({ forceKillAfter: "1 second" }).pipe(Effect.orDie),
);
return child;
}),
)
: spawner,
clientInfo: { name: "t3-code", version: "0.0.0" },
});
const initialized = yield* runtime.initialize();
Expand Down
Loading