Skip to content
Draft
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
23 changes: 21 additions & 2 deletions apps/server/src/vcs/VcsProjectConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ describe("VcsProjectConfig", () => {
yield* fileSystem.makeDirectory(nested, { recursive: true });
yield* fileSystem.writeFileString(
path.join(configDir, "vcs.json"),
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({ vcs: { kind: "jj" } }),
'{"vcs":{"kind":"jj"}}',
);

const config = yield* VcsProjectConfig.VcsProjectConfig;
Expand All @@ -67,4 +66,24 @@ describe("VcsProjectConfig", () => {
}),
);
});

it.layer(TestLayer)("falls back to auto when config JSON is invalid", (it) => {
it.effect("returns auto", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-vcs-config-test-",
});
const configDir = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDir, { recursive: true });
yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{invalid-json");

const config = yield* VcsProjectConfig.VcsProjectConfig;
const kind = yield* config.resolveKind({ cwd: root });

assert.equal(kind, "auto");
}),
);
});
});
45 changes: 15 additions & 30 deletions apps/server/src/vcs/VcsProjectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

Expand All @@ -15,16 +16,9 @@ const ProjectVcsConfig = Schema.Struct({
),
vcsKind: Schema.optional(VcsDriverKind),
});
const isProjectVcsConfig = Schema.is(ProjectVcsConfig);
const decodeProjectVcsConfig = Schema.decodeUnknownOption(Schema.fromJsonString(ProjectVcsConfig));

interface ProjectVcsConfigFile {
readonly vcs?:
| {
readonly kind?: VcsDriverKindType | undefined;
}
| undefined;
readonly vcsKind?: VcsDriverKindType | undefined;
}
type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;

export interface VcsProjectConfigResolveInput {
readonly cwd: string;
Expand All @@ -45,14 +39,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
return config.vcs?.kind ?? config.vcsKind ?? "auto";
}

function parseConfig(raw: string): ProjectVcsConfigFile | null {
try {
const parsed = JSON.parse(raw) as unknown;
return isProjectVcsConfig(parsed) ? parsed : null;
} catch {
return null;
}
}
const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
decodeProjectVcsConfig(raw);

export const make = Effect.fn("makeVcsProjectConfig")(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -63,12 +51,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
while (true) {
const candidate = path.join(current, ".t3code", "vcs.json");
if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
return candidate;
return Option.some(candidate);
}

const parent = path.dirname(current);
if (parent === current) {
return null;
return Option.none();
}
current = parent;
}
Expand All @@ -78,26 +66,25 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
configPath: string,
) {
const raw = yield* fileSystem.readFileString(configPath).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
Effect.logWarning("failed to read VCS project config", {
configPath,
error,
}).pipe(Effect.as(null)),
}).pipe(Effect.as(Option.none<string>())),
),
);
if (raw === null) {
return "auto" as const;
}
if (Option.isNone(raw)) return "auto" as const;

const parsed = parseConfig(raw);
if (parsed === null) {
const parsed = parseConfig(raw.value);
if (Option.isNone(parsed)) {
yield* Effect.logWarning("invalid VCS project config", {
configPath,
});
return "auto" as const;
}

return configuredKind(parsed);
return configuredKind(parsed.value);
});

const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
Expand All @@ -108,11 +95,9 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
}

const configPath = yield* findConfigPath(input.cwd);
if (configPath === null) {
return "auto";
}
if (Option.isNone(configPath)) return "auto";

return yield* readConfiguredKind(configPath);
return yield* readConfiguredKind(configPath.value);
});

