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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.node binary
54 changes: 52 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@ on: [push, pull_request]
permissions:
contents: read
jobs:
macos:
runs-on: macos-latest
platforms:
strategy:
fail-fast: false
matrix:
os:
[
macos-latest,
macos-15-intel,
ubuntu-latest,
ubuntu-24.04-arm,
windows-latest,
windows-11-arm,
]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -16,6 +28,14 @@ jobs:
- run: npm run typecheck
- run: npm run build
- run: npm test
- name: Hidden live processes fail closed
if: runner.os == 'Linux'
run: |
fixture=$(mktemp -d)
cp -R index.mjs lib test "$fixture/"
chmod -R a+rX "$fixture"
sudo unshare --mount --pid --fork --mount-proc sh -c 'mount -o remount,hidepid=2 /proc; exec "$@"' sh "$(command -v node)" "$fixture/test/hidepid.fixture.mjs"
- run: npm run test:package
- uses: actions/setup-node@v4
with:
node-version: 24
Expand All @@ -24,3 +44,33 @@ jobs:
with:
node-version: 26
- run: npm test
- uses: actions/setup-node@v4
with:
node-version: 20.19.4
- run: npm test
- uses: actions/upload-artifact@v4
if: runner.os != 'Linux'
with:
name: prebuild-${{ runner.os }}-${{ runner.arch }}
path: prebuilds/
package:
needs: platforms
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts
- uses: actions/download-artifact@v4
with:
pattern: prebuild-*
path: prebuilds
merge-multiple: true
- run: npm run pack:release
- run: npm run test:package
- uses: actions/upload-artifact@v4
with:
name: npm-package
path: unique-pid-*.tgz
124 changes: 35 additions & 89 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,116 +1,62 @@
# unique-pid

Persist a process identity. Later, check whether that PID still belongs to the
same process.
Save a process ID and identify the same process later.

**Early macOS prototype. Not published to npm or ready for production cleanup.**
Operating systems recycle PIDs. A PID saved in a file can eventually belong to
an unrelated process. `unique-pid` combines the PID with its exact OS-reported
start identity in a serializable token, so you can tell them apart.

Operating systems reuse process IDs. A saved PID alone can refer to an unrelated
process after the original exits. `unique-pid` pairs it with its exact
kernel-reported start time and boot-session identity.
Capture the token when a process starts, store it in a file or database, and
check it later, even from another invocation of your CLI. Supports macOS,
Linux, and Windows. No `ps` required.

```js
import { capture, check } from "./index.mjs";

const captured = capture(child.pid);
if (captured.status === "captured") {
// Persist captured.token in trusted application state.
const result = check(captured.token);
console.log(result.status); // same | different | gone | unknown
import { capture, check } from "unique-pid";

const captured = capture(process.pid);
if (!captured.ok) {
console.error(captured.error);
} else {
const checked = check(captured.value);
console.log(checked); // { ok: true, value: 'same' }
}
```

Both operations use the same native reader. There is no approximate timestamp,
`Date.now()`, shell command, `ps`, or running-process listing.

## API

### `capture(pid)`

Returns `{ status: 'captured', token }`, `{ status: 'gone', errno }`, or
`{ status: 'unknown', errno }`. Invalid PIDs throw.

### `check(token)`

Returns one of:

| Status | Meaning |
| ----------- | ---------------------------------------------------------------------- |
| `same` | The observed PID, kernel start time, and boot session match. |
| `different` | The PID exists, but the recorded identity does not match. |
| `gone` | The OS explicitly reports that the process does not exist. |
| `unknown` | Permission denial or another inspection failure prevents verification. |

Malformed tokens throw before querying the OS. Never treat `unknown` as `gone`.
`same` is a point-in-time identity observation, not an app-readiness check or
proof that the process is not a zombie.

### `decode(token)`

Returns the validated token fields for diagnostics. Tokens contain a format
version, platform, boot UUID, PID, and exact start seconds/microseconds stored as
decimal strings. Base64url is encoding, not encryption or authentication.
All functions return `{ ok: true, value }` or
`{ ok: false, error: { code, message } }`. Invalid input, unavailable native
binaries, and permission failures are results, not thrown exceptions.

## Implementation
- `capture(pid)` returns a serializable identity token.
- `check(token)` returns `same`, `different`, or `gone`.
- `decode(token)` returns the token's validated fields for inspection.

A small C addon uses the stable Node-API 8 ABI. The JavaScript layer owns token
encoding and validation, with TypeScript declarations. macOS uses
`proc_pidinfo(PROC_PIDTBSDINFO)` and `sysctlbyname("kern.bootsessionuuid")`.
It does not embed Python or compile a helper executable at runtime.
An inspection error is not proof that a process is gone. Tokens use exact
OS start values, not approximate dates. Linux also includes boot and PID
namespace identity; macOS includes boot identity. Windows uses the process's
64-bit creation time.

