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: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ authors:
affiliation: "Brookhaven National Laboratory"
license: BSD-3-Clause
repository-code: "https://github.com/NSLS2/PowderLine"
version: 0.1.1
version: 0.1.2
identifiers:
- type: doi
value: "10.48550/arXiv.2608.17009"
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,18 @@ needed), which is a large speedup across many refinements. Manage it directly,
or opt out per run with `--no-server`:

```bash
pixi run gsas-server status # start | status | stop | restart
pixi run gsas-server status # start | status | stop | restart | logs
pixi run kicker <recipe.json> --no-server # force an isolated one-shot subprocess
```

The server is per-user: it listens on loopback only and accepts only requests
signed with a secret token kept in a directory only you can read; your client
in turn only accepts results signed with that token, so other users on the
same machine can neither use your server nor impersonate it. To switch it off
— e.g. on a shared workstation — set `POWDERLINE_NO_SERVER=1`: default
(`auto`) runs then go in-process, explicit server mode (`--use-server` /
`execution_mode='server'`) fails, and nothing is auto-started.

**Simulate a pattern from Materials Project** (requires an MP API key in
`.powderline_config.yaml` — see `.powderline_config.yaml.example` — or in the
`MP_API_KEY` environment variable):
Expand Down
42 changes: 35 additions & 7 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ class SizeBroadening(BaseModel):

**src/powderline/gsas_server.py / gsas_client.py**:
- Persistent FastAPI server keeping GSAS-II loaded in memory, and the HTTP client that talks to it (with in-process subprocess fallback)
- Per-user and authenticated by signed requests/responses (private state dir, loopback, ephemeral port, single instance via a kernel lock); `POWDERLINE_NO_SERVER=1` disables it
- **Role**: Execution backends for `run()`'s `server`/`auto` modes

**src/powderline/topas/ + src/powderline/easydiff/ + src/powderline/engine.py**:
Expand Down Expand Up @@ -809,20 +810,47 @@ pixi run gsas-server status # Check if running
pixi run gsas-server stop # Shutdown server
```

**Configure server port (if default port 19471 is in use):**
**Server state and security (per user):** the server binds `127.0.0.1` on a
free ephemeral port and writes its lock, PID, log, and an endpoint file (port +
random secret token) — all mode 0600 — to a private per-user state directory:
`$XDG_RUNTIME_DIR/powderline` (if it is a real directory owned by you), else
`<tempdir>/powderline-<uid>` on POSIX (mode 0700; refused if it is a symlink,
owned by someone else, or group/other-accessible, or if the temp directory
would let others replace it), and `%LOCALAPPDATA%\powderline` on Windows. On
POSIX the files are opened relative to a re-verified, no-follow directory
handle.

The token never travels over the wire: every request carries a fresh nonce, a
timestamp and an HMAC-SHA256 over method, path, nonce, timestamp and body, and
the server rejects anything unsigned, wrongly signed, stale, or replayed —
on every route, including 404/405/422 — before reading the body. Every
response is signed back over the nonce, status and body, and the client
discards unsigned responses. Other users on a shared host therefore can
neither submit jobs to your server nor impersonate it (e.g. by taking over the
port of a crashed server). `gsas-server start` takes a kernel-held lock
(`flock` / `msvcrt.locking` on `server.lock`) for the server's lifetime; the
OS releases it when the process exits, so concurrent auto-starts never spawn
duplicates and leftover PID files can never make a dead server look alive.

**Pin the server port** (only if needed — the default ephemeral port avoids
collisions between users):
```bash
# Set custom port before starting server
export POWDERLINE_SERVER_PORT=19472
pixi run gsas-server start
pixi run kicker input.json # client reads the port from your state directory
```

# Client will automatically detect the port from the powderline_gsas_server.port
# file in the platform temp directory (e.g. /tmp on Linux, %TEMP% on Windows)
pixi run kicker input.json # No additional configuration needed
**Disable the server entirely** (shared workstations, locked-down deployments):
```bash
export POWDERLINE_NO_SERVER=1 # also: true / yes / on
```
With it set, `execution_mode='auto'` (and the CLI default) runs in-process and
never starts a server, `execution_mode='server'` / `--use-server` fail with a
clear error, and `pixi run gsas-server start|restart` refuse.

**View server logs** (`powderline_gsas_server.log` in the platform temp directory):
**View server logs** (`server.log` in the state directory; `status` prints the path):
```bash
tail -f /tmp/powderline_gsas_server.log # Linux; on Windows: %TEMP%\powderline_gsas_server.log
pixi run gsas-server logs
```

**Server benefits:**
Expand Down
69 changes: 49 additions & 20 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,11 +654,30 @@ Not necessarily! High correlation is common and often acceptable.
pixi run kicker input.json --use-server
```

