Compare commits

..
14 Commits
Author SHA1 Message Date
semantic-release-bot bb23bac1c0 chore(release): 0.39.8 [skip ci]
## [0.39.8](https://github.com/sasjs/server/compare/v0.39.7...v0.39.8) (2026-07-15)

### Bug Fixes

* **api:** return 200 with embedded log on SAS session failure instead of 400 ([8506950](https://github.com/sasjs/server/commit/850695024f4b7102982c197ec64b5bb45e7d5f90))
2026-07-15 13:17:59 +00:00
Allan BoweandGitHub bf0ddc952f Merge pull request #392 from sasjs/issue-388-fix
fix(api): return 200 with embedded log on SAS session failure instead…
2026-07-15 14:14:42 +01:00
YuryShkoda 850695024f fix(api): return 200 with embedded log on SAS session failure instead of 400
A failed session (e.g. SAS %abort;, or a non-zero JS/PY/R exit) is a
normal outcome of running arbitrary user code, not a request-shape or
server problem. The #388 fix stopped processProgram() from hanging
forever on a failed SAS session, but did so by throwing a
SessionExecutionError, which surfaced as an HTTP 400 with a bespoke
JSON shape - breaking Studio's log tab, which only renders on 2xx.

Align the SAS branch with the pre-existing JS/PY/R behaviour: resolve
instead of throwing, and let Execution.ts fold session.failureReason
into the log the same way it already does for a debug-mode run. This
removes the now-dead SessionExecutionError class and its try/catch
wrapper entirely.

Update the diagrams in api/docs/diagrams/ to match.
2026-07-15 13:59:01 +03:00
semantic-release-bot 9c3a1086b6 chore(release): 0.39.7 [skip ci]
## [0.39.7](https://github.com/sasjs/server/compare/v0.39.6...v0.39.7) (2026-07-14)

### Bug Fixes

* **jsonwebtoken:** bumped version to avoid vulnerability ([1473925](https://github.com/sasjs/server/commit/1473925896db0e4472c9ef5ba64955527a1be2de))
2026-07-14 13:53:13 +00:00
Yury ShkodaandGitHub 05768890a2 Merge pull request #366 from sasjs/cpr-fix
Fix API scripts
2026-07-14 16:50:34 +03:00
YuryShkoda 386185d1a0 Merge remote-tracking branch 'origin/main' into cpr-fix
# Conflicts:
#	api/package-lock.json
#	api/package.json
#	api/public/swagger.yaml
2026-07-14 16:28:10 +03:00
Yury ShkodaandGitHub 681f0123ad Merge pull request #387 from sasjs/fix/pin-axios-version
chore: pin axios versions
2026-07-14 15:45:40 +03:00
YuryShkoda daa5274fb0 Merge remote-tracking branch 'origin/main' into fix/pin-axios-version 2026-07-14 15:29:26 +03:00
mulahasanovic 4f75fd290f chore: pin axios versions 2026-04-01 20:55:50 +02:00
Yury Shkoda 66232aefd2 chore(lint): bumped prettier and fixed lint issues 2023-09-21 17:58:47 +03:00
Yury Shkoda bf35791655 chore(lint): fixed Session.ts 2023-09-21 17:52:28 +03:00
Yury Shkoda 2dc11630e4 chore(api): regenerated swagger.yaml 2023-09-21 17:45:52 +03:00
Yury Shkoda 1473925896 fix(jsonwebtoken): bumped version to avoid vulnerability 2023-09-21 17:45:21 +03:00
Yury Shkoda b472f1bd61 chore(scripts): used cpr package to improve coping 2023-09-21 17:44:31 +03:00
17 changed files with 203 additions and 128 deletions
+14
View File
@@ -1,3 +1,17 @@
## [0.39.8](https://github.com/sasjs/server/compare/v0.39.7...v0.39.8) (2026-07-15)
### Bug Fixes
* **api:** return 200 with embedded log on SAS session failure instead of 400 ([8506950](https://github.com/sasjs/server/commit/850695024f4b7102982c197ec64b5bb45e7d5f90))
## [0.39.7](https://github.com/sasjs/server/compare/v0.39.6...v0.39.7) (2026-07-14)
### Bug Fixes
* **jsonwebtoken:** bumped version to avoid vulnerability ([1473925](https://github.com/sasjs/server/commit/1473925896db0e4472c9ef5ba64955527a1be2de))
## [0.39.6](https://github.com/sasjs/server/compare/v0.39.5...v0.39.6) (2026-07-14)
+3 -2
View File
@@ -30,8 +30,9 @@ fresh per request inside `processProgram`.
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).
`processProgram`, reads back `log.log`/`webout.txt`/headers, and builds
the HTTP response. A failed session (any runtime) is embedded in that
same response, not thrown as a separate error.
- `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).
+23 -18
View File
@@ -8,7 +8,7 @@ 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"]
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:40-77"]
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"]
@@ -18,30 +18,28 @@ flowchart TD
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"]
J --> K["session.state = running<br/>Execution.ts:78"]
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:85-89"]
L --> M["processProgram(...)<br/>Execution.ts:96-107"]
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)"]
P --> Q["poll: while state !== completed<br/>AND state !== failed<br/>processProgram.ts:58-63"]
Q --> R["processProgram() resolves either way -<br/>state was set by the session's own<br/>process exit handler in Session.ts,<br/>see sas-execution-handshake.md"]
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
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:117-124"]
U -- "exit 0" --> Uc["session.state = completed<br/>processProgram.ts:126"]
U -- "exit non-zero" --> Uf["session.state = failed<br/>session.failureReason = err.toString()<br/>processProgram.ts:131-133"]
Uc --> R
Uf --> R
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"]
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:109-113, 121-125"]
V --> W["guard: if state !== failed,<br/>set state = completed<br/>(don't overwrite a failed session)<br/>Execution.ts:127-137"]
W --> X["build httpHeaders + result;<br/>embed log in result if<br/>isDebugOn(vars) or<br/>session.failureReason is set<br/>Execution.ts:139-164"]
X --> Y["Controller: res.send(result)<br/>HTTP 200<br/>(log embedded if the session failed -<br/>same shape as a successful run)"]
```
## Notes
@@ -55,9 +53,16 @@ flowchart TD
`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`).
- **A failed session never throws.** `processProgram()` resolves normally
whether the session completed or failed - a failed session (e.g. SAS
`%abort;`, or a non-zero JS/PY/R exit) is a normal outcome of running
arbitrary user code, not a request-shape/server problem. There is no
separate error path: the same `Execution.ts:139-164` logic that embeds
the log for a debug-mode successful run also embeds it when
`session.failureReason` is set, and the controller always responds 200.
- **`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`.
see `Execution.ts:149-156`.
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
call the same `ExecutionController` methods without awaiting them and
immediately return `{ sessionId }`; the client polls
+10 -10
View File
@@ -33,9 +33,9 @@ sequenceDiagram
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)
Exec->>Exec: session.state = running (Execution.ts:78)
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:85-89)
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:96-107)
Pool->>FS: createSASProgram() wraps user code with macro vars,<br/>_webout filename, autoexec injection (createSASProgram.ts)
Pool->>FS: write code.sas.bkp, then rename to code.sas (processProgram.ts:48-49)
Note over FS: write-then-rename, not a direct write,<br/>so SAS never reads a partial file
@@ -45,24 +45,24 @@ sequenceDiagram
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)
Pool->>Pool: processProgram poll loop:<br/>while state !== completed AND state !== failed (processProgram.ts:58-63)
alt program reaches EOF normally
SAS->>SAS: exits with code 0
SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152)
Pool->>Pool: session.state = completed
Pool-->>Exec: processProgram() resolves
else program aborts (fatal error, license failure, hard STOP)
else program aborts (e.g. %abort, fatal error, license failure, hard STOP)
SAS->>SAS: exits with non-zero code
SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163)
Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString()
Pool-->>Exec: processProgram() throws (processProgram.ts:53-55)
end
deactivate SAS
Note over Pool,Exec: either way processProgram() just RESOLVES here -<br/>a failed session is a normal outcome of running user<br/>code, not a request-shape/server problem, so it never throws<br/>(processProgram.ts:58-63, same as the JS/PY/R branch below)
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts: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 }
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:109-112, 121-125)
Note over Exec: if session.failureReason is set, the log is folded into<br/>result the same way isDebugOn(vars) already does for a<br/>successful run - no separate error shape (Execution.ts:158-164)
Exec-->>Ctrl: { httpHeaders, result }
Ctrl-->>Client: 200 + result<br/>(log embedded in result if the session failed)
```
## Why this design
+13 -6
View File
@@ -14,10 +14,10 @@ stateDiagram-v2
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
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:76-78
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()
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:124-129 (fresh process)
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:130-135<br/>failureReason = err.toString()<br/>neither branch throws - see request-execution-flow.md
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
@@ -55,7 +55,7 @@ stateDiagram-v2
| 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`) |
| `processProgram` (SAS branch poll loop) | `processProgram.ts:58-63` | `completed` or `failed` - either just stops the loop and returns normally; `failed` does **not** throw. `Execution.ts` reads `session.failureReason` afterward to fold the log into a normal (200) result, same as it already does for JS/PY/R. |
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
## Asymmetry between runtimes
@@ -65,6 +65,13 @@ stateDiagram-v2
`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
process is spawned fresh, per request, inside `processProgram.ts:124` and
its exit drives `completed`/`failed` directly in the same function - there
is no separate poll loop for these runtimes.
is no separate poll loop for these runtimes (SAS needs one because it's
polling a state change happening in a process spawned earlier, at session
creation; JS/PY/R just `await` the process they spawn right there).
- **Both runtimes resolve `processProgram()` normally on `failed`, they
never throw for it** - a failed session (any runtime) is a normal outcome
of running arbitrary user code, not a request-shape/server problem. See
`request-execution-flow.md` for how `Execution.ts` turns that into a
response.
+36 -6
View File
@@ -49,7 +49,8 @@
"@types/swagger-ui-express": "^4.1.3",
"@types/unzipper": "^0.10.5",
"adm-zip": "^0.5.9",
"axios": "^1.12.2",
"axios": "1.12.2",
"cpr": "^3.0.1",
"csrf": "^3.1.0",
"dotenv": "^16.0.1",
"http-headers-validation": "^0.0.1",
@@ -58,7 +59,7 @@
"nodejs-file-downloader": "4.10.2",
"nodemon": "^3.0.0",
"pkg": "5.6.0",
"prettier": "^3.0.0",
"prettier": "^3.0.3",
"rimraf": "^3.0.2",
"supertest": "^6.1.3",
"ts-jest": "^29.1.0",
@@ -3588,11 +3589,10 @@
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@@ -4490,6 +4490,36 @@
"node": ">= 0.10"
}
},
"node_modules/cpr": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/cpr/-/cpr-3.0.1.tgz",
"integrity": "sha512-Xch4PXQ/KC8lJ+KfJ9JI6eG/nmppLrPPWg5Q+vh65Qr9EjuJEubxh/H/Le1TmCZ7+Xv7iJuNRqapyOFZB+wsxA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"graceful-fs": "^4.1.5",
"minimist": "^1.2.0",
"mkdirp": "~0.5.1",
"rimraf": "^2.5.4"
},
"bin": {
"cpr": "bin/cpr"
}
},
"node_modules/cpr/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
+7 -6
View File
@@ -16,10 +16,10 @@
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"exe": "npm run build && pkg .",
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
"public:copy": "cp -r ./public/ ./build/public/",
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
"sas:copy": "cp -r ./sas/ ./build/sas/",
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
"public:copy": "cpr ./public/ ./build/public/",
"sasjsbuild:copy": "cpr ./sasjsbuild/ ./build/sasjsbuild/",
"sas:copy": "cpr ./sas/ ./build/sas/",
"web:copy": "rimraf web && mkdir web && cpr ../web/build/ ./web/build/",
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
@@ -84,7 +84,8 @@
"@types/swagger-ui-express": "^4.1.3",
"@types/unzipper": "^0.10.5",
"adm-zip": "^0.5.9",
"axios": "^1.12.2",
"axios": "1.12.2",
"cpr": "^3.0.1",
"csrf": "^3.1.0",
"dotenv": "^16.0.1",
"http-headers-validation": "^0.0.1",
@@ -93,7 +94,7 @@
"nodejs-file-downloader": "4.10.2",
"nodemon": "^3.0.0",
"pkg": "5.6.0",
"prettier": "^3.0.0",
"prettier": "^3.0.3",
"rimraf": "^3.0.2",
"supertest": "^6.1.3",
"ts-jest": "^29.1.0",
+1 -2
View File
@@ -114,8 +114,7 @@ const executeCode = async (
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err,
log: err?.log
error: typeof err === 'object' ? err.toString() : err
}
}
}
+17 -36
View File
@@ -15,24 +15,6 @@ 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
@@ -106,24 +88,23 @@ export class ExecutionController {
preProgramVariables?.httpHeaders.join('\n') ?? ''
)
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)
}
// A failed session (e.g. SAS via %abort;, or a non-zero JS/PY/R exit)
// is a normal outcome of running arbitrary user code, not a
// request-shape/server problem - processProgram resolves rather than
// throwing in that case, and the session.failureReason check below
// folds the log into the same result shape a successful run returns.
await processProgram(
program,
preProgramVariables,
vars,
session,
weboutPath,
headersPath,
tokenFile,
runTime,
logPath,
otherArgs
)
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
const headersContent = (await fileExists(headersPath))
+11 -6
View File
@@ -48,12 +48,17 @@ export const processProgram = async (
await createFile(codePath + '.bkp', program)
await moveFile(codePath + '.bkp', codePath)
// 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')
}
// we now need to poll the session status. A failed session (e.g. from
// %abort;) is not a request-shape/server problem - it's a normal
// outcome of running arbitrary user code, same as a SAS ERROR: in the
// log without %abort;. So we just stop polling rather than throwing;
// Execution.ts already knows how to turn session.failureReason into a
// 200 response with the log embedded, matching how JS/PY/R (the else
// branch below) has always handled a failed session.
while (
session.state !== SessionState.completed &&
session.state !== SessionState.failed
) {
await delay(50)
}
} else {
@@ -2,7 +2,7 @@ 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 { ExecutionController } from '../Execution'
import { Session, SessionState, PreProgramVars } from '../../../types'
import { RunTimeType } from '../../../utils'
@@ -83,8 +83,15 @@ describe('ExecutionController.executeProgram', () => {
})
})
// A failed SAS session (e.g. from %abort;) is a normal outcome of
// running arbitrary user code, not a request-shape/server problem - the
// same way a plain SAS ERROR: in the log (without %abort;) already
// returns 200 with the log embedded. This mirrors the JS/PY/R failure
// path above: resolve normally, don't throw, and let the existing
// session.failureReason check below produce the same result shape as a
// successful run.
describe('SAS failure path', () => {
it('throws a SessionExecutionError carrying the complete log when the session fails', async () => {
it('returns the complete log embedded in a normal result instead of throwing, 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'
@@ -94,12 +101,17 @@ describe('ExecutionController.executeProgram', () => {
jest
.spyOn(ProcessProgramModule, 'processProgram')
.mockImplementation(async () => {
throw new Error('ERROR: SAS session terminated. See log for details.')
// mirrors the real SAS branch after the fix: sets
// state/failureReason and resolves, exactly like the JS/PY/R
// branch already does - it does not throw
session.state = SessionState.failed
session.failureReason =
'ERROR: SAS session terminated. See log for details.'
})
const controller = new ExecutionController()
const resultPromise = controller.executeProgram({
const { result } = await controller.executeProgram({
program: '%abort;',
preProgramVariables,
vars: {},
@@ -107,11 +119,8 @@ describe('ExecutionController.executeProgram', () => {
runTime: RunTimeType.SAS
})
await expect(resultPromise).rejects.toBeInstanceOf(SessionExecutionError)
await expect(resultPromise).rejects.toMatchObject({
log: logContent,
message: expect.stringContaining('SAS session terminated')
})
expect(session.state).toBe(SessionState.failed)
expect(result).toEqual(expect.stringContaining(logContent))
})
})
})
@@ -65,7 +65,13 @@ describe('processProgram (SAS runtime)', () => {
await deleteFolder(session.path)
})
it('rejects instead of hanging when the session fails (e.g. %abort;)', async () => {
it('resolves instead of hanging when the session fails (e.g. %abort;)', async () => {
// mirrors the JS/PY/R branch below: a session failure is a normal
// outcome of running arbitrary user code (like a SAS ERROR: in the
// log without %abort;), not a server-side/request-shape problem - so
// processProgram must not throw here, just stop polling. Execution.ts
// is responsible for turning session.failureReason into a 200 response
// with the log embedded, the same way it already does for JS/PY/R.
setTimeout(() => {
session.state = SessionState.failed
session.failureReason =
@@ -84,7 +90,7 @@ describe('processProgram (SAS runtime)', () => {
RunTimeType.SAS,
logPath
)
).rejects.toThrow(/SAS session terminated/)
).resolves.toBeUndefined()
}, 3000)
it('resolves without throwing when the session completes normally', async () => {
+1 -2
View File
@@ -166,8 +166,7 @@ const execute = async (
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err,
log: err?.log
error: typeof err === 'object' ? err.toString() : err
}
}
}
+13 -7
View File
@@ -104,20 +104,26 @@ describe('code', () => {
)
}, 30000)
it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => {
// A failed SAS session (e.g. %abort;) is a normal outcome of running
// arbitrary user code, not a request-shape/server problem - the HTTP
// request itself was fine, so this must respond exactly like a
// successful run (200, same body shape, no hang), with the log simply
// reflecting what happened. Regression test for #388's follow-up: the
// original #388 fix stopped the hang but over-corrected into a 400
// with a bespoke error shape, which broke Studio's log-tab rendering.
it('returns 200 with the same shape as a successful run when the SAS session fails (%abort;), not a hang or an error shape', async () => {
const response = await request(app)
.post('/SASjsApi/code/execute')
.auth(accessToken, { type: 'bearer' })
.send({ code: '%abort;', runTime: 'sas' })
.expect(400)
.expect(200)
expect(response.body).toMatchObject({
status: 'failure',
message: 'Job execution failed.'
})
expect(response.body.log).toEqual(
expect(response.text).toEqual(
expect.stringContaining('mock SAS execution')
)
expect(response.text).toEqual(
expect.stringContaining('SAS session terminated')
)
}, 30000)
})
})
+10 -2
View File
@@ -93,11 +93,19 @@ if (!readResult.ok) {
const code = readResult.value
// real SAS writes errors/aborts directly into the log file itself, not
// just to stderr - mirror that so tests asserting on log content (what
// the API actually returns to the caller) are meaningful
const isAbort = code.includes('%abort;')
const logContent = isAbort
? `NOTE: mock SAS execution\n${code}\nERROR: SAS session terminated. See log for details.\n`
: `NOTE: mock SAS execution\n${code}\n`
if (logPath) {
retry(() => fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`))
retry(() => fs.writeFileSync(logPath, logContent))
}
if (code.includes('%abort;')) {
if (isAbort) {
process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
process.exit(1)
}
+13 -11
View File
@@ -20,7 +20,7 @@
"@types/jest": "^26.0.24",
"@types/node": "^12.20.28",
"@types/react": "^17.0.27",
"axios": "^1.12.2",
"axios": "1.12.2",
"monaco-editor": "^0.33.0",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0",
@@ -56,7 +56,7 @@
"html-webpack-plugin": "5.5.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"path": "0.12.7",
"prettier": "^2.4.1",
"prettier": "^3.0.3",
"sass": "^1.44.0",
"sass-loader": "^12.3.0",
"style-loader": "^3.3.1",
@@ -4334,7 +4334,6 @@
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@@ -9713,15 +9712,18 @@
}
},
"node_modules/prettier": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
"dev": true,
"bin": {
"prettier": "bin-prettier.js"
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=10.13.0"
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-error": {
@@ -19127,9 +19129,9 @@
"dev": true
},
"prettier": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
"dev": true
},
"pretty-error": {
+5 -3
View File
@@ -4,7 +4,9 @@
"private": true,
"scripts": {
"start": "webpack-dev-server --config webpack.dev.ts --hot",
"build": "webpack --config webpack.prod.ts"
"build": "webpack --config webpack.prod.ts",
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\""
},
"dependencies": {
"@emotion/react": "^11.4.1",
@@ -19,7 +21,7 @@
"@types/jest": "^26.0.24",
"@types/node": "^12.20.28",
"@types/react": "^17.0.27",
"axios": "^1.12.2",
"axios": "1.12.2",
"monaco-editor": "^0.33.0",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0",
@@ -55,7 +57,7 @@
"html-webpack-plugin": "5.5.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"path": "0.12.7",
"prettier": "^2.4.1",
"prettier": "^3.0.3",
"sass": "^1.44.0",
"sass-loader": "^12.3.0",
"style-loader": "^3.3.1",