mirror of
https://github.com/sasjs/server.git
synced 2026-07-23 21:25:29 +00:00
f84af4ac06
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
222 lines
5.9 KiB
TypeScript
222 lines
5.9 KiB
TypeScript
import path from 'path'
|
|
import fs from 'fs'
|
|
import { getSessionController, processProgram } from './'
|
|
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
|
import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
|
|
import {
|
|
extractHeaders,
|
|
getFilesFolder,
|
|
HTTPHeaders,
|
|
isDebugOn,
|
|
RunTimeType
|
|
} from '../../utils'
|
|
|
|
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
|
|
}
|
|
|
|
interface ExecuteFileParams {
|
|
programPath: string
|
|
preProgramVariables: PreProgramVars
|
|
vars: ExecutionVars
|
|
otherArgs?: any
|
|
returnJson?: boolean
|
|
session?: Session
|
|
runTime: RunTimeType
|
|
forceStringResult?: boolean
|
|
}
|
|
|
|
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
|
program: string
|
|
includePrintOutput?: boolean
|
|
}
|
|
|
|
export class ExecutionController {
|
|
async executeFile({
|
|
programPath,
|
|
preProgramVariables,
|
|
vars,
|
|
otherArgs,
|
|
returnJson,
|
|
session,
|
|
runTime,
|
|
forceStringResult
|
|
}: ExecuteFileParams) {
|
|
const program = await readFile(programPath)
|
|
|
|
return this.executeProgram({
|
|
program,
|
|
preProgramVariables,
|
|
vars,
|
|
otherArgs,
|
|
returnJson,
|
|
session,
|
|
runTime,
|
|
forceStringResult
|
|
})
|
|
}
|
|
|
|
async executeProgram({
|
|
program,
|
|
preProgramVariables,
|
|
vars,
|
|
otherArgs,
|
|
session: sessionByFileUpload,
|
|
runTime,
|
|
forceStringResult,
|
|
includePrintOutput
|
|
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
|
|
const sessionController = getSessionController(runTime)
|
|
|
|
const session =
|
|
sessionByFileUpload ?? (await sessionController.getSession())
|
|
session.state = SessionState.running
|
|
|
|
const logPath = path.join(session.path, 'log.log')
|
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
|
const weboutPath = path.join(session.path, 'webout.txt')
|
|
const tokenFile = path.join(session.path, 'reqHeaders.txt')
|
|
|
|
await createFile(weboutPath, '')
|
|
await createFile(
|
|
tokenFile,
|
|
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)
|
|
}
|
|
|
|
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
|
const headersContent = (await fileExists(headersPath))
|
|
? await readFile(headersPath)
|
|
: ''
|
|
const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
|
|
|
|
if (isDebugOn(vars)) {
|
|
httpHeaders['content-type'] = 'text/plain'
|
|
}
|
|
|
|
const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type')
|
|
|
|
const webout = (await fileExists(weboutPath))
|
|
? fileResponse && !forceStringResult
|
|
? await readFileBinary(weboutPath)
|
|
: await readFile(weboutPath)
|
|
: ''
|
|
|
|
// it should be deleted by scheduleSessionDestroy
|
|
session.state = SessionState.completed
|
|
|
|
const resultParts = []
|
|
|
|
// INFO: webout can be a Buffer, that is why it's length should be checked to determine if it is empty
|
|
if (webout && webout.length !== 0) resultParts.push(webout)
|
|
|
|
// INFO: log separator wraps the log from the beginning and the end
|
|
resultParts.push(process.logsUUID)
|
|
resultParts.push(log)
|
|
resultParts.push(process.logsUUID)
|
|
|
|
if (includePrintOutput && runTime === RunTimeType.SAS) {
|
|
const printOutputPath = path.join(session.path, 'output.lst')
|
|
const printOutput = (await fileExists(printOutputPath))
|
|
? await readFile(printOutputPath)
|
|
: ''
|
|
|
|
if (printOutput) resultParts.push(printOutput)
|
|
}
|
|
|
|
return {
|
|
httpHeaders,
|
|
result:
|
|
isDebugOn(vars) || session.failureReason
|
|
? resultParts.join(`\n`)
|
|
: webout
|
|
}
|
|
}
|
|
|
|
buildDirectoryTree() {
|
|
const root: TreeNode = {
|
|
name: 'files',
|
|
relativePath: '',
|
|
absolutePath: getFilesFolder(),
|
|
isFolder: true,
|
|
children: []
|
|
}
|
|
|
|
const stack = [root]
|
|
|
|
while (stack.length) {
|
|
const currentNode = stack.pop()
|
|
|
|
if (currentNode) {
|
|
currentNode.isFolder = fs
|
|
.statSync(currentNode.absolutePath)
|
|
.isDirectory()
|
|
|
|
const children = fs.readdirSync(currentNode.absolutePath)
|
|
|
|
for (let child of children) {
|
|
const absoluteChildPath = path.join(currentNode.absolutePath, child)
|
|
// relative path will only be used in frontend component
|
|
// so, no need to convert '/' to platform specific separator
|
|
const relativeChildPath = `${currentNode.relativePath}/${child}`
|
|
const childNode: TreeNode = {
|
|
name: child,
|
|
relativePath: relativeChildPath,
|
|
absolutePath: absoluteChildPath,
|
|
isFolder: false,
|
|
children: []
|
|
}
|
|
currentNode.children.push(childNode)
|
|
|
|
if (fs.statSync(childNode.absolutePath).isDirectory()) {
|
|
stack.push(childNode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return root
|
|
}
|
|
}
|