4. **Check server logs if it's not working** — `powderline_gsas_server.log` lives in the platform temp directory (e.g. `/tmp` on Linux, `%TEMP%` on Windows):
4. **Check server logs if it's not working** — `server.log` lives in your
private per-user state directory (`pixi run gsas-server status` prints the
path):
```bash
tail -f /tmp/powderline_gsas_server.log # Linux
pixi run gsas-server logs
```

5. **Check that the server isn't disabled** — if `POWDERLINE_NO_SERVER` is set
(e.g. by a facility launcher), default (`auto`) runs are in-process by
design, explicit server mode (`--use-server` / `execution_mode='server'`)
fails, and `gsas-server start` refuses.

6. **Upgrading from v0.1.1 or earlier?** Older servers used fixed, shared
files in the temp directory and port 19471, and accepted unauthenticated
requests. The current client never uses them, but `gsas-server status|stop`
cannot see them either, so one may still be running (it may have been
auto-started without you noticing). Stop it once:
```bash
pkill -u "$USER" -f gsas_server.py # Linux/macOS
```
On Windows, end the `python ... gsas_server.py start` process in Task
Manager (or `Get-CimInstance Win32_Process | Where-Object CommandLine -like
'*gsas_server.py*' | ForEach-Object { Stop-Process -Id $_.ProcessId }`).

**Performance comparison (post-fix):**
- Server mode: 0.1-0.3s per simulation (10x+ faster)
- Subprocess mode: 1-2s per simulation (GSAS-II loaded every time)
Expand All @@ -676,7 +695,18 @@ If you see "Server communication failed" errors, check the server logs.
❌ Server mode failed: Server not available and fallback disabled
```

**Cause:** Server process failed to start or port already in use.
**Cause:** Server process failed to start, the server is disabled
(`POWDERLINE_NO_SERVER`), or your per-user state directory is unusable.

**How the server is found:** each user's server binds `127.0.0.1` on a free
(ephemeral) port and records its port, PID and a random secret token in a
private per-user state directory — `$XDG_RUNTIME_DIR/powderline` or
`<tempdir>/powderline-<uid>` on Linux/macOS, `%LOCALAPPDATA%\powderline` on
Windows. Clients read only their own user's directory, sign every request
with the token (the token itself is never sent), and only accept responses
signed with it — so another user's server, or another process that took over
the port of a crashed server, is never used. An endpoint whose server process
is gone is ignored.

**Solution:**

Expand All @@ -688,30 +718,29 @@ If you see "Server communication failed" errors, check the server logs.
2. **Stop any hung server processes:**
```bash
pixi run gsas-server stop
# Or manually kill:
pkill -f gsas_server.py
# Or manually kill your own server:
pkill -u "$USER" -f gsas_server.py
```

3. **Check if the port is in use:**
3. **"Refusing to use GSAS-II server state directory ..."** — the directory is
a symlink, owned by another user, or readable/writable by others (e.g.
pre-created in a shared `/tmp`). The server and auto-start are skipped:
default runs fall back to in-process, explicit server mode fails. Remove the
directory named in the message (if it is not yours, ask an administrator)
and it is recreated privately on the next start. **"Refusing to keep the
GSAS-II server state under ..."** means the parent (your `TMPDIR`) would let
other users replace that directory — world-writable without the sticky bit,
or owned by someone else; point `TMPDIR` (or `XDG_RUNTIME_DIR`) at a private
directory.

4. **Pin a port only if you must** (e.g. a firewall rule): by default the
server uses a free ephemeral port, so users never collide. To force one:
```bash
# See if anything is listening on port 19471:
ss -tlnp | grep 19471
```

4. **Use a different port if 19471 is unavailable:**
```bash
# Set custom port via environment variable
export POWDERLINE_SERVER_PORT=19472
pixi run gsas-server start