return VcsProjectConfig.of({
Expand Down
6 changes: 3 additions & 3 deletions packages/ssh/src/tunnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,9 @@ describe("ssh tunnel scripts", () => {
Effect.result(
waitForHttpReady({
baseUrl: "http://127.0.0.1:41773/",
timeoutMs: 1_000,
intervalMs: 100,
probeTimeoutMs: 250,
timeout: Duration.seconds(1),
interval: Duration.millis(100),
probeTimeout: Duration.millis(250),
}),
),
);
Expand Down
49 changes: 26 additions & 23 deletions packages/ssh/src/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ import {

export const DEFAULT_REMOTE_PORT = 3773;
const REMOTE_PORT_SCAN_WINDOW = 200;
const SSH_READY_TIMEOUT_MS = 20_000;
const SSH_READY_PROBE_TIMEOUT_MS = 1_000;
const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
const REMOTE_READY_TIMEOUT_MS = 15_000;
const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
const DEFAULT_HTTP_READY_TIMEOUT = Duration.seconds(30);
const DEFAULT_HTTP_READY_INTERVAL = Duration.millis(100);
const SSH_READY_TIMEOUT = Duration.seconds(20);
const SSH_READY_PROBE_TIMEOUT = Duration.seconds(1);
const TUNNEL_SHUTDOWN_TIMEOUT = Duration.seconds(2);
const REMOTE_READY_TIMEOUT = Duration.seconds(15);
const REMOTE_REUSE_READY_TIMEOUT = Duration.seconds(2);

export interface RemoteT3RunnerOptions {
readonly packageSpec?: string;
Expand Down Expand Up @@ -686,9 +688,9 @@ export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT),
T3_DEFAULT_REMOTE_PORT: String(DEFAULT_REMOTE_PORT),
T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW),
T3_READY_TIMEOUT_MS: String(REMOTE_READY_TIMEOUT_MS),
T3_REUSE_READY_TIMEOUT_MS: String(REMOTE_REUSE_READY_TIMEOUT_MS),
T3_READY_PROBE_TIMEOUT_MS: String(SSH_READY_PROBE_TIMEOUT_MS),
T3_READY_TIMEOUT_MS: String(Duration.toMillis(REMOTE_READY_TIMEOUT)),
T3_REUSE_READY_TIMEOUT_MS: String(Duration.toMillis(REMOTE_REUSE_READY_TIMEOUT)),
T3_READY_PROBE_TIMEOUT_MS: String(Duration.toMillis(SSH_READY_PROBE_TIMEOUT)),
});
}

Expand Down Expand Up @@ -870,17 +872,18 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(

export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(function* (input: {
readonly baseUrl: string;
readonly timeoutMs?: number;
readonly intervalMs?: number;
readonly probeTimeoutMs?: number;
readonly timeout?: Duration.Duration;
readonly interval?: Duration.Duration;
readonly probeTimeout?: Duration.Duration;
readonly path?: string;
}): Effect.fn.Return<void, SshReadinessError, HttpClient.HttpClient> {
const timeoutMs = input.timeoutMs ?? 30_000;
const intervalMs = input.intervalMs ?? 100;
const probeTimeoutMs = input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS;
const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe(
Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))),
);
const timeout = input.timeout ?? DEFAULT_HTTP_READY_TIMEOUT;
const interval = input.interval ?? DEFAULT_HTTP_READY_INTERVAL;
const probeTimeout = input.probeTimeout ?? SSH_READY_PROBE_TIMEOUT;
const timeoutMs = Duration.toMillis(timeout);
const intervalMs = Duration.toMillis(interval);
const probeTimeoutMs = Duration.toMillis(probeTimeout);
const retryPolicy = Schedule.spaced(interval);
const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString();
const client = yield* HttpClient.HttpClient;
const lastProbeFailure = yield* Ref.make<unknown>(null);
Expand All @@ -900,7 +903,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio
Effect.gen(function* () {
attempt += 1;
const responseOption = yield* effect.pipe(
Effect.timeoutOption(Duration.millis(probeTimeoutMs)),
Effect.timeoutOption(probeTimeout),
Effect.mapError(
(cause) =>
new SshReadinessError({
Expand Down Expand Up @@ -953,7 +956,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio
cause,
}),
),
Effect.timeoutOption(Duration.millis(timeoutMs)),
Effect.timeoutOption(timeout),
);

return yield* Option.match(result, {
Expand Down Expand Up @@ -1236,7 +1239,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input:
yield* Effect.raceFirst(
waitForHttpReady({
baseUrl: input.httpBaseUrl,
timeoutMs: SSH_READY_TIMEOUT_MS,
timeout: SSH_READY_TIMEOUT,
}),
exitFailure,
).pipe(
Expand Down Expand Up @@ -1296,7 +1299,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input:
: child
.kill({
killSignal: "SIGTERM",
forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS,
forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT,
})
.pipe(Effect.ignore),
),
Expand Down Expand Up @@ -1540,7 +1543,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
[
tunnelEntry.process.kill({
killSignal: "SIGTERM",
forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS,
forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT,
}),
stopRemoteServer(
tunnelEntry.target,
Expand Down Expand Up @@ -1594,7 +1597,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
remotePort: entry.remotePort,
});
const readinessExit = yield* Effect.exit(
waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }),
waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeout: REMOTE_REUSE_READY_TIMEOUT }),
);
if (Exit.isSuccess(readinessExit)) {
yield* Effect.logDebug("ssh.environment.tunnel.reused", {
Expand Down
Loading