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
25 changes: 17 additions & 8 deletions apps/daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr

## State surfaces — all under the profile's state dir

- Default state dir is `~/.linkcode/`; `LINKCODE_PROFILE=<name>` (`[a-z0-9-]`, ≤32 chars, invalid
aborts boot) forks the whole universe to the sibling `~/.linkcode-<name>/` — including `hq.json` /
`device-key.pem`, so each profile registers as its own HQ device (deliberate: the relay allows one
uplink per device id). `~/LinkCode` workspaces and the managed asset store stay shared.
- The state dir is picked by **channel × profile** (CODE-460). Channel comes from
`daemonChannel()` (`src/paths.ts`): injected `LINKCODE_CHANNEL` → tsup's build-time
`LINKCODE_BUILD_CHANNEL` stamp (`release`) → `development`. So `~/.linkcode/` is the released
app's alone and running the source lands in `~/.linkcode.development/`; the suffix is
dot-separated because profile names forbid dots, making collision with `--profile=development`
impossible. `LINKCODE_PROFILE=<name>` (`[a-z0-9-]`, ≤32 chars, invalid aborts boot) then forks
the sibling `~/.linkcode[.development]-<name>/` — including `hq.json` / `device-key.pem`, so each
universe registers as its own HQ device (deliberate: the relay allows one uplink per device id).
Workspaces (`~/LinkCode` vs `~/LinkCode Development`) and the managed asset store fork by channel
but are shared across a channel's profiles. **Resolve the channel per call, never at module
load** — `instrument.ts` derives a state path in its module body, and `--import` runs it before
`index.ts` (the CODE-166 bug class).
- **Paths are owned by `src/config.ts`** (`configPath` / `databasePath` / `runtimeFilePath`) — never
scatter `homedir()` joins elsewhere. `os.homedir()` is read at call time, so a fake `$HOME` fully
redirects config/db/runtime (this is what isolates an E2E daemon).
Expand All @@ -29,13 +37,14 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr
in `packages/foundation/schema`). `LINKCODE_PORT` / `LINKCODE_HOST` override every listener. On `EADDRINUSE` a
listener hunts **upward** up to 10 ports (19523–19532); clients must read `runtime.json`, never
assume 19523.
- **One daemon per profile**, enforced by the daemon (not the desktop supervisor): `main()` calls
- **One daemon per universe (channel × profile)**, enforced by the daemon (not the desktop supervisor): `main()` calls
`findRunningDaemon()` — parse the profile's `runtime.json` → pid alive? → `GET /linkcode` identity
pid matches? A live one makes the new process log `already running (pid N)` and `process.exit(3)`
(`DAEMON_EXIT_ALREADY_RUNNING`) — an **explicit** exit because Electron's `utilityProcess` keeps the
parent IPC channel and the event loop alive forever. The identity (and `runtime.json`) carries an
optional `profile` field (absent = default profile): the port hunt treats a live daemon of
**another** profile as a port neighbor and hunts past it, so profiles coexist on adjacent ports.
parent IPC channel and the event loop alive forever. The identity (and `runtime.json`) carries
optional `profile` and `channel` fields (absent = default profile / release, which is what every
pre-split daemon is): the port hunt treats a live daemon of **another** universe as a port
neighbor and hunts past it, so a dev daemon starts fine on 19524 while a release holds 19523.
Health: `curl http://127.0.0.1:19523/linkcode`.

