Skip to content
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ This file is maintained by hand. There is no automated changelog tooling (see
`docs/CI.md#release-policy` for why) — update this file as part of the commit that bumps the
package versions for a release.

## Unreleased

- **`config.json`'s `wssPort` accepts `0`, meaning "bind an OS-assigned port".** The pinned-wss
listener takes whatever ephemeral port the OS hands it, and everything that reports or advertises
the port — `daemon.status`'s `wssPort`, a minted link's `endpoint.port`, and so the deep link and
QR code composed from it — carries the *bound* port rather than the configured `0`. This lets
several daemons (separate state dirs) coexist on one machine without an operator hand-picking a
port for each. Every other value must still be a port number in `1..65535`; the default is
unchanged at `8443`.

- **Fixed: a zombie daemon process no longer blocks pidfile takeover.** `process.kill(pid, 0)`
succeeds for an exited-but-unreaped process, so a daemon that was killed after its parent CLI had
exited could keep its pidfile looking live — in containers whose PID 1 does not reap, for the
life of the container, leaving every later command reporting a daemon that was already dead. The
liveness probe now also reads `/proc/<pid>/status` on Linux and treats `State: Z` as dead;
everywhere `/proc` is absent or unreadable the previous behaviour is unchanged.

## 0.10.0 (2026-09-16)

