Skip to content
Open
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
37 changes: 33 additions & 4 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import "reflect-metadata";
import os from "node:os";
import { TypedEventEmitter } from "@posthog/shared";
import type { WorkspaceClient } from "@posthog/workspace-client/client";
import { createWorkspaceClient } from "@posthog/workspace-client/client";
import { createReconnectingWorkspaceClient } from "@posthog/workspace-client/client";
import type { FileWatcherEvent } from "@posthog/workspace-client/types";
import { app, BrowserWindow, dialog, session } from "electron";
import log from "electron-log/main";
Expand Down Expand Up @@ -80,7 +80,11 @@ import {
focusSessionStore,
focusWorktreePaths,
} from "./services/focus/desktop-adapters";
import type { WorkspaceServerService } from "./services/workspace-server/service";
import {
WorkspaceServerEvent,
type WorkspaceServerService,
WorkspaceServerStatus,
} from "./services/workspace-server/service";
import {
collectMemorySnapshot,
flattenMemorySnapshot,
Expand Down Expand Up @@ -128,6 +132,19 @@ export class FileWatcherBridge extends TypedEventEmitter<FileWatcherEventsByKind
sub.unsubscribe();
this.subs.delete(repoPath);
}

/**
* Tear down and re-create every active watch. The workspace-server child
* respawns on a new port after a crash; the old SSE subscriptions keep
* retrying the dead port forever, so the boot wiring calls this when the
* server reports ready again.
*/
resubscribeAll(): void {
for (const repoPath of [...this.subs.keys()]) {
this.stopWatching(repoPath);
this.startWatching(repoPath);
}
}
}

