mirror of
https://github.com/sasjs/server.git
synced 2026-07-23 21:25:29 +00:00
fix(api): return prompt error response instead of hanging when a SAS session fails
The SAS-runtime poll loop in processProgram only checked for
SessionState.completed, so a session that failed (e.g. via %abort;)
left the loop spinning on delay(50) forever - the request never
resolved, even though the SAS log had already been written. Split a single completed-on-either-outcome
flag into separate completed/failed states without updating this loop.
- processProgram now throws when session.state becomes 'failed'
- Execution.ts catches that, reads the complete log (guaranteed
complete since the session's process has already exited by then),
and throws a SessionExecutionError carrying it
- stp.ts/code.ts surface { ..., log } in the HTTP error response
- add unit tests for the poll loop and the log-enrichment logic, plus
a mock SAS executable + end-to-end test exercising the real
session/process pipeline without needing a SAS install
- add docs/diagrams covering the session lifecycle and the SAS
execution handshake mechanism, for future context
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
# Code execution & session diagrams
|
||||||
|
|
||||||
|
Mermaid diagrams describing how `@sasjs/server` executes submitted code
|
||||||
|
(SAS/JS/PY/R) against pooled "sessions". Written for fast context-loading by
|
||||||
|
an AI coding agent: each diagram is self-contained, node/edge labels carry
|
||||||
|
the actual file:line references, and prose is kept to the minimum needed to
|
||||||
|
disambiguate the diagram.
|
||||||
|
|
||||||
|
| File | Covers |
|
||||||
|
|---|---|
|
||||||
|
| [session-lifecycle.md](session-lifecycle.md) | `SessionState` state machine; how it differs between the SAS runtime and the JS/PY/R runtimes |
|
||||||
|
| [sas-execution-handshake.md](sas-execution-handshake.md) | The SYSIN/AUTOEXEC file-swap mechanism a SAS session uses to turn one long-lived `sas` process into a single-use execution slot |
|
||||||
|
| [request-execution-flow.md](request-execution-flow.md) | End-to-end flowchart from HTTP request to response, covering session-pool acquisition and both runtime branches (SAS vs JS/PY/R) |
|
||||||
|
|
||||||
|
## Core concept
|
||||||
|
|
||||||
|
A "session" is not a request-scoped object. It is a pooled, reusable
|
||||||
|
execution slot with a filesystem folder (`session.path`) and a state
|
||||||
|
(`SessionState`). For the SAS runtime specifically, a session also owns one
|
||||||
|
real OS process, spawned at session-*creation* time and consumed by exactly
|
||||||
|
one code submission (see `sas-execution-handshake.md`). For JS/PY/R, a
|
||||||
|
session is just an ID + folder; the actual interpreter process is spawned
|
||||||
|
fresh per request inside `processProgram`.
|
||||||
|
|
||||||
|
## Key source files
|
||||||
|
|
||||||
|
- `api/src/controllers/internal/Session.ts` — session pool + lifecycle
|
||||||
|
(`SessionController`, `SASSessionController`), `SessionState` transitions.
|
||||||
|
- `api/src/controllers/internal/processProgram.ts` — writes the submitted
|
||||||
|
code into the session and drives it to completion/failure, per runtime.
|
||||||
|
- `api/src/controllers/internal/Execution.ts` — `ExecutionController`,
|
||||||
|
the entry point controllers call; acquires a session, calls
|
||||||
|
`processProgram`, reads back `log.log`/`webout.txt`/headers, builds the
|
||||||
|
HTTP response (or a `SessionExecutionError` on failure).
|
||||||
|
- `api/src/controllers/internal/create{SAS,JS,Python,R}Program.ts` —
|
||||||
|
per-runtime code templating (wraps the user's submitted code with
|
||||||
|
boilerplate: variable injection, `_webout` redirection, etc).
|
||||||
|
- `api/src/types/Session.ts` — `SessionState` enum and `Session` interface.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Request execution flow (end-to-end)
|
||||||
|
|
||||||
|
Full path from an inbound HTTP request to the HTTP response, covering
|
||||||
|
session-pool acquisition and the two structurally different runtime
|
||||||
|
branches: SAS (reuses a parked long-lived process) vs JS/PY/R (spawns a
|
||||||
|
fresh interpreter process per request).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["HTTP request<br/>POST /SASjsApi/code/execute<br/>or /SASjsApi/stp/execute"] --> B["Controller<br/>code.ts / stp.ts<br/>try/catch wrapper"]
|
||||||
|
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:58-91"]
|
||||||
|
C --> D{"session already provided?<br/>(e.g. file-upload flow)"}
|
||||||
|
D -- no --> E["getSessionController(runTime).getSession()<br/>Session.ts:59-69"]
|
||||||
|
D -- yes --> F["use provided session"]
|
||||||
|
E --> G{"pending session<br/>available in pool?"}
|
||||||
|
G -- yes --> H["reuse it"]
|
||||||
|
G -- no --> I["createSession()<br/>SAS: spawns a real sas process, see<br/>sas-execution-handshake.md<br/>JS/PY/R: cheap - folder + id only"]
|
||||||
|
H --> J["pre-warm: if pool has fewer<br/>than 3 pending, fire off more<br/>createSession() calls (not awaited)<br/>Session.ts:66"]
|
||||||
|
I --> J
|
||||||
|
F --> K
|
||||||
|
J --> K["session.state = running<br/>Execution.ts:96"]
|
||||||
|
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:103-107"]
|
||||||
|
L --> M["processProgram(...)<br/>processProgram.ts:16-27"]
|
||||||
|
|
||||||
|
M --> N{"runTime?"}
|
||||||
|
|
||||||
|
N -- SAS --> O["createSASProgram():<br/>wrap user code with _webout<br/>filename, macro vars, autoexec"]
|
||||||
|
O --> P["write code.sas via .bkp + rename<br/>processProgram.ts:48-49"]
|
||||||
|
P --> Q["poll: while session.state !== completed<br/>processProgram.ts:52-58"]
|
||||||
|
Q -- "state becomes completed" --> R["resolve"]
|
||||||
|
Q -- "state becomes failed" --> S["throw Error(session.failureReason)"]
|
||||||
|
|
||||||
|
N -- "JS / PY / R" --> T["createJSProgram / createPythonProgram /<br/>createRProgram: wrap user code similarly"]
|
||||||
|
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:108-134"]
|
||||||
|
U -- "exit 0" --> R
|
||||||
|
U -- "exit non-zero" --> S
|
||||||
|
|
||||||
|
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:128-131"]
|
||||||
|
V --> W["build httpHeaders + result<br/>return ExecuteReturnRaw<br/>Execution.ts:128-151"]
|
||||||
|
W --> X["Controller: res.send(result)<br/>HTTP 200"]
|
||||||
|
|
||||||
|
S --> Y["catch in Execution.ts:109-126:<br/>read whatever log exists,<br/>throw SessionExecutionError(message, log)"]
|
||||||
|
Y --> Z["Controller catch block<br/>rethrow { code: 400, status: 'failure',<br/>message, error, log }"]
|
||||||
|
Z --> AA["res.status(err.code).send(err)<br/>HTTP 400 with complete log"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Session pool is per-runtime.** SAS sessions and JS/PY/R sessions live in
|
||||||
|
separate controllers/pools (`process.sasSessionController` vs
|
||||||
|
`process.sessionController`, `Session.ts:239-253`) - a pending SAS session
|
||||||
|
is never handed out for a JS request or vice versa.
|
||||||
|
- **The SAS branch and the JS/PY/R branch converge** on the same
|
||||||
|
success/failure signal shape: `session.state` becomes `completed` or
|
||||||
|
`failed` either way, and both paths flow through the same log/webout
|
||||||
|
reading logic in `Execution.ts` afterward. The mechanics of *how* that
|
||||||
|
state gets set differ substantially (see `session-lifecycle.md`).
|
||||||
|
- **`includePrintOutput`** (SAS only) additionally appends `output.lst`
|
||||||
|
content to the result when debug mode is on - omitted above for brevity;
|
||||||
|
see `Execution.ts:135-142`.
|
||||||
|
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
|
||||||
|
call the same `ExecutionController` methods without awaiting them and
|
||||||
|
immediately return `{ sessionId }`; the client polls
|
||||||
|
`GET /SASjsApi/session/{sessionId}/state` separately.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 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: 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:96)
|
||||||
|
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:103-107)
|
||||||
|
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:110-121)
|
||||||
|
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 session.state !== completed (processProgram.ts:52-58)
|
||||||
|
|
||||||
|
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
|
||||||
|
Pool-->>Exec: processProgram() resolves
|
||||||
|
else program aborts (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()
|
||||||
|
Pool-->>Exec: processProgram() throws (processProgram.ts:53-55)
|
||||||
|
end
|
||||||
|
deactivate SAS
|
||||||
|
|
||||||
|
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:123, 128-131)
|
||||||
|
Exec-->>Ctrl: { httpHeaders, result }<br/>or throws SessionExecutionError{ message, log } (Execution.ts:109-126)
|
||||||
|
Ctrl-->>Client: 200 + result<br/>or 400 { status, message, error, log }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Session lifecycle (`SessionState`)
|
||||||
|
|
||||||
|
Enum defined in `api/src/types/Session.ts`. A `Session` is `{ id, state,
|
||||||
|
path, creationTimeStamp, deathTimeStamp, expiresAfterMins?, failureReason? }`.
|
||||||
|
Sessions live in an in-memory array on a per-runtime singleton controller
|
||||||
|
(`process.sasSessionController` for SAS, `process.sessionController` shared
|
||||||
|
by JS/PY/R — see `getSessionController()` in `Session.ts:239-253`).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> initialising: SAS runtime<br/>SASSessionController.createSession()<br/>Session.ts:77-95
|
||||||
|
[*] --> pending: JS/PY/R runtime, no process yet<br/>SessionController.createSession()<br/>Session.ts:30-57
|
||||||
|
|
||||||
|
initialising --> pending: dummy SYSIN file deleted<br/>by SAS's autoexec<br/>waitForSession(), Session.ts:175-192
|
||||||
|
initialising --> failed: spawned SAS process exits<br/>before handshake completes<br/>Session.ts:153-163, 184-188
|
||||||
|
|
||||||
|
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:94-96
|
||||||
|
|
||||||
|
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:116-120 (fresh process)
|
||||||
|
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:121-131<br/>failureReason = err.toString()
|
||||||
|
|
||||||
|
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
|
||||||
|
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
|
||||||
|
|
||||||
|
note right of initialising
|
||||||
|
SAS only. The real sas
|
||||||
|
executable is already
|
||||||
|
running, spin-waiting
|
||||||
|
inside its AUTOEXEC for
|
||||||
|
real code to arrive.
|
||||||
|
See sas-execution-handshake.md
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of pending
|
||||||
|
Session sits in the pool,
|
||||||
|
reusable. Pre-warmed up to
|
||||||
|
3 pending sessions per
|
||||||
|
runtime (getSession(),
|
||||||
|
Session.ts:59-69)
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of running
|
||||||
|
SAS: no new process spawned
|
||||||
|
here, request just feeds
|
||||||
|
code to the already-running
|
||||||
|
process.
|
||||||
|
JS/PY/R: a brand new
|
||||||
|
interpreter process is
|
||||||
|
spawned right now.
|
||||||
|
end note
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consumers of `state`
|
||||||
|
|
||||||
|
| Reader | Location | Watches for |
|
||||||
|
|---|---|---|
|
||||||
|
| `waitForSession` | `Session.ts:175-192` | `failed` (breaks early) or the dummy SYSIN file disappearing (implies session survived init) |
|
||||||
|
| `processProgram` (SAS branch poll loop) | `processProgram.ts:52-58` | `completed` (success exit) or `failed` (throws, carrying `session.failureReason`) |
|
||||||
|
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
|
||||||
|
|
||||||
|
## Asymmetry between runtimes
|
||||||
|
|
||||||
|
- **SAS**: one OS process per session, spawned once at `createSession()`
|
||||||
|
time and reused for exactly one job (see `sas-execution-handshake.md`).
|
||||||
|
`running`/`completed`/`failed` are all driven by that *same* process's
|
||||||
|
eventual exit.
|
||||||
|
- **JS/PY/R**: a session is cheap (folder + id, no process). The interpreter
|
||||||
|
process is spawned fresh, per request, inside `processProgram.ts:115` and
|
||||||
|
its exit drives `completed`/`failed` directly in the same function - there
|
||||||
|
is no separate poll loop for these runtimes.
|
||||||
@@ -114,7 +114,8 @@ const executeCode = async (
|
|||||||
code: 400,
|
code: 400,
|
||||||
status: 'failure',
|
status: 'failure',
|
||||||
message: 'Job execution failed.',
|
message: 'Job execution failed.',
|
||||||
error: typeof err === 'object' ? err.toString() : err
|
error: typeof err === 'object' ? err.toString() : err,
|
||||||
|
log: err?.log
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ export interface ExecutionVars {
|
|||||||
[key: string]: string | number | undefined
|
[key: string]: string | number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thrown when the session itself fails (e.g. SAS exits abnormally via
|
||||||
|
// %abort;). Carries the complete log - by the time this is thrown, the
|
||||||
|
// session's process has already exited, so the log file it wrote is final,
|
||||||
|
// not a partial/truncated snapshot.
|
||||||
|
export class SessionExecutionError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public log?: string
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
|
||||||
|
// required for `instanceof` to work when compiling to ES5, since the
|
||||||
|
// default __extends helper does not preserve the prototype chain for
|
||||||
|
// classes extending built-ins like Error
|
||||||
|
Object.setPrototypeOf(this, SessionExecutionError.prototype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExecuteReturnRaw {
|
export interface ExecuteReturnRaw {
|
||||||
httpHeaders: HTTPHeaders
|
httpHeaders: HTTPHeaders
|
||||||
result: string | Buffer
|
result: string | Buffer
|
||||||
@@ -88,6 +106,7 @@ export class ExecutionController {
|
|||||||
preProgramVariables?.httpHeaders.join('\n') ?? ''
|
preProgramVariables?.httpHeaders.join('\n') ?? ''
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
await processProgram(
|
await processProgram(
|
||||||
program,
|
program,
|
||||||
preProgramVariables,
|
preProgramVariables,
|
||||||
@@ -100,6 +119,11 @@ export class ExecutionController {
|
|||||||
logPath,
|
logPath,
|
||||||
otherArgs
|
otherArgs
|
||||||
)
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||||
|
|
||||||
|
throw new SessionExecutionError(err.message, log)
|
||||||
|
}
|
||||||
|
|
||||||
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||||
const headersContent = (await fileExists(headersPath))
|
const headersContent = (await fileExists(headersPath))
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export const processProgram = async (
|
|||||||
|
|
||||||
// we now need to poll the session status
|
// we now need to poll the session status
|
||||||
while (session.state !== SessionState.completed) {
|
while (session.state !== SessionState.completed) {
|
||||||
|
if (session.state === SessionState.failed) {
|
||||||
|
throw new Error(session.failureReason || 'SAS session failed')
|
||||||
|
}
|
||||||
|
|
||||||
await delay(50)
|
await delay(50)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
|
||||||
|
import * as ProcessProgramModule from '../processProgram'
|
||||||
|
import { ExecutionController, SessionExecutionError } from '../Execution'
|
||||||
|
import { Session, SessionState, PreProgramVars } from '../../../types'
|
||||||
|
import { RunTimeType } from '../../../utils'
|
||||||
|
|
||||||
|
const preProgramVariables: PreProgramVars = {
|
||||||
|
username: 'testUser',
|
||||||
|
userId: 1,
|
||||||
|
displayName: 'Test User',
|
||||||
|
serverUrl: 'http://localhost:5000',
|
||||||
|
httpHeaders: []
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ExecutionController.executeProgram (SAS failure path)', () => {
|
||||||
|
let session: Session
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const sessionId = `test-session-${generateTimestamp()}`
|
||||||
|
session = {
|
||||||
|
id: sessionId,
|
||||||
|
state: SessionState.pending,
|
||||||
|
creationTimeStamp: '0',
|
||||||
|
deathTimeStamp: '0',
|
||||||
|
path: path.join(os.tmpdir(), sessionId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
await deleteFolder(session.path)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws a SessionExecutionError carrying the complete log when the session fails', async () => {
|
||||||
|
const logPath = path.join(session.path, 'log.log')
|
||||||
|
const logContent =
|
||||||
|
'NOTE: SAS session\nERROR: SAS session terminated. See log for details.\n'
|
||||||
|
|
||||||
|
await createFile(logPath, logContent)
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(ProcessProgramModule, 'processProgram')
|
||||||
|
.mockImplementation(async () => {
|
||||||
|
throw new Error('ERROR: SAS session terminated. See log for details.')
|
||||||
|
})
|
||||||
|
|
||||||
|
const controller = new ExecutionController()
|
||||||
|
|
||||||
|
const resultPromise = controller.executeProgram({
|
||||||
|
program: '%abort;',
|
||||||
|
preProgramVariables,
|
||||||
|
vars: {},
|
||||||
|
session,
|
||||||
|
runTime: RunTimeType.SAS
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(resultPromise).rejects.toBeInstanceOf(SessionExecutionError)
|
||||||
|
await expect(resultPromise).rejects.toMatchObject({
|
||||||
|
log: logContent,
|
||||||
|
message: expect.stringContaining('SAS session terminated')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
|
||||||
|
import { processProgram } from '../processProgram'
|
||||||
|
import { Session, SessionState, PreProgramVars } from '../../../types'
|
||||||
|
import {
|
||||||
|
RunTimeType,
|
||||||
|
getSessionsFolder,
|
||||||
|
generateUniqueFileName
|
||||||
|
} from '../../../utils'
|
||||||
|
|
||||||
|
const preProgramVariables: PreProgramVars = {
|
||||||
|
username: 'testUser',
|
||||||
|
userId: 1,
|
||||||
|
displayName: 'Test User',
|
||||||
|
serverUrl: 'http://localhost:5000',
|
||||||
|
httpHeaders: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeSession = (): Session => {
|
||||||
|
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||||
|
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||||
|
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||||
|
const deathTimeStamp = (
|
||||||
|
parseInt(creationTimeStamp) +
|
||||||
|
15 * 60 * 1000 -
|
||||||
|
1000
|
||||||
|
).toString()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
state: SessionState.running,
|
||||||
|
creationTimeStamp,
|
||||||
|
deathTimeStamp,
|
||||||
|
path: sessionFolder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processProgram (SAS runtime)', () => {
|
||||||
|
let session: Session
|
||||||
|
let logPath: string
|
||||||
|
let weboutPath: string
|
||||||
|
let headersPath: string
|
||||||
|
let tokenFile: string
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const root = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`sasjs-processProgram-spec-${generateTimestamp()}`
|
||||||
|
)
|
||||||
|
process.sasjsRoot = root
|
||||||
|
process.driveLoc = path.join(root, 'drive')
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
session = makeSession()
|
||||||
|
logPath = path.join(session.path, 'log.log')
|
||||||
|
weboutPath = path.join(session.path, 'webout.txt')
|
||||||
|
headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
|
tokenFile = path.join(session.path, 'reqHeaders.txt')
|
||||||
|
await createFile(weboutPath, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await deleteFolder(session.path)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects instead of hanging when the session fails (e.g. %abort;)', async () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
session.state = SessionState.failed
|
||||||
|
session.failureReason =
|
||||||
|
'ERROR: SAS session terminated. See log for details.'
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
processProgram(
|
||||||
|
'%abort;',
|
||||||
|
preProgramVariables,
|
||||||
|
{},
|
||||||
|
session,
|
||||||
|
weboutPath,
|
||||||
|
headersPath,
|
||||||
|
tokenFile,
|
||||||
|
RunTimeType.SAS,
|
||||||
|
logPath
|
||||||
|
)
|
||||||
|
).rejects.toThrow(/SAS session terminated/)
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
it('resolves without throwing when the session completes normally', async () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
session.state = SessionState.completed
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
processProgram(
|
||||||
|
'%put hello world;',
|
||||||
|
preProgramVariables,
|
||||||
|
{},
|
||||||
|
session,
|
||||||
|
weboutPath,
|
||||||
|
headersPath,
|
||||||
|
tokenFile,
|
||||||
|
RunTimeType.SAS,
|
||||||
|
logPath
|
||||||
|
)
|
||||||
|
).resolves.toBeUndefined()
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
@@ -166,7 +166,8 @@ const execute = async (
|
|||||||
code: 400,
|
code: 400,
|
||||||
status: 'failure',
|
status: 'failure',
|
||||||
message: 'Job execution failed.',
|
message: 'Job execution failed.',
|
||||||
error: typeof err === 'object' ? err.toString() : err
|
error: typeof err === 'object' ? err.toString() : err,
|
||||||
|
log: err?.log
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { Express } from 'express'
|
||||||
|
import mongoose, { Mongoose } from 'mongoose'
|
||||||
|
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||||
|
import request from 'supertest'
|
||||||
|
import {
|
||||||
|
UserController,
|
||||||
|
PermissionController,
|
||||||
|
PermissionType,
|
||||||
|
PermissionSettingForRoute,
|
||||||
|
PrincipalType
|
||||||
|
} from '../../../controllers/'
|
||||||
|
import {
|
||||||
|
generateAccessToken,
|
||||||
|
saveTokensInDB,
|
||||||
|
RunTimeType
|
||||||
|
} from '../../../utils'
|
||||||
|
|
||||||
|
// Real, unmocked end-to-end test of the SAS execution pipeline (session
|
||||||
|
// spawn -> autoexec handshake -> exit code -> HTTP response), using a fake
|
||||||
|
// "SAS executable" (mockSas.js) in place of a real SAS install. This is the
|
||||||
|
// regression test for issue #388: SAS code that aborts (%abort;) used to
|
||||||
|
// hang the request forever instead of returning an error response.
|
||||||
|
const mockSasPath = path.join(__dirname, 'files', 'mockSas.js')
|
||||||
|
|
||||||
|
const clientId = 'codeSpecClientID'
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
displayName: 'Code Spec User',
|
||||||
|
username: 'codeSpecUsername',
|
||||||
|
password: '87654321',
|
||||||
|
isAdmin: false,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: Express
|
||||||
|
let accessToken: string
|
||||||
|
|
||||||
|
describe('code', () => {
|
||||||
|
let con: Mongoose
|
||||||
|
let mongoServer: MongoMemoryServer
|
||||||
|
let userController: UserController
|
||||||
|
let permissionController: PermissionController
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create()
|
||||||
|
process.env.DB_CONNECT = mongoServer.getUri()
|
||||||
|
process.env.MODE = 'server'
|
||||||
|
process.env.RUN_TIMES = 'sas'
|
||||||
|
process.env.SAS_PATH = mockSasPath
|
||||||
|
|
||||||
|
const appPromise = (await import('../../../app')).default
|
||||||
|
app = await appPromise
|
||||||
|
|
||||||
|
con = await mongoose.connect(mongoServer.getUri())
|
||||||
|
|
||||||
|
userController = new UserController()
|
||||||
|
permissionController = new PermissionController()
|
||||||
|
|
||||||
|
const dbUser = await userController.createUser(user)
|
||||||
|
accessToken = await generateAndSaveToken(dbUser.id)
|
||||||
|
|
||||||
|
await permissionController.createPermission({
|
||||||
|
path: '/SASjsApi/code/execute',
|
||||||
|
type: PermissionType.route,
|
||||||
|
principalType: PrincipalType.user,
|
||||||
|
principalId: dbUser.id,
|
||||||
|
setting: PermissionSettingForRoute.grant
|
||||||
|
})
|
||||||
|
|
||||||
|
process.runTimes = [RunTimeType.SAS]
|
||||||
|
process.sasLoc = mockSasPath
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await con.connection.dropDatabase()
|
||||||
|
await con.connection.close()
|
||||||
|
await mongoServer.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('returns 200 with the mock log when the SAS program completes normally', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/SASjsApi/code/execute')
|
||||||
|
.auth(accessToken, { type: 'bearer' })
|
||||||
|
.send({ code: '%put hello world;', runTime: 'sas' })
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
expect(response.text).toEqual(
|
||||||
|
expect.stringContaining('mock SAS execution')
|
||||||
|
)
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/SASjsApi/code/execute')
|
||||||
|
.auth(accessToken, { type: 'bearer' })
|
||||||
|
.send({ code: '%abort;', runTime: 'sas' })
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
status: 'failure',
|
||||||
|
message: 'Job execution failed.'
|
||||||
|
})
|
||||||
|
expect(response.body.log).toEqual(
|
||||||
|
expect.stringContaining('mock SAS execution')
|
||||||
|
)
|
||||||
|
}, 15000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateAndSaveToken = async (userId: number) => {
|
||||||
|
const accessToken = generateAccessToken({
|
||||||
|
clientId,
|
||||||
|
userId
|
||||||
|
})
|
||||||
|
await saveTokensInDB(userId, clientId, accessToken, 'refreshToken')
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Minimal fake "SAS executable" used by tests in place of a real SAS
|
||||||
|
// install. It only fulfils the CLI/filesystem handshake contract that
|
||||||
|
// Session.ts / processProgram.ts rely on:
|
||||||
|
//
|
||||||
|
// -SYSIN <path> the file used as a signal channel: it starts out
|
||||||
|
// containing a dummy (empty) placeholder, we delete it
|
||||||
|
// to signal "session ready", then wait for it to be
|
||||||
|
// rewritten with the real submitted program.
|
||||||
|
// -LOG <path> where we write a fake log so downstream fileExists()/
|
||||||
|
// readFile() calls have something to find.
|
||||||
|
//
|
||||||
|
// It does NOT interpret real SAS syntax. Exit code is the only thing that
|
||||||
|
// matters to the Node side: 0 mimics a normal SAS termination, non-zero
|
||||||
|
// mimics an abnormal one (e.g. %abort;).
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const arg = (flag) => {
|
||||||
|
const idx = process.argv.indexOf(flag)
|
||||||
|
|
||||||
|
return idx === -1 ? undefined : process.argv[idx + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sysin = arg('-SYSIN')
|
||||||
|
const logPath = arg('-LOG')
|
||||||
|
|
||||||
|
// give up waiting for the real program after this long, so an unused
|
||||||
|
// pre-warmed session (sasjs pools up to 3 ready sessions) doesn't linger
|
||||||
|
// forever and keep a test process alive. Short, since in tests the real
|
||||||
|
// signal (if this session is the one actually used) arrives near-instantly.
|
||||||
|
const GIVE_UP_AFTER_MS = 1500
|
||||||
|
|
||||||
|
const sleepSync = (ms) => {
|
||||||
|
const until = Date.now() + ms
|
||||||
|
|
||||||
|
while (Date.now() < until) {
|
||||||
|
/* busy-wait, mirroring the real autoexec's SAS-side sleep() loop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. remove the dummy SYSIN, signalling "session ready" to waitForSession()
|
||||||
|
if (sysin && fs.existsSync(sysin)) fs.unlinkSync(sysin)
|
||||||
|
|
||||||
|
// 2. wait for the real program to be written back to the same path
|
||||||
|
const deadline = Date.now() + GIVE_UP_AFTER_MS
|
||||||
|
|
||||||
|
while (!sysin || !fs.existsSync(sysin)) {
|
||||||
|
if (Date.now() > deadline) process.exit(0)
|
||||||
|
|
||||||
|
sleepSync(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// small settle delay, mirroring the real autoexec's sleep(0.01,1) after
|
||||||
|
// detecting the file, so a fast-moving rename isn't read mid-write
|
||||||
|
sleepSync(20)
|
||||||
|
|
||||||
|
const code = fs.readFileSync(sysin, 'utf-8')
|
||||||
|
|
||||||
|
if (logPath) {
|
||||||
|
fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.includes('%abort;')) {
|
||||||
|
process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
Reference in New Issue
Block a user