You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Makes the persistent GSAS-II server safe on machines with more than one user. Each user gets their own server, and only that user can submit jobs to it or receive results from it. The server listens on a free loopback port. Requests and responses are signed with a secret that only its owner can read, and that secret never travels over the wire. This PR also adds POWDERLINE_NO_SERVER, an environment variable that switches the server off.
Problem
Up to v0.1.1 the server behaved as if the machine had a single user:
Its state files (powderline_gsas_server.{port,pid,log}) had fixed names in the system temp directory.
It listened on a fixed port (19471).
It accepted requests without any authentication, and wrote output to whatever output_dir the request named, as the user who started it.
With several users on one machine, this caused four problems:
Jobs ran as the wrong user. One user's client could find another user's server and run its job there. Output files were then owned by the other user, and could land in places the submitting user can't write to.
Any local user could write files as the server's owner. Anyone could send a request to a running server with an output_dir inside the owner's directories.
Users collided. They competed for the same port and the same temp-file names.
No way to turn the server off.--no-server only applies to the CLI. powderline.run() still auto-started a server.
Changes
Per-user state directory
On Linux/macOS: $XDG_RUNTIME_DIR/powderline if that is a real directory owned by the user, otherwise <tempdir>/powderline-<uid>.
It is created with mode 0700 and refused if it is a symlink, owned by someone else, or accessible to group/other users. It is also refused if the temp directory would let another user replace it: writable by others without the sticky bit, or owned by another user. On POSIX, files inside are opened relative to a re-verified, no-follow directory handle.
On Windows: %LOCALAPPDATA%\powderline, protected by the user-profile ACL.
It holds server.lock, server.pid, server.log and server.json ({pid, port, token}), all mode 0600. server.json is written atomically.
Signed requests and signed responses
At each start the server generates a secret with secrets.token_urlsafe(32) and binds 127.0.0.1 on a free port (or POWDERLINE_SERVER_PORT if set).
The secret is never sent over the wire. Each request carries a fresh nonce, a timestamp and an HMAC-SHA256 over the method, path, nonce, timestamp and body.
An ASGI middleware wraps the whole app. It rejects unsigned, wrongly signed, stale or replayed requests with 401 on every route (404/405/422 included), before reading the body, and it signs every response over the nonce, status and body. Each nonce is reserved atomically, so even two concurrent copies of one signed request run at most once.
The client discards any response without a valid signature. So a process that takes over the port of a crashed server learns nothing it can reuse, and cannot fake a result. Clients also ignore an endpoint whose server process has exited.
The interactive docs and OpenAPI routes are off, and client requests ignore HTTP proxy settings.
Single instance per user.gsas-server start holds a kernel lock (flock / msvcrt.locking) on server.lock for the server's lifetime. The OS releases it when the process exits, however it exits. Concurrent auto-starts therefore never create duplicate servers, and a leftover PID file or recycled PID never makes a dead server look alive to status/stop. gsas-server stop removes the files of a server that could not clean up after itself (e.g. on Windows, where stopping terminates the process). It does this while briefly holding the lock, so a live server's files are never touched.
POWDERLINE_NO_SERVER=1|true|yes|on
execution_mode='auto' (the CLI default) runs in-process and never contacts or starts a server.
execution_mode='server' and --use-server fail with a clear error.
gsas-server start and gsas-server restart refuse to run.
It is enforced in GSASClient, so it covers run(), the CLI, mp-simulate and direct GSASClient use.
Why signed messages over loopback TCP rather than a Unix domain socket: a socket file in a 0700 directory (or a named pipe) would give OS-level peer authentication. But it needs separate POSIX and Windows code paths. Signing requests and responses gives mutual authentication on loopback TCP with one code path on every platform, and it doesn't rely on whoever is listening being trustworthy.
Behaviour change:POWDERLINE_SERVER_PORT now only fixes the server's port. Clients always find the port in the user's state directory.
Upgrading: a v0.1.1 server that is still running is never used by the new client, but gsas-server status/stop can't see it either. Stop it once with pkill -u "$USER" -f gsas_server.py; Windows instructions are in TROUBLESHOOTING. Old servers were often auto-started, so you may not know one is running.
Unchanged: auto-start, reusing a running server, the output-visibility guard and the in-process fallback.
Tests
Linux: full suite 578 passed, 4 skipped, against 492 passed / 3 skipped on main. The extra skip is a Windows-only test.
Windows 11 (win-64, Python 3.13), at the commit before the last one: full suite 556 passed, 23 skipped, 0 failed. The last commit adds only the atomic nonce re-check and the stale-file cleanup in stop, which use the same lock primitives verified on Windows; its tests pass on Linux. The skips are POSIX-only tests; the Windows-only test ran and passed. The server tests passed in 3 of 3 repeat runs. Also checked by hand on Windows:
Server: the server started on a random loopback port. The state folder is only accessible to the user, SYSTEM and Administrators (inherited profile ACL). A second start is refused.
Results: LaB6 gives Rwp 6.527% through the server and in-process, with byte-identical refined_parameters.csv.
Authentication:/health, /docs and /openapi.json return 401 without a signature, and also with the real token sent as a bearer token.
Lifecycle: after a forced kill, status reports "not running" and the next run auto-starts a fresh server. stop and restart work.
Start lock: four simultaneous starts leave exactly one server.
Off switch:POWDERLINE_NO_SERVER works (1 and on).
Pinned port: honoured, and another socket cannot bind it (SO_EXCLUSIVEADDRUSE).
pixi run docs passes with -W.
New unit tests:
State-directory checks: symlink, foreign owner, group/other access, a symlinked XDG_RUNTIME_DIR, a shared temp directory without the sticky bit (refused), a sticky one (accepted), a temp directory owned by another user, a directory swapped for a symlink after the check, a directory that can't be created, and distinct paths per uid.
Signing on the ASGI app: unsigned requests, a bare bearer token, a wrong key, stale and future timestamps, a tampered body, the wrong path, malformed nonces, non-ASCII signatures and replays all get 401, with no job run. That includes two copies of one request sent concurrently. Signed requests are accepted and every response is signed, including 404/405/422. Oversized bodies are refused.
Client identity: unsigned, wrongly signed or replayed responses are rejected. An impostor that signs with any secret it was sent is rejected. A forged success is never reported. Endpoints of dead servers are not probed. The token never appears in any request.
The start lock: claim, refusal while held, replacement of stale files, release, and stale-file cleanup after stop that never touches a live server. Also a real 8-process concurrent start with stale files present, which must produce exactly one holder.
POWDERLINE_NO_SERVER for auto mode, server mode, auto-start, direct probes, start/restart, run(), and the kicker --use-server exit code.
Mutation-checked: each of the following makes at least one test fail:
sending the token,
skipping response verification,
dropping the dead-PID check,
dropping replay protection, or the atomic re-check of the nonce,
following a symlinked XDG_RUNTIME_DIR,
dropping the sticky check,
dropping O_NOFOLLOW,
removing the POWDERLINE_NO_SERVER check from probes,
removing the lock probe.
Integration tests start a real server in an isolated private state directory. They cover:
rejection of unsigned and wrongly signed requests;
file permissions;
the start lock held by a live server;
a client with another user's (forged) endpoint;
a full LaB6 refinement through the server.
Isolation: an autouse fixture keeps every test away from the developer's real state directory.
Checked by hand:
With a real server killed by SIGKILL and a listener squatting its port, the squatter receives no secret and no job, whether the stale endpoint names a dead PID or a recycled live one.
A server started after the crash takes over cleanly.
LaB6 through the server gives Rwp 6.527%, the same as in-process.
Not yet run on macOS. Its code paths are the POSIX ones tested on Linux. Its per-user $TMPDIR is owned by the user, so the parent check accepts it.
Docs
Updated README, TROUBLESHOOTING (including the upgrade note), DEVELOPMENT, the integration guide and the cross-platform guide.
New register entry KI-13 (implemented).
KI-11 updated. Its split-discovery problem is resolved; the one remaining case stays open: a server whose state directory is deleted while it runs can only be stopped by hand.
Version bumped to 0.1.2 in pixi.toml, CITATION.cff and src/powderline/__init__.py.
The server kept machine-global state (fixed file names in the system temp
dir), listened on a fixed port and accepted unauthenticated requests. On a
host with several users, one user's client could find and use another
user's server, so jobs ran, and files were written, under the wrong
identity; any local user could also submit a job writing into another
user's directories. Users also collided on the port and the shared files,
and nothing could stop powderline.run() from auto-starting a server.
Server state (PID, log, and server.json with port + token, mode 0600) now
lives in a per-user directory: $XDG_RUNTIME_DIR/powderline when it is ours,
else <tempdir>/powderline-<uid> on POSIX (0700; refused if a symlink,
foreign-owned or group/other-accessible), %LOCALAPPDATA%\powderline on
Windows. The server binds 127.0.0.1 on an ephemeral port (or
POWDERLINE_SERVER_PORT) and requires a per-start bearer token on every
route. /health returns an HMAC of a client nonce, so a client only uses a
server that holds its token; client requests bypass HTTP proxies. The PID
file is claimed with O_EXCL before the GSAS-II import, the start lock the
fixed port used to provide.
POWDERLINE_NO_SERVER=1|true|yes disables the server: auto runs in-process
without probing or starting one, server mode and --use-server fail with a
clear error, gsas-server start|restart refuse. Enforced in GSASClient, so
run(), the CLI and mp-simulate all honour it.
Docs updated (README, TROUBLESHOOTING, DEVELOPMENT, integration and
cross-platform guides); KI-13 records the issue and fix, KI-11 notes the
new state layout. kicker.py: run() docstring, CLI epilog and --use-server
check; code hash updated.
Assisted-by: claude-code:claude-opus-5-5
Patch release for the per-user, token-authenticated GSAS-II server and
the POWDERLINE_NO_SERVER switch. Same three files as the 0.1.1 bump; tag
v0.1.2 on the merge commit.
Assisted-by: claude-code:claude-opus-5-5
Review of the per-user server change found that the /health identity check
could be defeated. The client sent its bearer token to whatever listened on
the recorded port, and the expected proof was an HMAC keyed by that same
token. A process that took over the port of a crashed server (whose endpoint
file survives SIGKILL/OOM) therefore received the token, answered with a
valid proof, received the recipe, and — if it could write to the output
directory — made the client report a forged success.
The token now never leaves the owner's processes. Every request carries a
fresh nonce, a timestamp and an HMAC-SHA256 over method, path, nonce,
timestamp and body. A pure ASGI middleware wrapping the whole app rejects
unsigned, wrongly signed, stale or replayed requests on every route
(including 404/405/422) before reading the body, and signs every response
over the nonce, status and body. The client discards unsigned responses,
which also closes the gap between the health probe and the submission.
Clients additionally ignore an endpoint whose server process is gone.
The PID-file start lock let several concurrent starters win whenever a
stale PID file existed: each deleted the file by name, including a fresh
claim. It is replaced by a kernel-held lock (flock / msvcrt.locking) on
server.lock, kept for the server's lifetime and released by the OS on exit.
"Running" now means holding that lock, so leftover PID files and recycled
PIDs no longer fool status/stop.
The state directory is hardened further: XDG_RUNTIME_DIR is only trusted
if it is a real directory (lstat), a temp directory that would let other
users replace ours (world/group-writable without the sticky bit, or owned
by another user) is refused, and on POSIX state files are opened relative
to an O_NOFOLLOW, fstat-verified directory handle. The log is now 0600.
Also: POWDERLINE_NO_SERVER accepts "on" and a direct is_server_available()
call honours it; tests no longer reach the developer's real state
directory (autouse conftest fixture); new tests cover impostor listeners,
forged results, replay, a concurrent-start race and the --use-server exit.
Docs now describe the auto/server-mode distinction accurately, add an
upgrade note for still-running v0.1.1 servers, and KI-11/KI-13 are updated.
Assisted-by: claude-code:claude-opus-5-5
The replay check ran before the request body was awaited, but the nonce was
recorded only after signature verification. Two concurrent copies of the
same signed request could therefore both pass the check while suspended
in receive() and both run. The nonce is now re-checked and recorded with no
await in between, so exactly one copy proceeds; a test drives two copies
through the middleware concurrently.
On Windows, `gsas-server stop` terminates the process (TerminateProcess),
so the server never removes its PID and endpoint files. They were harmless
(the lock decides whether a server runs, and clients ignore endpoints of
dead processes) but confusing. stop now removes them after the server is
gone, while briefly holding the start lock, which proves no server is
running or starting, so a live server's files are never touched.
Assisted-by: claude-code:claude-opus-5-5
Authenticate request bodies before buffering to prevent resource exhaustion
src/powderline/gsas_server.py:385
The request HMAC is not checked until the complete body has been buffered. Any local user can therefore send a plausible nonce/timestamp/signature and slowly stream up to 64 MiB per connection, consuming unbounded connections and memory before receiving 401; this also contradicts the documentation that wrongly signed requests are rejected before reading the body. Sign a declared body digest so the header can be authenticated first, enforce a body-read timeout/size limit, and then verify the streamed body against that digest.
Prevent cleanup from deleting replacement server discovery files
src/powderline/gsas_server.py:585
Cleanup unlinks whichever PID and endpoint files currently exist at the pathname, not necessarily the files associated with _lock_fd. If the runtime directory is deleted while this server runs, a replacement server can recreate it and acquire a new lock inode; when the old server later exits, this cleanup deletes the replacement server's discovery files. Only unlink when the current PID file still contains this process's PID (or otherwise bind cleanup to the original directory/instance).
Correct PR description to document HMAC authentication
src/powderline/gsas_server.py:88
The PR description says every route uses Authorization: Bearer <token>, but this protocol uses nonce/timestamp/HMAC headers and the new tests explicitly reject bearer authentication. Update the PR description so reviewers and release notes describe the protocol actually implemented.
Correct PR description to document the actual startup locking design
src/powderline/gsas_server.py:529
The PR description says startup claims the PID file atomically with O_EXCL, whereas the implementation opens server.lock normally and relies on a kernel flock/msvcrt.locking lock. The repository documentation matches this implementation, so update the PR description's start-lock section to avoid documenting a different lifecycle design.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the persistent GSAS-II server safe on machines with more than one user. Each user gets their own server, and only that user can submit jobs to it or receive results from it. The server listens on a free loopback port. Requests and responses are signed with a secret that only its owner can read, and that secret never travels over the wire. This PR also adds
POWDERLINE_NO_SERVER, an environment variable that switches the server off.Problem
Up to v0.1.1 the server behaved as if the machine had a single user:
powderline_gsas_server.{port,pid,log}) had fixed names in the system temp directory.output_dirthe request named, as the user who started it.With several users on one machine, this caused four problems:
output_dirinside the owner's directories.--no-serveronly applies to the CLI.powderline.run()still auto-started a server.Changes
$XDG_RUNTIME_DIR/powderlineif that is a real directory owned by the user, otherwise<tempdir>/powderline-<uid>.0700and refused if it is a symlink, owned by someone else, or accessible to group/other users. It is also refused if the temp directory would let another user replace it: writable by others without the sticky bit, or owned by another user. On POSIX, files inside are opened relative to a re-verified, no-follow directory handle.%LOCALAPPDATA%\powderline, protected by the user-profile ACL.server.lock,server.pid,server.logandserver.json({pid, port, token}), all mode0600.server.jsonis written atomically.secrets.token_urlsafe(32)and binds127.0.0.1on a free port (orPOWDERLINE_SERVER_PORTif set).gsas-server startholds a kernel lock (flock/msvcrt.locking) onserver.lockfor the server's lifetime. The OS releases it when the process exits, however it exits. Concurrent auto-starts therefore never create duplicate servers, and a leftover PID file or recycled PID never makes a dead server look alive tostatus/stop.gsas-server stopremoves the files of a server that could not clean up after itself (e.g. on Windows, where stopping terminates the process). It does this while briefly holding the lock, so a live server's files are never touched.POWDERLINE_NO_SERVER=1|true|yes|onexecution_mode='auto'(the CLI default) runs in-process and never contacts or starts a server.execution_mode='server'and--use-serverfail with a clear error.gsas-server startandgsas-server restartrefuse to run.GSASClient, so it coversrun(), the CLI, mp-simulate and directGSASClientuse.Why signed messages over loopback TCP rather than a Unix domain socket: a socket file in a
0700directory (or a named pipe) would give OS-level peer authentication. But it needs separate POSIX and Windows code paths. Signing requests and responses gives mutual authentication on loopback TCP with one code path on every platform, and it doesn't rely on whoever is listening being trustworthy.Behaviour change:
POWDERLINE_SERVER_PORTnow only fixes the server's port. Clients always find the port in the user's state directory.Upgrading: a v0.1.1 server that is still running is never used by the new client, but
gsas-server status/stopcan't see it either. Stop it once withpkill -u "$USER" -f gsas_server.py; Windows instructions are in TROUBLESHOOTING. Old servers were often auto-started, so you may not know one is running.Unchanged: auto-start, reusing a running server, the output-visibility guard and the in-process fallback.
Tests
main. The extra skip is a Windows-only test.stop, which use the same lock primitives verified on Windows; its tests pass on Linux. The skips are POSIX-only tests; the Windows-only test ran and passed. The server tests passed in 3 of 3 repeat runs. Also checked by hand on Windows:startis refused.refined_parameters.csv./health,/docsand/openapi.jsonreturn 401 without a signature, and also with the real token sent as a bearer token.statusreports "not running" and the next run auto-starts a fresh server.stopandrestartwork.POWDERLINE_NO_SERVERworks (1andon).SO_EXCLUSIVEADDRUSE).pixi run docspasses with-W.XDG_RUNTIME_DIR, a shared temp directory without the sticky bit (refused), a sticky one (accepted), a temp directory owned by another user, a directory swapped for a symlink after the check, a directory that can't be created, and distinct paths per uid.successis never reported. Endpoints of dead servers are not probed. The token never appears in any request.stopthat never touches a live server. Also a real 8-process concurrent start with stale files present, which must produce exactly one holder.POWDERLINE_NO_SERVERfor auto mode, server mode, auto-start, direct probes,start/restart,run(), and thekicker --use-serverexit code.XDG_RUNTIME_DIR,O_NOFOLLOW,POWDERLINE_NO_SERVERcheck from probes,$TMPDIRis owned by the user, so the parent check accepts it.Docs
KI-13(implemented).KI-11updated. Its split-discovery problem is resolved; the one remaining case stays open: a server whose state directory is deleted while it runs can only be stopped by hand.pixi.toml,CITATION.cffandsrc/powderline/__init__.py.Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-haiku-4-5