// Single instance lock must be acquired FIRST before any other app setup
Expand Down Expand Up @@ -350,13 +367,25 @@ app.whenReady().then(async () => {
const wsServer = container.get<WorkspaceServerService>(
WORKSPACE_SERVER_SERVICE,
);
const connection = await wsServer.start();
const workspaceClient = createWorkspaceClient(connection);
await wsServer.start();
// The workspace-server child respawns on a new port/secret after a crash;
// a reconnecting client follows the current connection so main-process
// callers don't keep hitting the dead port for the rest of the session.
const workspaceClient = createReconnectingWorkspaceClient(() =>
wsServer.getConnection(),
);
container.bind(WORKSPACE_CLIENT).toConstantValue(workspaceClient);
container.bind(GIT_WORKSPACE_CLIENT).toConstantValue(workspaceClient);
container.bind(CONNECTIVITY_CLIENT).toConstantValue(workspaceClient);
container.bind(ENVIRONMENT_CLIENT).toConstantValue(workspaceClient);
const fileWatcherBridge = new FileWatcherBridge(workspaceClient);
// Re-establish live watches after a workspace-server respawn — the old SSE
// subscriptions keep retrying the dead port and never recover on their own.
wsServer.on(WorkspaceServerEvent.StatusChanged, ({ status }) => {
if (status === WorkspaceServerStatus.Ready) {
fileWatcherBridge.resubscribeAll();
}
});
container.bind(FILE_WATCHER_SERVICE).toConstantValue(fileWatcherBridge);
container.bind(FILE_WATCHER_CONTROL).toConstantValue(fileWatcherBridge);
container.bind(FOCUS_WORKSPACE_CLIENT).toConstantValue(workspaceClient);
Expand Down
39 changes: 38 additions & 1 deletion apps/code/src/main/services/auth/port-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ import {
AUTH_PREFERENCE_REPOSITORY,
AUTH_SESSION_REPOSITORY,
WORKSPACE_CLIENT,
WORKSPACE_SERVER_SERVICE,
} from "../../di/tokens";
import { decrypt, encrypt } from "../../utils/encryption";
import {
WorkspaceServerEvent,
type WorkspaceServerService,
WorkspaceServerStatus,
} from "../workspace-server/service";

@injectable()
export class TokenCipherPortAdapter implements IAuthTokenCipher {
Expand Down Expand Up @@ -149,12 +155,43 @@ export class AuthPreferencePortAdapter implements IAuthPreferenceStore {
export class ConnectivityPortAdapter implements IAuthConnectivity {
private isOnline = true;
private readonly handlers = new Set<(status: ConnectivityStatus) => void>();
private sub: { unsubscribe: () => void } | null = null;
private readonly onServerStatusChanged = ({
status,
}: {
status: WorkspaceServerStatus;
}): void => {
if (status === WorkspaceServerStatus.Ready) this.subscribe();
};

constructor(
@inject(WORKSPACE_CLIENT)
private readonly workspace: WorkspaceClient,
@inject(WORKSPACE_SERVER_SERVICE)
private readonly workspaceServer: WorkspaceServerService,
) {
this.workspace.connectivity.onStatusChange.subscribe(undefined, {
this.subscribe();
// The workspace-server child respawns on a new port after a crash; the
// old SSE subscription keeps retrying the dead port forever, so re-
// establish it against the current connection once the server is healthy.
this.workspaceServer.on(
WorkspaceServerEvent.StatusChanged,
this.onServerStatusChanged,
);
}

dispose(): void {
this.workspaceServer.off(
WorkspaceServerEvent.StatusChanged,
this.onServerStatusChanged,
);
this.sub?.unsubscribe();
this.sub = null;
}

private subscribe(): void {
this.sub?.unsubscribe();
this.sub = this.workspace.connectivity.onStatusChange.subscribe(undefined, {
onData: (status) => {
this.isOnline = status.isOnline;
for (const handler of this.handlers) {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,25 @@ describe("TaskCreationSaga", () => {
);
});

it("creates the task without a repository when repo detection fails", async () => {
const createTaskMock = vi.fn().mockResolvedValue(createTask());
mockHost.addFolder.mockResolvedValue({ id: "folder-1", path: "/repo" });
mockHost.detectRepo.mockRejectedValue(new TypeError("fetch failed"));

const saga = makeSaga({ createTask: createTaskMock });

const result = await saga.run({
content: "Ship the fix",
repoPath: "/repo",
workspaceMode: "worktree",
});

expect(result.success).toBe(true);
expect(createTaskMock).toHaveBeenCalledWith(
expect.objectContaining({ repository: undefined }),
);
});

it("rolls back the import snapshot and tracking row when a later step fails", async () => {
const createdTask = createTask();
const createTaskMock = vi.fn().mockResolvedValue(createdTask);
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,8 +600,18 @@ export class TaskCreationSaga extends Saga<

const repoPathForDetection = input.repoPath;
if (!repository && repoPathForDetection) {
// Detection only fills the org/repo metadata on the task; a transient
// failure (e.g. the workspace-server transport dropping mid-call) must
// not abort task creation, so degrade to an untagged task instead.
const detected = await this.readOnlyStep("repo_detection", () =>
this.deps.host.detectRepo({ directoryPath: repoPathForDetection }),
this.deps.host
.detectRepo({ directoryPath: repoPathForDetection })
.catch((error) => {
this.log.warn("Repo detection failed; creating task without one", {
error,
});
return null;
}),
);
if (detected) {
repository = `${detected.organization}/${detected.repository}`;
Expand Down
4 changes: 3 additions & 1 deletion packages/workspace-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "node ../../scripts/rimraf.mjs .turbo"
},
"dependencies": {
Expand All @@ -30,6 +31,7 @@
"@trpc/tanstack-react-query": "catalog:",
"@types/react": "catalog:",
"react": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^4.1.8"
}
}
73 changes: 73 additions & 0 deletions packages/workspace-client/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import {
createReconnectingWorkspaceClient,
type WorkspaceClient,
type WorkspaceConnection,
} from "./client";

const connA: WorkspaceConnection = {
url: "http://127.0.0.1:1111",
secret: "secret-a",
};
const connB: WorkspaceConnection = {
url: "http://127.0.0.1:2222",
secret: "secret-b",
};

function makeFakeClient(name: string): WorkspaceClient {
return { name } as unknown as WorkspaceClient;
}

describe("createReconnectingWorkspaceClient", () => {
it("builds one client per connection and reuses it across accesses", () => {
const buildClient = vi.fn(() => makeFakeClient("a"));
const client = createReconnectingWorkspaceClient(() => connA, buildClient);

expect(Reflect.get(client, "name")).toBe("a");
expect(Reflect.get(client, "name")).toBe("a");
expect(buildClient).toHaveBeenCalledTimes(1);
expect(buildClient).toHaveBeenCalledWith(connA);
});

it("rebuilds the client when the connection changes (server respawn)", () => {
let current = connA;
const buildClient = vi
.fn()
.mockReturnValueOnce(makeFakeClient("a"))
.mockReturnValueOnce(makeFakeClient("b"));
const client = createReconnectingWorkspaceClient(
() => current,
buildClient,
);

expect(Reflect.get(client, "name")).toBe("a");
current = connB;
expect(Reflect.get(client, "name")).toBe("b");
expect(buildClient).toHaveBeenCalledTimes(2);
expect(buildClient).toHaveBeenLastCalledWith(connB);
});

it("keeps using the last known client while the server is down", () => {
let current: WorkspaceConnection | null = connA;
const buildClient = vi.fn(() => makeFakeClient("a"));
const client = createReconnectingWorkspaceClient(
() => current,
buildClient,
);

expect(Reflect.get(client, "name")).toBe("a");
current = null;
expect(Reflect.get(client, "name")).toBe("a");
expect(buildClient).toHaveBeenCalledTimes(1);
});

it("throws when accessed before any connection is established", () => {
const buildClient = vi.fn();
const client = createReconnectingWorkspaceClient(() => null, buildClient);

expect(() => Reflect.get(client, "name")).toThrow(
"workspace-server connection is not established yet",
);
expect(buildClient).not.toHaveBeenCalled();
});
});
43 changes: 43 additions & 0 deletions packages/workspace-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,49 @@ export interface WorkspaceConnection {

export type WorkspaceClient = ReturnType<typeof createWorkspaceClient>;

/**
* A workspace client that follows the host's current connection. The
* workspace-server child can crash and be respawned on a new port with a new
* secret; a client built once at boot would keep calling the dead port
* ("fetch failed") for the rest of the app session. This proxy re-reads the
* connection at the start of every call chain and rebuilds the underlying
* client whenever it changes. While the server is down (`getConnection`
* returns null) the last known client is used, so calls fail fast against the
* old port instead of hanging.
*/
export function createReconnectingWorkspaceClient(
getConnection: () => WorkspaceConnection | null,
buildClient: (
connection: WorkspaceConnection,
) => WorkspaceClient = createWorkspaceClient,
): WorkspaceClient {
let built: {
connection: WorkspaceConnection;
client: WorkspaceClient;
} | null = null;

const resolve = (): WorkspaceClient => {
const connection = getConnection() ?? built?.connection;
if (!connection) {
throw new Error("workspace-server connection is not established yet");
}
if (
!built ||
built.connection.url !== connection.url ||
built.connection.secret !== connection.secret
) {
built = { connection, client: buildClient(connection) };
}
return built.client;
};

return new Proxy({} as WorkspaceClient, {
get: (_target, prop) => Reflect.get(resolve() as object, prop),
has: (_target, prop) => Reflect.has(resolve() as object, prop),
getPrototypeOf: () => Reflect.getPrototypeOf(resolve() as object),
});
Comment on lines +55 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Proxy only traps get

The reconnecting proxy intercepts only get, so any code that uses in, Object.keys(), Object.getOwnPropertyDescriptor(), or getPrototypeOf() against the wrapped client will see the empty {} target instead of the real tRPC client. This isn't a problem today — tRPC clients don't rely on those traps — but it makes the wrapper silently wrong for callers that might probe the object. Adding has and getPrototypeOf traps would make it a complete transparent proxy.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}

export function createWorkspaceClient(connection: WorkspaceConnection) {
const url = `${connection.url.replace(/\/$/, "")}/trpc`;
const headers = { [SECRET_HEADER]: connection.secret };
Expand Down
12 changes: 12 additions & 0 deletions packages/workspace-client/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
import { trunkTestOptions } from "../../vitest.config.base";

export default defineConfig({
test: {
globals: true,
...trunkTestOptions,
environment: "node",
include: ["src/**/*.test.ts"],
exclude: ["**/node_modules/**", "**/dist/**"],
},
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading