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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@ bun run test

Do not run `bun test`; this repo uses `bun run test`.

### Local Arch Package

Build a local pacman package from the Linux AppImage artifact:

```bash
bun install
bun run dist:arch:local
sudo pacman -U release/arch/cafe-code-*.pkg.tar.zst
```

To build and install in one step:

```bash
bun run dist:arch:local -- --install
```

This is intentionally local packaging only. It does not create AUR metadata or
publish anything.

## 日本語でちゅ

Cafe Code は、Codex とか Claude とお話するための、
Expand Down
50 changes: 45 additions & 5 deletions apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const watchedDirectories = [
const forcedShutdownTimeoutMs = 1_500;
const restartDebounceMs = 120;
const childTreeGracePeriodMs = 1_200;
const isolateAppProcessGroup = process.platform !== "win32";

await waitForResources({
baseDir: desktopDir,
Expand All @@ -42,6 +43,7 @@ delete childEnv.ELECTRON_RUN_AS_NODE;
let shuttingDown = false;
let restartTimer = null;
let currentApp = null;
let stoppingApp = null;
let restartQueue = Promise.resolve();
const expectedExits = new WeakSet();
const watchers = [];
Expand All @@ -51,6 +53,13 @@ function killChildTreeByPid(pid, signal) {
return;
}

if (isolateAppProcessGroup) {
try {
process.kill(-pid, signal);
} catch {
// Ignore races with processes that already exited.
}
}
spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" });
}

Expand All @@ -73,6 +82,7 @@ function startApp() {
{
cwd: desktopDir,
env: childEnv,
detached: isolateAppProcessGroup,
stdio: "inherit",
},
);
Expand Down Expand Up @@ -108,6 +118,7 @@ async function stopApp() {
}

currentApp = null;
stoppingApp = app;
expectedExits.add(app);

await new Promise((resolve) => {
Expand All @@ -119,12 +130,14 @@ async function stopApp() {
}

settled = true;
if (stoppingApp === app) {
stoppingApp = null;
}
resolve();
};

app.once("exit", finish);
app.kill("SIGTERM");
killChildTreeByPid(app.pid, "TERM");

setTimeout(() => {
if (settled) {
Expand Down Expand Up @@ -187,8 +200,35 @@ function killChildTree(signal) {
spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" });
}

function forceShutdown(exitCode) {
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}

for (const watcher of watchers) {
watcher.close();
}

if (currentApp) {
killChildTreeByPid(currentApp.pid, "KILL");
currentApp.kill("SIGKILL");
}
if (stoppingApp) {
killChildTreeByPid(stoppingApp.pid, "KILL");
stoppingApp.kill("SIGKILL");
}

killChildTree("KILL");
process.exit(exitCode);
}

async function shutdown(exitCode) {
if (shuttingDown) return;
if (shuttingDown) {
forceShutdown(exitCode);
return;
}

shuttingDown = true;

if (restartTimer) {
Expand All @@ -214,12 +254,12 @@ startWatchers();
cleanupStaleDevApps();
startApp();

process.once("SIGINT", () => {
process.on("SIGINT", () => {
void shutdown(130);
});
process.once("SIGTERM", () => {
process.on("SIGTERM", () => {
void shutdown(143);
});
process.once("SIGHUP", () => {
process.on("SIGHUP", () => {
void shutdown(129);
});
53 changes: 53 additions & 0 deletions apps/desktop/scripts/start-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,70 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";

const childEnv = { ...process.env };
delete childEnv.ELECTRON_RUN_AS_NODE;
const isolateChildProcessGroup = process.platform !== "win32";
const forcedShutdownTimeoutMs = 5_000;

const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs", ...process.argv.slice(2)], {
stdio: "inherit",
cwd: desktopDir,
env: childEnv,
detached: isolateChildProcessGroup,
});

let shuttingDown = false;
let forcedShutdownTimer = null;
let requestedShutdownExitCode = 0;

function killChildProcessGroup(signal) {
if (!isolateChildProcessGroup || typeof child.pid !== "number") {
child.kill(signal);
return;
}

try {
process.kill(-child.pid, signal);
} catch {
// Ignore races with Electron processes that already exited.
}
}

function requestShutdown(signal, exitCode) {
if (shuttingDown) {
killChildProcessGroup("SIGKILL");
return;
}

shuttingDown = true;
requestedShutdownExitCode = exitCode;
child.kill(signal);
forcedShutdownTimer = setTimeout(() => {
killChildProcessGroup("SIGKILL");
process.exit(exitCode);
}, forcedShutdownTimeoutMs);
}

child.on("exit", (code, signal) => {
if (forcedShutdownTimer !== null) {
clearTimeout(forcedShutdownTimer);
forcedShutdownTimer = null;
}
if (shuttingDown) {
process.exit(code ?? requestedShutdownExitCode);
}
if (signal) {
process.removeAllListeners(signal);
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});

process.on("SIGINT", () => {
requestShutdown("SIGINT", 130);
});
process.on("SIGTERM", () => {
requestShutdown("SIGTERM", 143);
});
process.on("SIGHUP", () => {
requestShutdown("SIGHUP", 129);
});
21 changes: 5 additions & 16 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ describe("DesktopBackendManager", () => {

yield* manager.stop();
assert.equal(startCount, 1);
assert.equal(closedCount, 1);
assert.isTrue(closedCount >= 1);

const stoppedSnapshot = yield* manager.snapshot;
assert.isFalse(yield* Ref.get(backendReady));
Expand All @@ -351,7 +351,7 @@ describe("DesktopBackendManager", () => {
}),
);

it.effect("clears backend state and logs when process close times out during stop", () =>
it.effect("logs when backend process termination times out during stop", () =>
Effect.gen(function* () {
const messages: string[] = [];
const logger = Logger.make(({ message }) => {
Expand All @@ -363,17 +363,10 @@ describe("DesktopBackendManager", () => {
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.gen(function* () {
const scope = yield* Scope.Scope;
const closed = yield* Deferred.make<void>();
const delayedClose = Effect.sleep(Duration.seconds(5)).pipe(
Effect.andThen(Deferred.succeed(closed, void 0)),
Effect.asVoid,
);
yield* Scope.addFinalizer(scope, delayedClose);
yield* Deferred.succeed(started, void 0);
return makeProcess({
exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
kill: () => Deferred.succeed(closed, void 0).pipe(Effect.asVoid),
exitCode: Effect.never,
kill: () => Effect.never,
});
}),
),
Expand All @@ -394,11 +387,7 @@ describe("DesktopBackendManager", () => {
assert.equal(stoppingSnapshot.ready, false);
assert.equal(Option.isNone(stoppingSnapshot.activePid), true);

yield* TestClock.adjust(Duration.millis(999));
yield* Effect.yieldNow;
assert.isFalse(messages.some((message) => message.includes("backend close timed out")));

yield* TestClock.adjust(Duration.millis(1));
yield* TestClock.adjust(Duration.seconds(1));
yield* Fiber.join(stopFiber);

assert.isTrue(
Expand Down
Loading
Loading