## Packaging: spawn, binaries & bundle
Expand Down
2 changes: 2 additions & 0 deletions apps/daemon/e2e/startup.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ async function main(): Promise<void> {

let client: LinkCodeClient | null = null;
try {
// Plain `.linkcode`, not the development sibling: this drives the tsup bundle, which is
// stamped `release` at build time (CODE-460). Running the TS source would land elsewhere.
const runtimePath = join(home, '.linkcode', 'runtime.json');
const runtime = await waitFor(
() => {
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/scripts/dev-clean.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { rmSync } from 'node:fs';
import { databasePath, runtimeFilePath } from '../src/config';

// Resolves through config.ts so a fork's renamed STATE_DIR_BASENAME or an active
// Resolves through config.ts so a fork's renamed state dir, the resolved channel, or an active
// LINKCODE_PROFILE cleans the same universe the dev daemon will actually use.
for (const path of [databasePath(), runtimeFilePath()]) rmSync(path, { force: true });
60 changes: 58 additions & 2 deletions apps/daemon/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,23 @@ import {
runtimeFilePath,
} from '../config';
import { logger } from '../logger';
import { telemetryConfigCachePath } from '../paths';
import { daemonChannel, telemetryConfigCachePath } from '../paths';

let savedHome: string | undefined;

// loadConfig() reads ~/.linkcode/config.json; point HOME at a fresh temp dir per test.
// loadConfig() reads the channel's config.json; point HOME at a fresh temp dir per test. The
// channel is pinned to release so these cases keep asserting plain `~/.linkcode` — running the TS
// source would otherwise resolve as development. The channel axis itself is covered further down.
beforeEach(() => {
savedHome = process.env.HOME;
process.env.HOME = mkdtempSync(join(tmpdir(), 'linkcode-config-'));
process.env.LINKCODE_CHANNEL = 'release';
});

afterEach(() => {
process.env.HOME = savedHome;
delete process.env.LINKCODE_PROFILE;
delete process.env.LINKCODE_CHANNEL;
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -134,6 +138,58 @@ describe('profile-scoped state paths', () => {
});
});

// CODE-460: a local build must never land in the installed release's universe — that shared
// `~/.linkcode` is what let a dev daemon hold 19523 and serve a release client frames it drops.
describe('channel-scoped state paths', () => {
it('forks the development channel into its own state directory', () => {
process.env.LINKCODE_CHANNEL = 'development';
const root = join(process.env.HOME ?? '', '.linkcode.development');
expect(daemonChannel()).toBe('development');
expect(databasePath()).toBe(join(root, 'daemon.db'));
expect(runtimeFilePath()).toBe(join(root, 'runtime.json'));
expect(hqCredentialsPath()).toBe(join(root, 'hq.json'));
expect(telemetryConfigCachePath()).toBe(join(root, 'telemetry-config.json'));
});

it('composes the channel with a profile, dot before hyphen', () => {
process.env.LINKCODE_CHANNEL = 'development';
process.env.LINKCODE_PROFILE = 'alpha';
expect(databasePath()).toBe(
join(process.env.HOME ?? '', '.linkcode.development-alpha', 'daemon.db'),
);
});

// The dot separator is what makes this impossible to express as a profile name, so a release
// build can never be talked into the development directory by `--profile=development`.
it('keeps a profile named after the channel out of the development directory', () => {
process.env.LINKCODE_PROFILE = 'development';
expect(databasePath()).toBe(join(process.env.HOME ?? '', '.linkcode-development', 'daemon.db'));
});

it('defaults to development when nothing is injected — an unstamped build is a working copy', () => {
delete process.env.LINKCODE_CHANNEL;
expect(daemonChannel()).toBe('development');
expect(databasePath()).toBe(join(process.env.HOME ?? '', '.linkcode.development', 'daemon.db'));
});

it('aborts on an invalid channel instead of silently picking a universe', () => {
process.env.LINKCODE_CHANNEL = 'prod';
expect(() => daemonChannel()).toThrow(TypeError);
expect(() => databasePath()).toThrow(TypeError);
});

it('lets an injected channel outrank the build stamp', () => {
// The devshell pack ships a bundle stamped `release` inside a development shell.
process.env.LINKCODE_BUILD_CHANNEL = 'release';
process.env.LINKCODE_CHANNEL = 'development';
try {
expect(daemonChannel()).toBe('development');
} finally {
delete process.env.LINKCODE_BUILD_CHANNEL;
}
});
});

describe('loadConfig accounts', () => {
it('keeps valid accounts and drops an invalid one, logging the error', () => {
const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop);
Expand Down
10 changes: 5 additions & 5 deletions apps/daemon/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import {
ProviderConfigSchema,
SimulatorConsentStateSchema,
} from '@linkcode/schema';
import { WORKSPACES_DIRNAME } from '@linkcode/schema/product';
import { workspacesDirName } from '@linkcode/schema/product';
import type { TransportServerOptions } from '@linkcode/transport/server';
import { logger } from './logger';
import { daemonProfile, daemonStateDir } from './paths';
import { daemonChannel, daemonProfile, daemonStateDir } from './paths';

export { daemonProfile } from './paths';
export { daemonChannel, daemonProfile } from './paths';

/**
* Daemon configuration: `config.json` in the profile's state dir (optional) with env overrides.
Expand Down Expand Up @@ -57,7 +57,7 @@ export function databasePath(): string {

/** Runtime discovery file advertising the running daemon's bound endpoints, next to config.json. */
export function runtimeFilePath(): string {
return daemonRuntimeFilePath(daemonProfile());
return daemonRuntimeFilePath(daemonChannel(), daemonProfile());
}

/** HQ sign-in state (session token + registered device id), next to config.json; written 0600. */
Expand All @@ -81,7 +81,7 @@ export function deviceKeysDir(): string {
* independently — a system-plane invariant enforced regardless of which client is connected.
*/
export function chatWorkspaceRoot(): string {
return join(homedir(), WORKSPACES_DIRNAME);
return join(homedir(), workspacesDirName(daemonChannel()));
}

export function loadConfig(): DaemonConfig {
Expand Down
13 changes: 10 additions & 3 deletions apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { installAsarSpawnFix } from './asar-spawn';
import type { DaemonConfig } from './config';
import {
chatWorkspaceRoot,
daemonChannel,
daemonProfile,
databasePath,
loadConfig,
Expand Down Expand Up @@ -135,8 +136,10 @@ const teardown: Runtime.Teardown = (exit, onExit) => {
*/
async function main(): Promise<void> {
// Resolved before anything (subcommands included) touches state paths: an invalid
// LINKCODE_PROFILE must abort here, not mid-command or as a default-profile daemon.
// LINKCODE_PROFILE or LINKCODE_CHANNEL must abort here, not mid-command or as a daemon that
// silently landed in the default universe.
const profile = daemonProfile();
const channel = daemonChannel();

// Subcommands run and exit instead of booting the host (a running daemon
// picks the new sign-in state up on its next restart).
Expand All @@ -152,8 +155,9 @@ async function main(): Promise<void> {
Shared,
Effect.gen(function* () {
const config = loadConfig();
// One daemon per profile — a second instance would share this profile's daemon.db and split
// sessions. Daemons of other profiles live in sibling state dirs and are not visible here.
// One daemon per universe (channel × profile) — a second instance would share this
// universe's daemon.db and split sessions. Daemons of other channels/profiles live in
// sibling state dirs and are not visible here.
const running = yield* Effect.promise(findRunningDaemon);
if (running) {
const urls = running.listeners.map((listener) => listener.url).join(', ');
Expand All @@ -168,6 +172,9 @@ async function main(): Promise<void> {
pid: process.pid,
startedAt: Date.now(),
...(profile !== undefined && { profile }),
// Absent means release on the wire, so only development needs stating — this keeps a
// release daemon's identity and runtime.json byte-identical to pre-split ones.
...(channel !== 'release' && { channel }),
};
const hub = new Hub();
yield* Effect.addFinalizer(() =>
Expand Down
21 changes: 19 additions & 2 deletions apps/daemon/src/paths.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { ProductChannel } from '@linkcode/schema';
import { linkcodeStateDirName, parseProfileName } from '@linkcode/schema';
import { resolveProductChannel } from '@linkcode/schema/product';

/** Profile from `LINKCODE_PROFILE`; invalid names fail boot instead of crossing state universes. */
export function daemonProfile(): string | undefined {
return parseProfileName(process.env.LINKCODE_PROFILE);
}

/** The daemon's profile-aware state directory. Safe to import before logging/Sentry initialization. */
/**
* This build's channel, which picks the whole on-disk universe (CODE-460). `LINKCODE_CHANNEL` —
* injected by the desktop supervisor — outranks the build-time marker, because the devshell pack
* ships a tsup bundle stamped `release` inside a `development` shell. tsup replaces
* `process.env.LINKCODE_BUILD_CHANNEL` with a literal (see its `define`); running the TS source
* leaves it undefined, so a dev daemon defaults to `development` with nothing to remember.
*
* Resolved on every call, never cached at module load: `instrument.ts` derives a state path in its
* module body, and `--import` runs it before `index.ts` — anything hung off entry-point side
* effects would capture the wrong universe (the CODE-166 bug class).
*/
export function daemonChannel(): ProductChannel {
return resolveProductChannel(process.env.LINKCODE_CHANNEL, process.env.LINKCODE_BUILD_CHANNEL);
}

/** The daemon's channel × profile state directory. Safe to import before logging/Sentry initialization. */
export function daemonStateDir(): string {
return join(homedir(), linkcodeStateDirName(daemonProfile()));
return join(homedir(), linkcodeStateDirName(daemonChannel(), daemonProfile()));
}

export function telemetryConfigCachePath(): string {
Expand Down
13 changes: 10 additions & 3 deletions apps/daemon/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,10 @@ async function huntFrom(
if (!isAddrInUse(err)) throw err;
const probeUrl = httpUrl(listener.host, port);
const occupant = await probeDaemonIdentity(probeUrl);
// Our own pid = another of this daemon's listeners hunted onto the port; another profile
// (absent field = default) is an isolated universe, not a double-start — hunt past both.
if (occupant && occupant.pid !== identity.pid && occupant.profile === identity.profile) {
// Our own pid = another of this daemon's listeners hunted onto the port; another universe
// (channel × profile, absent fields = release × default) is isolated, not a double-start —
// hunt past both. This is what lets a dev daemon start while a release one holds 19523.
if (occupant && occupant.pid !== identity.pid && sameUniverse(occupant, identity)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Give development a non-overlapping port range

When a new development daemon occupies 19523 before a pre-CODE-460 installed release starts, the old daemon's Zod schema accepts the response while stripping the unknown channel field, then its old occupant.profile === identity.profile check treats both default profiles as the same daemon and exits 3. The release supervisor consequently stands down without a release runtime.json, so coexistence remains startup-order-dependent; use disjoint channel port ranges or another identity representation older daemons cannot misclassify.

AGENTS.md reference: apps/daemon/AGENTS.md:L40-L47

Useful? React with 👍 / 👎.

throw new DaemonAlreadyRunningError(occupant, probeUrl);
}
if (attempt + 1 >= PORT_HUNT_ATTEMPTS) {
Expand All @@ -105,6 +106,12 @@ async function huntFrom(
}
}

/** Same state universe = same `daemon.db`. Both fields are optional on the wire and default to the
* pre-split universe, so a daemon predating either axis compares equal to release × default. */
function sameUniverse(a: DaemonIdentity, b: DaemonIdentity): boolean {
return (a.channel ?? 'release') === (b.channel ?? 'release') && a.profile === b.profile;
}

/**
* The daemon advertised by the runtime file, or `null`: file missing/malformed (stale leftovers
* are overwritten on next start), pid dead, or the endpoint not answering as a linkcode daemon.
Expand Down
34 changes: 34 additions & 0 deletions apps/daemon/tests/integration/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,40 @@ describe('listenWithPortHunt', () => {
),
).rejects.toBeInstanceOf(DaemonAlreadyRunningError);
});

// CODE-460: this is what lets a dev daemon start while an installed release holds 19523.
// Both default profiles are `undefined`, so without the channel comparison the dev daemon
// reads the release one as a double-start and exits 3.
it('hunts past a live daemon of another channel', async () => {
const port = await serveIdentity(identity(4242));
const { server, url } = await listenWithPortHunt(
{ type: 'ws', port, host: '127.0.0.1' },
{ ...identity(process.pid), channel: 'development' },
);
expect(url).toBe(`ws://127.0.0.1:${port + 1}`);
await server.close();
});

it('refuses to hunt past a live daemon of the same channel', async () => {
const port = await serveIdentity({ ...identity(4242), channel: 'development' });
await expect(
listenWithPortHunt(
{ type: 'ws', port, host: '127.0.0.1' },
{ ...identity(process.pid), channel: 'development' },
),
).rejects.toBeInstanceOf(DaemonAlreadyRunningError);
});

// An absent channel is release's, so a pre-CODE-460 daemon still reads as a double-start.
it('treats an occupant with no channel field as release', async () => {
const port = await serveIdentity(identity(4242));
await expect(
listenWithPortHunt(
{ type: 'ws', port, host: '127.0.0.1' },
{ ...identity(process.pid), channel: 'release' },
),
).rejects.toBeInstanceOf(DaemonAlreadyRunningError);
});
});

describe('findRunningDaemon', () => {
Expand Down
5 changes: 5 additions & 0 deletions apps/daemon/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export default defineConfig({
// Desktop packaging copies only index.js into the asar (electron.vite.config.ts
// bundle-daemon-artifact); a split bundle boots to ERR_MODULE_NOT_FOUND on the missing chunk-*.js.
splitting: false,
// Stamps the channel into the bundle (CODE-460): a built daemon is a release artifact, while
// running the TS source leaves this undefined so `daemonChannel()` defaults to development. The
// desktop supervisor's LINKCODE_CHANNEL still outranks it — the devshell pack ships this very
// bundle inside a development shell.
define: { 'process.env.LINKCODE_BUILD_CHANNEL': JSON.stringify('release') },
// Inlined CJS deps call `require()`; esbuild's ESM output has none, so provide one or boot dies
// with "Dynamic require of ... is not supported". The private alias avoids redeclaring a bundled
// module's own preserved `import { createRequire }` (the banner is prepended after esbuild).
Expand Down
Loading