mirror of
https://github.com/sasjs/server.git
synced 2026-07-23 21:25:29 +00:00
850695024f
A failed session (e.g. SAS %abort;, or a non-zero JS/PY/R exit) is a normal outcome of running arbitrary user code, not a request-shape or server problem. The #388 fix stopped processProgram() from hanging forever on a failed SAS session, but did so by throwing a SessionExecutionError, which surfaced as an HTTP 400 with a bespoke JSON shape - breaking Studio's log tab, which only renders on 2xx. Align the SAS branch with the pre-existing JS/PY/R behaviour: resolve instead of throwing, and let Execution.ts fold session.failureReason into the log the same way it already does for a debug-mode run. This removes the now-dead SessionExecutionError class and its try/catch wrapper entirely. Update the diagrams in api/docs/diagrams/ to match.
82 lines
5.1 KiB
Markdown
82 lines
5.1 KiB
Markdown
# SAS session execution handshake
|
|
|
|
How one long-lived `sas` process is turned into a single-use execution slot
|
|
via a SYSIN file-swap trick. This is the mechanism specific to
|
|
`RunTimeType.SAS`; JS/PY/R sessions spawn a fresh interpreter process per
|
|
request instead (see `request-execution-flow.md`).
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client as HTTP client
|
|
participant Ctrl as Controller<br/>(code.ts / stp.ts)
|
|
participant Exec as ExecutionController<br/>(Execution.ts)
|
|
participant Pool as SASSessionController<br/>(Session.ts)
|
|
participant FS as Filesystem<br/>(session.path)
|
|
participant SAS as spawned "sas" process
|
|
|
|
Note over Pool,SAS: SESSION CREATION - happens ahead of any request,<br/>pool pre-warms up to 3 sessions (Session.ts:59-69)
|
|
Pool->>FS: createFile(code.sas, "") - empty dummy SYSIN (Session.ts:117-118)
|
|
Pool->>FS: readFile(sysInitCompiledPath) - compiled system-init<br/>macros, produced by `npm run compileSysInit` (Session.ts:105)
|
|
Note over Pool: throws if this file doesn't exist yet -<br/>session creation never reaches execFile below
|
|
Pool->>FS: createFile(autoexec.sas, autoExecContent) (Session.ts:108-114)
|
|
Pool->>SAS: execFile(sasLoc, -SYSIN code.sas -AUTOEXEC autoexec.sas -LOG log.log ...) (Session.ts:127-147)
|
|
activate SAS
|
|
Note right of SAS: process stays active (this box)<br/>from spawn until it finally exits below
|
|
SAS->>FS: autoexec step 1: delete code.sas, the dummy SYSIN (Session.ts:257-261)
|
|
Pool->>FS: waitForSession() polls fileExists(code.sas) (Session.ts:175-192)
|
|
FS-->>Pool: code.sas no longer exists
|
|
Pool->>Pool: session.state = pending (Session.ts:190)
|
|
SAS->>SAS: autoexec step 2: busy-wait for code.sas to reappear,<br/>up to 15 minutes (Session.ts:263-274)
|
|
Note over SAS: process is now idle, parked mid-autoexec,<br/>waiting on the filesystem.<br/>Counts as "pending" in the pool.
|
|
|
|
Note over Client,SAS: REQUEST ARRIVES - reuses the parked process above
|
|
Client->>Ctrl: POST .../execute { code, runTime: sas }
|
|
Ctrl->>Exec: executeProgram({ program, runTime: SAS, ... })
|
|
Exec->>Pool: getSession() returns the pending session (Session.ts:59-69)
|
|
Exec->>Exec: session.state = running (Execution.ts:78)
|
|
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:85-89)
|
|
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:96-107)
|
|
Pool->>FS: createSASProgram() wraps user code with macro vars,<br/>_webout filename, autoexec injection (createSASProgram.ts)
|
|
Pool->>FS: write code.sas.bkp, then rename to code.sas (processProgram.ts:48-49)
|
|
Note over FS: write-then-rename, not a direct write,<br/>so SAS never reads a partial file
|
|
|
|
FS-->>SAS: code.sas exists again
|
|
SAS->>SAS: autoexec step 2 loop exits, sleeps 0.01s, autoexec ends (Session.ts:267-271)
|
|
Note over SAS: -SYSIN has pointed at code.sas from the start,<br/>so SAS now executes ITS CONTENT as the main job
|
|
SAS->>FS: writes log.log, output.lst, webout.txt while running
|
|
|
|
Pool->>Pool: processProgram poll loop:<br/>while state !== completed AND state !== failed (processProgram.ts:58-63)
|
|
|
|
alt program reaches EOF normally
|
|
SAS->>SAS: exits with code 0
|
|
SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152)
|
|
Pool->>Pool: session.state = completed
|
|
else program aborts (e.g. %abort, fatal error, license failure, hard STOP)
|
|
SAS->>SAS: exits with non-zero code
|
|
SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163)
|
|
Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString()
|
|
end
|
|
deactivate SAS
|
|
Note over Pool,Exec: either way processProgram() just RESOLVES here -<br/>a failed session is a normal outcome of running user<br/>code, not a request-shape/server problem, so it never throws<br/>(processProgram.ts:58-63, same as the JS/PY/R branch below)
|
|
|
|
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:109-112, 121-125)
|
|
Note over Exec: if session.failureReason is set, the log is folded into<br/>result the same way isDebugOn(vars) already does for a<br/>successful run - no separate error shape (Execution.ts:158-164)
|
|
Exec-->>Ctrl: { httpHeaders, result }
|
|
Ctrl-->>Client: 200 + result<br/>(log embedded in result if the session failed)
|
|
```
|
|
|
|
## Why this design
|
|
|
|
- SAS has meaningful startup cost (loading the engine, running system init
|
|
macros). Spawning a fresh `sas` process per request would pay that cost
|
|
every time. Instead, a process is spawned once and parked, ready to run
|
|
exactly one job the moment code shows up at a path it's already watching.
|
|
- The pool (`SessionController.getSession`, `Session.ts:59-69`) keeps up to
|
|
3 such parked processes ready, so most requests get an instant handoff
|
|
instead of waiting through the ~15-minute autoexec spin-wait or a cold
|
|
start.
|
|
- A session is single-use: once its process consumes the real SYSIN content
|
|
and exits (success or failure), that process is gone. The session object
|
|
itself is deleted later by `scheduleSessionDestroy` (`Session.ts:204-236`),
|
|
not reused for a second job.
|