- **New: native SDKs for apps without React Native.** The same Appduct core the React Native
Expand Down
21 changes: 20 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ The daemon refuses to load a key file that is group/world-readable.
}
```

`wssPort` is the pinned-wss listener's TCP port. **`0` binds an OS-assigned port**: the listener
takes whatever ephemeral port the OS hands it, and everything that reports or advertises the port
afterwards — `daemon.status`'s `wssPort` (§5) and a minted link's `endpoint.port` (§5, §8) — carries
the *bound* port, never the configured `0`. That is how several daemons coexist on one machine
without an operator hand-picking a port for each (the test suite's daemons all run this way).
Any other value must be a port number in `1..65535`.

`advertisedIp` overrides auto-detection of the address advertised in minted bootstrap
payloads. `scheme` is the deep-link URI scheme composed into `appduct link`'s output
when `--scheme` is not passed (§10) — set it once here instead of on every invocation.
Expand Down Expand Up @@ -153,7 +160,19 @@ only-when-no-sessions-are-live (§4, "Version drift").
(3) polls the socket until ready (timeout 5 s), (4) retries the original request.
A stale socket file with a dead pid is unlinked before spawning.
- Single instance is enforced via the pidfile (write with `O_EXCL`; on conflict, check
liveness with `process.kill(pid, 0)` and take over only if dead).
liveness and take over only if dead). Liveness is `process.kill(pid, 0)` — with `EPERM` counted
as alive — plus, on Linux, a `/proc/<pid>/status` read that treats `State: Z` (zombie) as **dead**.
A zombie is an exited process nobody has reaped: it still holds a pid table entry, so
`process.kill(pid, 0)` succeeds for it, but the daemon it names is gone and its socket is closed.
Normally that window is invisible because PID 1 reaps orphans immediately; in a container whose
PID 1 is a plain command rather than an init, nothing reaps, and a daemon killed after its parent
CLI exited stays a zombie for the life of the container — without this check the pidfile would
never look stale and every later command would report a daemon that is already dead. The procfs
read only ever adds a "dead" verdict on positive evidence: where `/proc` is absent or unreadable
(macOS, a hardened container, a pid we lack permission on) `process.kill(pid, 0)`'s answer stands,
because wrongly declaring a *live* daemon dead would clobber its state. The same probe
(`isProcessAlive`, `daemon/pidfile.ts`) answers every other "is a daemon still there?" question —
the auto-spawn path's stale-socket unlink and `daemon.log` rotation — so all three agree.
- SIGINT/SIGTERM: close all device sockets with code 1001, remove `daemon.sock` and
`daemon.pid`, flush audit, exit 0.
- **Version drift:** the daemon outlives the CLI that spawned it, so `npm i -g appduct@<newer>`
Expand Down
42 changes: 16 additions & 26 deletions packages/appduct/src/__tests__/cli-v2.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
* `tool-invocation.integration.test.ts`, just through the CLI instead of raw UDS RPC.
*/

import { createServer as createNetServer } from "node:net";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { text } from "node:stream/consumers";

Expand All @@ -18,7 +15,12 @@ import WebSocket from "ws";

import { decodeBootstrap } from "@appduct/shared";

import { spawnCliBinary, waitForExit, writeTestHostKey } from "./fixtures.js";
import {
makeTempStateDir as makeSharedStateDir,
removeStateDir,
spawnCliBinary,
waitForExit,
} from "./fixtures.js";

// The fake app client below skips pinning (that is the app SDK's job, exercised in
// session-engine.integration.test.ts); the leaf-cert check is disabled process-wide for this
Expand Down Expand Up @@ -50,30 +52,14 @@ afterEach(async () => {
}

while (stateDirs.length > 0) {
await rm(stateDirs.pop()!, { force: true, recursive: true });
await removeStateDir(stateDirs.pop()!);
}
});

const pickFreePort = async (): Promise<number> => {
return new Promise((resolve, reject) => {
const server = createNetServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address !== "string" ? address.port : 0;
server.close(() => resolve(port));
});
});
};

const makeTempStateDir = async (configOverrides: Record<string, unknown> = {}): Promise<string> => {
const directory = await mkdtemp(path.join(tmpdir(), "appduct-cli-v2-"));
await writeTestHostKey(path.join(directory, "key.pem"));

const port = await pickFreePort();
await writeFile(
path.join(directory, "config.json"),
JSON.stringify({ wssPort: port, advertisedIp: "127.0.0.1", scheme: "appduct-e2e", ...configOverrides }),
const directory = await makeSharedStateDir(
{ scheme: "appduct-e2e", ...configOverrides },
{ prefix: "appduct-cli-v2-" },
);

stateDirs.push(directory);
Expand Down Expand Up @@ -132,7 +118,6 @@ describe("appduct CLI v2: end-to-end command table", () => {
"keygen -> ls auto-spawns -> link -> claim -> ls ACTIVE -> tools/invoke round-trip -> revoke",
async () => {
const stateDir = await makeTempStateDir();
const port = JSON.parse(await readFile(path.join(stateDir, "config.json"), "utf8")).wssPort as number;

// keygen: fully non-interactive, refuses to overwrite without --force.
const keygenPath = path.join(stateDir, "operator-key.pem");
Expand All @@ -152,6 +137,10 @@ describe("appduct CLI v2: end-to-end command table", () => {
const status = await runCliJson(["daemon", "status"], stateDir);
expect(status.ok).toBe(true);
daemonPids.push((status.data as { daemon: { pid: number } }).daemon.pid);
// The state dir asks for an OS-assigned wss port (`wssPort: 0`), so the number is only
// knowable from the running daemon — which is also what a link must end up advertising.
const port = (status.data as { daemon: { wss_port: number } }).daemon.wss_port;
expect(port).toBeGreaterThan(0);

// link: mint a pending session and decode its deep link.
const linkResult = await runCliJson(["link", "--ttl", "60"], stateDir);
Expand Down Expand Up @@ -274,7 +263,8 @@ describe("appduct CLI v2: end-to-end command table", () => {
expect(status.ok).toBe(true);
daemonPids.push((status.data as { daemon: { pid: number } }).daemon.pid);

const port = JSON.parse(await readFile(path.join(stateDir, "config.json"), "utf8")).wssPort as number;
const port = (status.data as { daemon: { wss_port: number } }).daemon.wss_port;
expect(port).toBeGreaterThan(0);

const claimOne = async (deviceModel: string): Promise<{ socket: WebSocket; alias: string }> => {
const linkResult = await runCliJson(["link", "--ttl", "60"], stateDir);
Expand Down
20 changes: 13 additions & 7 deletions packages/appduct/src/__tests__/daemon-cli.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { stat } from "node:fs/promises";
import path from "node:path";

import { afterEach, describe, expect, test } from "vitest";

import { runCliBinary, spawnCliBinary, waitForExit, writeTestHostKey } from "./fixtures.js";
import {
makeTempStateDir as makeSharedStateDir,
removeStateDir,
runCliBinary,
spawnCliBinary,
waitForExit,
} from "./fixtures.js";

const stateDirs: string[] = [];
const daemonPids: number[] = [];

/** The shared fixture's `wssPort: 0` matters here: every case below auto-spawns or runs a real
* daemon, and this file used to write no `config.json` at all, so each one bound the default 8443
* and collided with any other daemon on the machine. */
const makeTempStateDir = async (): Promise<string> => {
const directory = await mkdtemp(path.join(tmpdir(), "appduct-daemon-cli-"));
await writeTestHostKey(path.join(directory, "key.pem"));
const directory = await makeSharedStateDir({}, { prefix: "appduct-daemon-cli-" });
stateDirs.push(directory);
return directory;
};
Expand All @@ -38,8 +45,7 @@ afterEach(async () => {
}

while (stateDirs.length > 0) {
const directory = stateDirs.pop()!;
await rm(directory, { force: true, recursive: true });
await removeStateDir(stateDirs.pop()!);
}
});

Expand Down
Loading
Loading