From f84af4ac0639625fee4508e764f0eed07d7b4e8a Mon Sep 17 00:00:00 2001 From: YuryShkoda Date: Mon, 13 Jul 2026 13:46:36 +0300 Subject: [PATCH 1/7] 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 --- api/docs/diagrams/code-execution-overview.md | 38 ++++++ api/docs/diagrams/request-execution-flow.md | 64 ++++++++++ api/docs/diagrams/sas-execution-handshake.md | 79 ++++++++++++ api/docs/diagrams/session-lifecycle.md | 70 +++++++++++ api/src/controllers/code.ts | 3 +- api/src/controllers/internal/Execution.ts | 48 +++++-- .../controllers/internal/processProgram.ts | 4 + .../internal/spec/Execution.spec.ts | 65 ++++++++++ .../internal/spec/processProgram.spec.ts | 109 ++++++++++++++++ api/src/controllers/stp.ts | 3 +- api/src/routes/api/spec/code.spec.ts | 119 ++++++++++++++++++ api/src/routes/api/spec/files/mockSas.js | 70 +++++++++++ 12 files changed, 658 insertions(+), 14 deletions(-) create mode 100644 api/docs/diagrams/code-execution-overview.md create mode 100644 api/docs/diagrams/request-execution-flow.md create mode 100644 api/docs/diagrams/sas-execution-handshake.md create mode 100644 api/docs/diagrams/session-lifecycle.md create mode 100644 api/src/controllers/internal/spec/Execution.spec.ts create mode 100644 api/src/controllers/internal/spec/processProgram.spec.ts create mode 100644 api/src/routes/api/spec/code.spec.ts create mode 100755 api/src/routes/api/spec/files/mockSas.js diff --git a/api/docs/diagrams/code-execution-overview.md b/api/docs/diagrams/code-execution-overview.md new file mode 100644 index 0000000..293138c --- /dev/null +++ b/api/docs/diagrams/code-execution-overview.md @@ -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. diff --git a/api/docs/diagrams/request-execution-flow.md b/api/docs/diagrams/request-execution-flow.md new file mode 100644 index 0000000..0c6652b --- /dev/null +++ b/api/docs/diagrams/request-execution-flow.md @@ -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
POST /SASjsApi/code/execute
or /SASjsApi/stp/execute"] --> B["Controller
code.ts / stp.ts
try/catch wrapper"] + B --> C["ExecutionController.executeProgram
or .executeFile
Execution.ts:58-91"] + C --> D{"session already provided?
(e.g. file-upload flow)"} + D -- no --> E["getSessionController(runTime).getSession()
Session.ts:59-69"] + D -- yes --> F["use provided session"] + E --> G{"pending session
available in pool?"} + G -- yes --> H["reuse it"] + G -- no --> I["createSession()
SAS: spawns a real sas process, see
sas-execution-handshake.md
JS/PY/R: cheap - folder + id only"] + H --> J["pre-warm: if pool has fewer
than 3 pending, fire off more
createSession() calls (not awaited)
Session.ts:66"] + I --> J + F --> K + J --> K["session.state = running
Execution.ts:96"] + K --> L["write webout.txt (empty),
reqHeaders.txt
Execution.ts:103-107"] + L --> M["processProgram(...)
processProgram.ts:16-27"] + + M --> N{"runTime?"} + + N -- SAS --> O["createSASProgram():
wrap user code with _webout
filename, macro vars, autoexec"] + O --> P["write code.sas via .bkp + rename
processProgram.ts:48-49"] + P --> Q["poll: while session.state !== completed
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 /
createRProgram: wrap user code similarly"] + T --> U["write code file; spawn interpreter
fresh via execFile; pipe stdout/stderr
into a log.log write stream
processProgram.ts:108-134"] + U -- "exit 0" --> R + U -- "exit non-zero" --> S + + R --> V["read log.log, webout.txt,
stpsrv_header.txt
Execution.ts:128-131"] + V --> W["build httpHeaders + result
return ExecuteReturnRaw
Execution.ts:128-151"] + W --> X["Controller: res.send(result)
HTTP 200"] + + S --> Y["catch in Execution.ts:109-126:
read whatever log exists,
throw SessionExecutionError(message, log)"] + Y --> Z["Controller catch block
rethrow { code: 400, status: 'failure',
message, error, log }"] + Z --> AA["res.status(err.code).send(err)
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. diff --git a/api/docs/diagrams/sas-execution-handshake.md b/api/docs/diagrams/sas-execution-handshake.md new file mode 100644 index 0000000..ce38924 --- /dev/null +++ b/api/docs/diagrams/sas-execution-handshake.md @@ -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
(code.ts / stp.ts) + participant Exec as ExecutionController
(Execution.ts) + participant Pool as SASSessionController
(Session.ts) + participant FS as Filesystem
(session.path) + participant SAS as spawned "sas" process + + Note over Pool,SAS: SESSION CREATION - happens ahead of any request,
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)
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,
up to 15 minutes (Session.ts:263-274) + Note over SAS: process is now idle, parked mid-autoexec,
waiting on the filesystem.
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,
_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,
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,
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:
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
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 }
or throws SessionExecutionError{ message, log } (Execution.ts:109-126) + Ctrl-->>Client: 200 + result
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. diff --git a/api/docs/diagrams/session-lifecycle.md b/api/docs/diagrams/session-lifecycle.md new file mode 100644 index 0000000..d790a5e --- /dev/null +++ b/api/docs/diagrams/session-lifecycle.md @@ -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
SASSessionController.createSession()
Session.ts:77-95 + [*] --> pending: JS/PY/R runtime, no process yet
SessionController.createSession()
Session.ts:30-57 + + initialising --> pending: dummy SYSIN file deleted
by SAS's autoexec
waitForSession(), Session.ts:175-192 + initialising --> failed: spawned SAS process exits
before handshake completes
Session.ts:153-163, 184-188 + + pending --> running: session picked for a request
executeProgram(), Execution.ts:94-96 + + running --> completed: process exits 0
SAS: Session.ts:148-152 (original process)
JS/PY/R: processProgram.ts:116-120 (fresh process) + running --> failed: process exits non-zero
SAS: Session.ts:153-163
JS/PY/R: processProgram.ts:121-131
failureReason = err.toString() + + completed --> [*]: deleteSession()
scheduleSessionDestroy(), Session.ts:194-236 + failed --> [*]: deleteSession()
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. diff --git a/api/src/controllers/code.ts b/api/src/controllers/code.ts index f6aaa30..37c6068 100644 --- a/api/src/controllers/code.ts +++ b/api/src/controllers/code.ts @@ -114,7 +114,8 @@ const executeCode = async ( code: 400, status: 'failure', message: 'Job execution failed.', - error: typeof err === 'object' ? err.toString() : err + error: typeof err === 'object' ? err.toString() : err, + log: err?.log } } } diff --git a/api/src/controllers/internal/Execution.ts b/api/src/controllers/internal/Execution.ts index c1ff197..ad11706 100644 --- a/api/src/controllers/internal/Execution.ts +++ b/api/src/controllers/internal/Execution.ts @@ -15,6 +15,24 @@ export interface ExecutionVars { [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 { httpHeaders: HTTPHeaders result: string | Buffer @@ -88,18 +106,24 @@ export class ExecutionController { preProgramVariables?.httpHeaders.join('\n') ?? '' ) - await processProgram( - program, - preProgramVariables, - vars, - session, - weboutPath, - headersPath, - tokenFile, - runTime, - logPath, - otherArgs - ) + try { + await processProgram( + program, + preProgramVariables, + vars, + session, + weboutPath, + headersPath, + tokenFile, + runTime, + logPath, + 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 headersContent = (await fileExists(headersPath)) diff --git a/api/src/controllers/internal/processProgram.ts b/api/src/controllers/internal/processProgram.ts index 0557c0f..1948dde 100644 --- a/api/src/controllers/internal/processProgram.ts +++ b/api/src/controllers/internal/processProgram.ts @@ -50,6 +50,10 @@ export const processProgram = async ( // we now need to poll the session status while (session.state !== SessionState.completed) { + if (session.state === SessionState.failed) { + throw new Error(session.failureReason || 'SAS session failed') + } + await delay(50) } } else { diff --git a/api/src/controllers/internal/spec/Execution.spec.ts b/api/src/controllers/internal/spec/Execution.spec.ts new file mode 100644 index 0000000..3640d88 --- /dev/null +++ b/api/src/controllers/internal/spec/Execution.spec.ts @@ -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') + }) + }) +}) diff --git a/api/src/controllers/internal/spec/processProgram.spec.ts b/api/src/controllers/internal/spec/processProgram.spec.ts new file mode 100644 index 0000000..b1a37f3 --- /dev/null +++ b/api/src/controllers/internal/spec/processProgram.spec.ts @@ -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) +}) diff --git a/api/src/controllers/stp.ts b/api/src/controllers/stp.ts index d1797ec..ffe2c2e 100644 --- a/api/src/controllers/stp.ts +++ b/api/src/controllers/stp.ts @@ -166,7 +166,8 @@ const execute = async ( code: 400, status: 'failure', message: 'Job execution failed.', - error: typeof err === 'object' ? err.toString() : err + error: typeof err === 'object' ? err.toString() : err, + log: err?.log } } } diff --git a/api/src/routes/api/spec/code.spec.ts b/api/src/routes/api/spec/code.spec.ts new file mode 100644 index 0000000..fe75d4b --- /dev/null +++ b/api/src/routes/api/spec/code.spec.ts @@ -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 +} diff --git a/api/src/routes/api/spec/files/mockSas.js b/api/src/routes/api/spec/files/mockSas.js new file mode 100755 index 0000000..6502703 --- /dev/null +++ b/api/src/routes/api/spec/files/mockSas.js @@ -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 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 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) From 67fce475a3c53b3c20919902edb45dce809dffb9 Mon Sep 17 00:00:00 2001 From: YuryShkoda Date: Tue, 14 Jul 2026 08:41:35 +0300 Subject: [PATCH 2/7] fix(api): harden mock SAS executable and timeouts for CI - wrap the file read/write in a small retry() helper instead of letting an uncaught exception (e.g. a rename racing an existsSync check) crash the process with a non-zero exit indistinguishable from a genuine SAS failure - bump the internal give-up deadline from 1500ms to 8000ms - bump the corresponding Jest test timeouts from 15000ms to 30000ms to match --- api/src/routes/api/spec/code.spec.ts | 4 +- api/src/routes/api/spec/files/mockSas.js | 51 ++++++++++++++++++++---- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/api/src/routes/api/spec/code.spec.ts b/api/src/routes/api/spec/code.spec.ts index fe75d4b..6cac058 100644 --- a/api/src/routes/api/spec/code.spec.ts +++ b/api/src/routes/api/spec/code.spec.ts @@ -89,7 +89,7 @@ describe('code', () => { expect(response.text).toEqual( expect.stringContaining('mock SAS execution') ) - }, 15000) + }, 30000) it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => { const response = await request(app) @@ -105,7 +105,7 @@ describe('code', () => { expect(response.body.log).toEqual( expect.stringContaining('mock SAS execution') ) - }, 15000) + }, 30000) }) }) diff --git a/api/src/routes/api/spec/files/mockSas.js b/api/src/routes/api/spec/files/mockSas.js index 6502703..bd72833 100755 --- a/api/src/routes/api/spec/files/mockSas.js +++ b/api/src/routes/api/spec/files/mockSas.js @@ -26,11 +26,15 @@ const arg = (flag) => { const sysin = arg('-SYSIN') const logPath = arg('-LOG') -// give up waiting for the real program after this long, so an unused +// 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 +// forever and keep a test process alive. Generous rather than tight: CI +// runners are frequently slower/more contended than a local dev machine +// (shared CPU, coverage instrumentation on the Node side slowing down the +// round trip this process is waiting on), and this cost is only ever paid +// by sessions nothing is actually waiting on - the session actually used +// by a request gets its real code written far sooner than this in practice. +const GIVE_UP_AFTER_MS = 8000 const sleepSync = (ms) => { const until = Date.now() + ms @@ -40,8 +44,30 @@ const sleepSync = (ms) => { } } +// Any of the filesystem calls below can legitimately race against +// processProgram's own write-then-rename (e.g. existsSync sees the file, +// then a rename mid-flight makes the following read miss). Treat that as +// "not ready yet" and retry a few times, rather than letting an uncaught +// exception crash this process with a non-zero exit - which would be +// indistinguishable, to the Node side, from a genuine SAS failure. +const retry = (fn, attempts = 5, delayMs = 20) => { + for (let i = 0; i < attempts; i++) { + try { + return { ok: true, value: fn() } + } catch (err) { + if (i === attempts - 1) return { ok: false, error: err } + + sleepSync(delayMs) + } + } +} + // 1. remove the dummy SYSIN, signalling "session ready" to waitForSession() -if (sysin && fs.existsSync(sysin)) fs.unlinkSync(sysin) +if (sysin) { + retry(() => { + if (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 @@ -54,12 +80,21 @@ while (!sysin || !fs.existsSync(sysin)) { // 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) +sleepSync(50) -const code = fs.readFileSync(sysin, 'utf-8') +const readResult = retry(() => fs.readFileSync(sysin, 'utf-8')) + +if (!readResult.ok) { + process.stderr.write( + `mockSas.js: failed to read ${sysin}: ${readResult.error}\n` + ) + process.exit(1) +} + +const code = readResult.value if (logPath) { - fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`) + retry(() => fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`)) } if (code.includes('%abort;')) { From 0af6b63ffa770bfa2b77505d74778be517f8d631 Mon Sep 17 00:00:00 2001 From: YuryShkoda Date: Tue, 14 Jul 2026 09:20:35 +0300 Subject: [PATCH 3/7] fix: fix code.spec.ts failing on a fresh checkout (CI) --- api/docs/diagrams/sas-execution-handshake.md | 2 ++ api/src/routes/api/spec/code.spec.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/api/docs/diagrams/sas-execution-handshake.md b/api/docs/diagrams/sas-execution-handshake.md index ce38924..22b4084 100644 --- a/api/docs/diagrams/sas-execution-handshake.md +++ b/api/docs/diagrams/sas-execution-handshake.md @@ -16,6 +16,8 @@ sequenceDiagram Note over Pool,SAS: SESSION CREATION - happens ahead of any request,
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
macros, produced by `npm run compileSysInit` (Session.ts:105) + Note over Pool: throws if this file doesn't exist yet -
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 diff --git a/api/src/routes/api/spec/code.spec.ts b/api/src/routes/api/spec/code.spec.ts index 6cac058..a65f212 100644 --- a/api/src/routes/api/spec/code.spec.ts +++ b/api/src/routes/api/spec/code.spec.ts @@ -1,4 +1,5 @@ import path from 'path' +import { createFile, fileExists } from '@sasjs/utils' import { Express } from 'express' import mongoose, { Mongoose } from 'mongoose' import { MongoMemoryServer } from 'mongodb-memory-server' @@ -13,7 +14,8 @@ import { import { generateAccessToken, saveTokensInDB, - RunTimeType + RunTimeType, + sysInitCompiledPath } from '../../../utils' // Real, unmocked end-to-end test of the SAS execution pipeline (session @@ -43,6 +45,17 @@ describe('code', () => { let permissionController: PermissionController beforeAll(async () => { + // SASSessionController.createSession() unconditionally reads this file + // (Session.ts:105) before it ever spawns the SAS process. It's normally + // produced by `npm run compileSysInit` (part of the `initial`/`build` + // scripts), which `npm test` does not run - so on a fresh checkout + // (e.g. CI, where "Run Unit Tests" happens before "Build Package") it + // doesn't exist yet. Content is irrelevant here since mockSas.js never + // interprets it; it just needs to exist and be readable. + if (!(await fileExists(sysInitCompiledPath))) { + await createFile(sysInitCompiledPath, '') + } + mongoServer = await MongoMemoryServer.create() process.env.DB_CONNECT = mongoServer.getUri() process.env.MODE = 'server' From 2fe733d02f46fa09f91e0bd9aef7973e06940a77 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 14 Jul 2026 06:38:40 +0000 Subject: [PATCH 4/7] chore(release): 0.39.5 [skip ci] ## [0.39.5](https://github.com/sasjs/server/compare/v0.39.4...v0.39.5) (2026-07-14) ### Bug Fixes * **api:** harden mock SAS executable and timeouts for CI ([67fce47](https://github.com/sasjs/server/commit/67fce475a3c53b3c20919902edb45dce809dffb9)) * **api:** return prompt error response instead of hanging when a SAS session fails ([f84af4a](https://github.com/sasjs/server/commit/f84af4ac0639625fee4508e764f0eed07d7b4e8a)) * fix code.spec.ts failing on a fresh checkout (CI) ([0af6b63](https://github.com/sasjs/server/commit/0af6b63ffa770bfa2b77505d74778be517f8d631)) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 093f087..9d64ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [0.39.5](https://github.com/sasjs/server/compare/v0.39.4...v0.39.5) (2026-07-14) + + +### Bug Fixes + +* **api:** harden mock SAS executable and timeouts for CI ([67fce47](https://github.com/sasjs/server/commit/67fce475a3c53b3c20919902edb45dce809dffb9)) +* **api:** return prompt error response instead of hanging when a SAS session fails ([f84af4a](https://github.com/sasjs/server/commit/f84af4ac0639625fee4508e764f0eed07d7b4e8a)) +* fix code.spec.ts failing on a fresh checkout (CI) ([0af6b63](https://github.com/sasjs/server/commit/0af6b63ffa770bfa2b77505d74778be517f8d631)) + ## [0.39.4](https://github.com/sasjs/server/compare/v0.39.3...v0.39.4) (2025-12-21) From 4858245372fa19929b6e59c28148f5d004239c9d Mon Sep 17 00:00:00 2001 From: YuryShkoda Date: Tue, 14 Jul 2026 11:29:30 +0300 Subject: [PATCH 5/7] fix(api): stop overwriting a failed JS/PY/R session back to completed For JS/PY/R, processProgram sets session.state = failed (with failureReason) itself when the interpreter process exits non-zero, without throwing. ExecutionController.executeProgram then unconditionally set session.state = completed right after processProgram returned, silently overwriting that - so anything downstream inspecting session.state (e.g. scheduleSessionDestroy's expiresAfterMins branch) would see a crashed session mis-reported as successful. Guard the assignment so a failed state is never overwritten. No effect on SAS, which sets state independently via its own spawned process lifecycle. Added Execution.spec.ts covering both the failure case (state stays failed) and the success case (state still becomes completed), to guard against regressing in either direction. --- api/src/controllers/internal/Execution.ts | 11 ++- .../internal/spec/Execution.spec.ts | 84 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 api/src/controllers/internal/spec/Execution.spec.ts diff --git a/api/src/controllers/internal/Execution.ts b/api/src/controllers/internal/Execution.ts index c1ff197..d256275 100644 --- a/api/src/controllers/internal/Execution.ts +++ b/api/src/controllers/internal/Execution.ts @@ -120,7 +120,16 @@ export class ExecutionController { : '' // it should be deleted by scheduleSessionDestroy - session.state = SessionState.completed + // + // Guarded: for JS/PY/R, processProgram sets state to `failed` itself + // (without throwing) when the interpreter process exits non-zero - if + // we unconditionally set `completed` here we'd silently overwrite that, + // and anything downstream inspecting session.state (e.g. + // scheduleSessionDestroy's expiresAfterMins branch in Session.ts) would + // see a crashed session mis-reported as successful. + if ((session.state as SessionState) !== SessionState.failed) { + session.state = SessionState.completed + } const resultParts = [] diff --git a/api/src/controllers/internal/spec/Execution.spec.ts b/api/src/controllers/internal/spec/Execution.spec.ts new file mode 100644 index 0000000..ec91cfe --- /dev/null +++ b/api/src/controllers/internal/spec/Execution.spec.ts @@ -0,0 +1,84 @@ +import path from 'path' +import os from 'os' +import { deleteFolder, generateTimestamp } from '@sasjs/utils' +import * as ProcessProgramModule from '../processProgram' +import { ExecutionController } from '../Execution' +import { Session, SessionState, PreProgramVars } from '../../../types' +import { RunTimeType } from '../../../utils' + +// Regression coverage: for JS/PY/R, processProgram sets session.state to +// `failed` itself (without throwing) on a non-zero interpreter exit. +// ExecutionController.executeProgram used to unconditionally overwrite +// that back to `completed` right after processProgram returned, silently +// losing the failure for anything downstream that inspects session.state. + +const preProgramVariables: PreProgramVars = { + username: 'testUser', + userId: 1, + displayName: 'Test User', + serverUrl: 'http://localhost:5000', + httpHeaders: [] +} + +describe('ExecutionController.executeProgram (JS/PY/R 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('does not overwrite a failed session state back to completed', async () => { + // mirrors processProgram's real JS/PY/R branch on a non-zero exit: + // it sets state/failureReason itself and resolves - it does not throw + jest + .spyOn(ProcessProgramModule, 'processProgram') + .mockImplementation(async () => { + session.state = SessionState.failed + session.failureReason = 'Error: process exited with code 1' + }) + + const controller = new ExecutionController() + + await controller.executeProgram({ + program: 'throw new Error("boom")', + preProgramVariables, + vars: {}, + session, + runTime: RunTimeType.JS + }) + + expect(session.state).toBe(SessionState.failed) + }) + + it('still marks a genuinely successful session as completed', async () => { + jest + .spyOn(ProcessProgramModule, 'processProgram') + .mockImplementation(async () => { + session.state = SessionState.completed + }) + + const controller = new ExecutionController() + + await controller.executeProgram({ + program: 'console.log("hello")', + preProgramVariables, + vars: {}, + session, + runTime: RunTimeType.JS + }) + + expect(session.state).toBe(SessionState.completed) + }) +}) From dec51914911c10b66b43ebe95f77b0af63e2d03e Mon Sep 17 00:00:00 2001 From: YuryShkoda Date: Tue, 14 Jul 2026 11:32:01 +0300 Subject: [PATCH 6/7] fix(api): isolate drive.spec.ts's files folder from the real drive drive.spec.ts already isolates itself into a unique, timestamped tmpFolder for getSasjsRootFolder()/getUploadsFolder(), cleaned up via afterAll(() => deleteFolder(tmpFolder)). But getFilesFolder() - what nearly every test in this file actually writes to - resolves through a separate, unmocked function (getSasjsDriveFolder()/process.driveLoc), so every run left real files/folders (e.g. 'level1', 'my/path/...') behind in the shared api/sasjs_root/drive/files, causing later runs to fail: folder-listing tests saw stale entries, and file-creation tests got 409 Conflict against files a previous run already created. Mock getFilesFolder() the same way getUploadsFolder() already is, so it resolves inside the same isolated tmpFolder and gets cleaned up by the existing afterAll. Verified by running the suite twice in a row from a clean slate: both runs pass, and the real sasjs_root/drive is never touched. --- api/src/routes/api/spec/drive.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/api/src/routes/api/spec/drive.spec.ts b/api/src/routes/api/spec/drive.spec.ts index c4c28ce..c6bfd9d 100644 --- a/api/src/routes/api/spec/drive.spec.ts +++ b/api/src/routes/api/spec/drive.spec.ts @@ -28,6 +28,15 @@ jest .spyOn(fileUtilModules, 'getUploadsFolder') .mockImplementation(() => path.join(tmpFolder, 'uploads')) +// getFilesFolder() resolves via getSasjsDriveFolder()/process.driveLoc, a +// separate root from getSasjsRootFolder()/process.sasjsRoot above - without +// this, every test in this file that creates content under the drive (e.g. +// 'level1', 'my/path/...') writes into the real, shared sasjs_root/drive +// instead of this run's isolated tmpFolder, leaking state into later runs. +jest + .spyOn(fileUtilModules, 'getFilesFolder') + .mockImplementation(() => path.join(tmpFolder, 'drive', 'files')) + import appPromise from '../../../app' import { UserController, From 50fa4320cd9595434ee6bf338f7651aae86e42a9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 14 Jul 2026 09:05:49 +0000 Subject: [PATCH 7/7] chore(release): 0.39.6 [skip ci] ## [0.39.6](https://github.com/sasjs/server/compare/v0.39.5...v0.39.6) (2026-07-14) ### Bug Fixes * **api:** isolate drive.spec.ts's files folder from the real drive ([dec5191](https://github.com/sasjs/server/commit/dec51914911c10b66b43ebe95f77b0af63e2d03e)) * **api:** stop overwriting a failed JS/PY/R session back to completed ([4858245](https://github.com/sasjs/server/commit/4858245372fa19929b6e59c28148f5d004239c9d)) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d64ac0..340e620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [0.39.6](https://github.com/sasjs/server/compare/v0.39.5...v0.39.6) (2026-07-14) + + +### Bug Fixes + +* **api:** isolate drive.spec.ts's files folder from the real drive ([dec5191](https://github.com/sasjs/server/commit/dec51914911c10b66b43ebe95f77b0af63e2d03e)) +* **api:** stop overwriting a failed JS/PY/R session back to completed ([4858245](https://github.com/sasjs/server/commit/4858245372fa19929b6e59c28148f5d004239c9d)) + ## [0.39.5](https://github.com/sasjs/server/compare/v0.39.4...v0.39.5) (2026-07-14)