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..22b4084 --- /dev/null +++ b/api/docs/diagrams/sas-execution-handshake.md @@ -0,0 +1,81 @@ +# 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: 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 + 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..a65f212 --- /dev/null +++ b/api/src/routes/api/spec/code.spec.ts @@ -0,0 +1,132 @@ +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' +import request from 'supertest' +import { + UserController, + PermissionController, + PermissionType, + PermissionSettingForRoute, + PrincipalType +} from '../../../controllers/' +import { + generateAccessToken, + saveTokensInDB, + RunTimeType, + sysInitCompiledPath +} 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 () => { + // 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' + 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') + ) + }, 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) + .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') + ) + }, 30000) + }) +}) + +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..bd72833 --- /dev/null +++ b/api/src/routes/api/spec/files/mockSas.js @@ -0,0 +1,105 @@ +#!/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. 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 + + while (Date.now() < until) { + /* busy-wait, mirroring the real autoexec's SAS-side sleep() loop */ + } +} + +// 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) { + 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 + +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(50) + +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) { + retry(() => 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)