# Client will automatically detect the port
pixi run kicker input.json # No changes needed
pixi run kicker input.json # the client finds the port automatically
```

The server writes its port to `powderline_gsas_server.port` in the platform temp
directory (e.g. `/tmp` on Linux, `%TEMP%` on Windows), which the client reads
automatically. No code changes needed when using a custom port.

5. **Start server in foreground to see errors:**
```bash
pixi shell
Expand Down
12 changes: 12 additions & 0 deletions docs/cross-platform-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,15 @@ If you prefer native tools, the platform equivalents are:

- **Unix (Linux/macOS)**: `tail -f <log_file>`
- **Windows PowerShell**: `Get-Content <log_file> -Wait -Tail 50`

`<log_file>` is `server.log` in the per-user state directory
(`pixi run gsas-server status` prints it): `$XDG_RUNTIME_DIR/powderline` or
`<tempdir>/powderline-<uid>` on Linux/macOS (POSIX mode 0700, ownership
checked), `%LOCALAPPDATA%\powderline` on Windows. The server's port and token
live in the same directory, and the protocol is the same everywhere: loopback
TCP with requests and responses signed by a token only the owning user can
read. What protects that token differs: POSIX mode/ownership checks (enforced
by PowderLine) on Linux/macOS, the user-profile ACL of `%LOCALAPPDATA%` on
Windows (relied on, not checked — a profile whose ACL was loosened by an
administrator is not detected). The start lock uses `flock` on POSIX and
`msvcrt.locking` on Windows.
6 changes: 6 additions & 0 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ The difference is startup overhead:
not running and fall back to subprocess mode if the server fails to start.
`powderline.run()` uses `GSASClient` internally.

The server is per-user: it is started as, and only accepts jobs from, the
calling user (requests and responses signed with a secret token kept in a
private state directory; loopback only), so it is safe on shared hosts. Integrations that must never run a background server
can set `POWDERLINE_NO_SERVER=1`: `auto` then runs in-process, `server` mode
returns an error result, and no server is ever started.

---

## Example Scripts
Expand Down
70 changes: 70 additions & 0 deletions docs/known_issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,18 @@ discovery/lifecycle mismatch below remains open.
HTTP shutdown endpoint, or have `stop` fall back to the PID reported by
`/health`).

**Update (v0.1.2, KI-13).** The split discovery is resolved. Server state
moved to a per-user directory (`server.lock`, `server.pid`, `server.json` =
port + token; ephemeral port), and `status`/`stop` and clients now agree:
"running" means holding the kernel lock on `server.lock` (released by the OS
on exit, so a leftover PID file or a recycled PID no longer fools `status` or
`stop`), and clients use only the endpoint in `server.json`, verified by
signed responses. **Residual (why this stays open):** if the state directory
itself is deleted while a server runs (e.g. `$XDG_RUNTIME_DIR` removed at
logout, or a tempdir cleaner), that server keeps running unused — clients can
no longer find or authenticate to it — but `status`/`stop` cannot see it
either; it must be killed by hand (`pkill -u "$USER" -f gsas_server.py`).

**Revisit.** Next branch touching `gsas_server.py`/`gsas_client.py`.

---
Expand Down Expand Up @@ -356,3 +368,61 @@ supported topology. Needs protocol versioning for already-running servers.

**Revisit.** Next branch touching the server protocol, or when the HPC
deployment work starts.

---

## KI-13 — GSAS-II server was shared across users on multi-user hosts `implemented · critical`

**What.** Through v0.1.1 the server kept machine-global state
(`<tempdir>/powderline_gsas_server.{port,pid,log}`), listened on a fixed port
(19471) and had no authentication. On a shared workstation: user B's client
found user A's server and ran B's job as A (files owned by A, possibly written
where B could not write); any local user could POST a job with an
`output_dir` inside another user's directories and have it written with that
user's identity; users collided on the port and the shared files; and there
was no global way to keep `powderline.run()` from auto-starting a server.