The initial prototype's same compiled addon was tested on Node 22, 24, and 26.
It worked under a sandbox that denied `/bin/ps`; a stricter sandbox denied native
inspection and correctly produced `unknown`. This is not a sandbox bypass or a
guarantee that every environment permits inspection.

## Safety boundaries

- Identity is not ownership. Only manage processes your application owns, and
keep tokens in trusted state. A forged token is not an authorization grant.
- Capture promptly after spawn and account for early exit. An arbitrary-PID
query cannot establish that the caller launched that process. A cooperating
child can send its self-captured token through IPC.
- Rechecking then signaling is not atomic on macOS. This prototype deliberately
exposes no `kill` or `terminate` API. Calling `process.kill()` after `check()`
still has a time-of-check/time-of-use race.
- Identifying a group leader does not establish ownership of all descendants.
- Tokens are local to the originating host/boot context. They are not portable
process references across machines or PID namespaces.
- Changes to command titles or an exec transition do not necessarily change
process identity; executable role and readiness need separate checks.

The idea follows psutil's PID-plus-creation-time identity model, not a full port
of its monitoring API. See [psutil's PID reuse documentation](https://psutil.io/faq/#pid-reuse).
Tokens belong in trusted state on the originating machine. They are not
credentials. Identity checks do not prove ownership or make a later PID-based
signal atomic. This package does not terminate processes.

## Development

Requires macOS, Node 22+, Python, and Xcode Command Line Tools for the development
build. End-user prebuilt distribution is planned, not implemented yet. The
package remains private in package.json to prevent accidental npm publication.
Node 20.19.4+ (20.x) or 22.12+. Native development
builds require Python and Xcode Command Line Tools on macOS, or Visual Studio
C++ Build Tools on Windows. Linux reads procfs directly.

```sh
npm ci --ignore-scripts
npm run build
npm run format:check
npm run typecheck
npm run format:check
npm test
```

Tests inspect only short-lived children they create and stop them through IPC.
They cover exact repeated reads, parent/child agreement, fresh-process token
verification, timestamp and boot mismatches, malformed tokens, invalid PIDs,
title changes, and actual process exit. Mismatches are simulated against a live
owned child; tests do not force PID recycling or change the system clock.

## Next steps

- Linux and Windows backends with exact native identity fields.
- Prebuilt binaries, package installation tests, and platform/architecture CI.
- Additional denial, restart, reboot, clock-change, and short-lived-child tests.
- A lifecycle API only after its ownership and race semantics are defined;
use retained OS handles where supported instead of promising atomic safety
from a serialized token alone.

## License
CI builds and tests x64 and ARM64 binaries for macOS and Windows, then
assembles the release tarball with `npm run pack:release`. Installing that
package needs no compiler or install scripts. Linux reads procfs directly.

MIT
14 changes: 10 additions & 4 deletions binding.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
"target_name": "identity",
"sources": ["src/identity.c"],
"defines": ["NAPI_VERSION=8"],
"xcode_settings": {
"GCC_C_LANGUAGE_STANDARD": "c11",
"WARNING_CFLAGS": ["-Wall", "-Wextra", "-Werror"]
}
"conditions": [
["OS=='mac'", {
"sources": ["src/darwin.c"],
"xcode_settings": {
"GCC_C_LANGUAGE_STANDARD": "c11",
"WARNING_CFLAGS": ["-Wall", "-Wextra", "-Werror"]
}
}],
["OS=='win'", { "sources": ["src/win32.c"] }]
]
}]
}
35 changes: 19 additions & 16 deletions index.d.mts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
export type ErrorCode =
| "INVALID_ARGUMENT"
| "INVALID_TOKEN"
| "NOT_FOUND"
| "ACCESS_DENIED"
| "UNSUPPORTED_PLATFORM"
| "NATIVE_UNAVAILABLE"
| "INSPECTION_FAILED";
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: { code: ErrorCode; message: string } };
export interface ProcessIdentity {
v: 1;
platform: "darwin";
bootId: string;
pid: number;
seconds: string;
micros: string;
readonly version: 1;
readonly platform: "darwin" | "linux" | "win32";
readonly pid: number;
readonly bootId: string | null;
readonly startTime: string;
}

export type Unavailable = { status: "gone" | "unknown"; errno: number };

export declare function capture(
pid: number,
): { status: "captured"; token: string } | Unavailable;

export declare function capture(pid: number): Result<string>;
export declare function check(
token: string,
): { status: "same" | "different" } | Unavailable;

export declare function decode(token: string): ProcessIdentity;
): Result<"same" | "different" | "gone">;
export declare function decode(token: string): Result<ProcessIdentity>;
Loading
Loading