**Evidence.** `gsas_server.py` (v0.1.1) `PORT_FILE`/`PID_FILE`/`LOG_FILE`,
`DEFAULT_PORT = 19471`, unauthenticated `/simulate`; `gsas_client.py` probed
`/health` on that port and submitted to whatever answered.

**Decision.** Fixed in v0.1.2:

- *Per-user state.* Lock, PID, log and endpoint (port + token), all mode
0600, live in `$XDG_RUNTIME_DIR/powderline` (only if that is a real
directory owned by the user) or `<tempdir>/powderline-<uid>` (POSIX: 0700;
refused if a symlink, foreign-owned or group/other-accessible, or if the
temp directory would let other users replace it; files opened via a
re-verified `O_NOFOLLOW` directory handle), or `%LOCALAPPDATA%\powderline`
(Windows, protected by the profile ACL).
- *Authentication both ways, token never on the wire.* The server binds
`127.0.0.1` on an ephemeral port. Every request must carry a fresh nonce, a
timestamp and an HMAC-SHA256 (keyed by a per-start `secrets.token_urlsafe(32)`
token) over method, path, nonce, timestamp and body; unsigned, wrongly
signed, stale or replayed requests get 401 on every route before the body is
read. Every response is signed over the nonce, status and body, and the
client discards unsigned ones — so a process that takes over the port of a
crashed server learns nothing reusable and cannot fake a result. Clients
also ignore an endpoint whose server process is gone.
- *Single instance.* A kernel-held lock (`flock` / `msvcrt.locking`) on
`server.lock` for the server's lifetime replaces the PID-file lock, which a
stale PID file let several concurrent starters win.
- *Off switch.* `POWDERLINE_NO_SERVER=1|true|yes|on` disables the server:
`auto` runs in-process without starting or contacting one, `server` mode /
`--use-server` error, `gsas-server start|restart` refuse.
- *Upgrade.* A v0.1.1 server still running after the upgrade is never used,
but is invisible to `gsas-server status|stop`; stop it by hand (see
TROUBLESHOOTING).

**Evidence.** `src/powderline/gsas_server.py:119-315` (state directory and
file access), `:318-450` (signing and `SignedRequestMiddleware`), `:476-562`
(start lock), `:847` (`is_server_running`); `src/powderline/gsas_client.py:59-136`
(endpoint, signed requests, identity check). Tests:
`tests/test_gsas_server_unit.py` (state-dir checks, lock incl. a concurrent
start race, signing on every route), `tests/test_gsas_server_http.py` (live
server), `tests/test_gsas_client_visibility.py` (impostor listeners, forged
results, `POWDERLINE_NO_SERVER`), `tests/test_api.py`.

**Revisit.** Closed; kept for history. A Unix-domain-socket / named-pipe
transport would give OS-level peer authentication but needs separate POSIX and
Windows code paths; revisit if the HTTP transport is replaced (see KI-12).
2 changes: 1 addition & 1 deletion pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ authors = ["Dan Olds <dpolds@gmail.com>", "Adam Corrao <95718914+AdamCorrao@user
channels = ["conda-forge"]
name = "PowderLine"
platforms = ["linux-64", "win-64", "osx-arm64"]
version = "0.1.1"
version = "0.1.2"
preview = ["pixi-build"]

[tasks]
Expand Down
2 changes: 1 addition & 1 deletion src/powderline/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""PowderLine: Automated powder diffraction analysis using GSAS-II."""

__version__ = "0.1.1"
__version__ = "0.1.2"

# The GSAS-II-backed public API is imported eagerly when GSAS-II is available,
# preserving the original behaviour exactly (including `powderline.kicker` being
Expand Down
2 changes: 1 addition & 1 deletion src/powderline/_code_hash.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schema_version": "0.26.0",
"kicker_hash": "67826378ba97ed89c55cfb1df7e32ee4"
"kicker_hash": "c5e25670cb609186fedd6ac2e2401b9e"
}
Loading
Loading