Skip to content

Commit 63ca2e9

Browse files
committed
api/async exec: debugging how data moves around, documentation, etc
1 parent d6b8787 commit 63ca2e9

File tree

7 files changed

+172
-91
lines changed

7 files changed

+172
-91
lines changed

src/packages/backend/execute-code.test.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -91,22 +91,26 @@ describe("test timeout", () => {
9191
});
9292

9393
describe("async", () => {
94-
it("use ID for async execution", async () => {
95-
const retID = await executeCode({
94+
it("use ID to get async execution result", async () => {
95+
const c = await executeCode({
9696
command: "sh",
97-
args: ["-c", "sleep .1; echo foo;"],
97+
args: ["-c", "sleep .5; echo foo;"],
9898
bash: false,
9999
timeout: 10,
100-
async_exec: true,
100+
async_mode: true,
101101
});
102-
const id = retID["async_id"];
102+
expect(c.async_status).toEqual("running");
103+
expect(c.async_start).toBeGreaterThan(1);
104+
const id = c.async_id;
103105
expect(typeof id).toEqual("string");
104106
if (typeof id === "string") {
105107
await new Promise((done) => setTimeout(done, 1000));
106108
const status = await executeCode({ async_get: id });
107-
console.log("status", status);
109+
expect(status.async_status).toEqual("completed");
108110
expect(status.stdout).toEqual("foo\n");
109111
expect(status.elapsed_s).toBeGreaterThan(0.1);
112+
expect(status.elapsed_s).toBeLessThan(3);
113+
expect(status.async_start).toBeGreaterThan(1);
110114
}
111115
});
112116
});

src/packages/backend/execute-code.ts

+34-10
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ async function executeCodeNoAggregate(
7373
if (cached != null) {
7474
return cached;
7575
} else {
76-
throw new Error(`Status or result of '${opts.async_get}' not found.`);
76+
throw new Error(`Async operation '${opts.async_get}' not found.`);
7777
}
7878
}
7979

@@ -141,23 +141,29 @@ async function executeCodeNoAggregate(
141141
await chmod(tempPath, 0o700);
142142
}
143143

144-
if (opts.async_exec) {
144+
if (opts.async_mode) {
145145
// we return an ID, the caller can then use it to query the status
146-
const async_limit = 1024 * 1024; // we limit how much we keep in memory, to avoid problems
147-
opts.max_output = Math.min(async_limit, opts.max_output ?? async_limit);
146+
opts.max_output ??= 1024 * 1024; // we limit how much we keep in memory, to avoid problems;
147+
opts.timeout ??= 10 * 60;
148148
const id = uuid();
149149
const start = new Date();
150-
const started = {
150+
const started: ExecuteCodeOutput = {
151151
stdout: `Process started running at ${start.toISOString()}`,
152152
stderr: "",
153-
exit_code: start.getTime(),
153+
exit_code: 0,
154+
async_start: start.getTime(),
154155
async_id: id,
156+
async_status: "running",
155157
};
156158
asyncCache.set(id, started);
157159

158-
doSpawn({ ...opts, origCommand }, (err, result) => {
159-
const started = asyncCache.get(id)?.exit_code ?? 0;
160-
const info = { elapsed_s: (Date.now() - started) / 1000 };
160+
doSpawn({ ...opts, origCommand, async_id: id }, (err, result) => {
161+
const started = asyncCache.get(id)?.async_start ?? 0;
162+
const info: Partial<ExecuteCodeOutput> = {
163+
elapsed_s: (Date.now() - started) / 1000,
164+
async_start: start.getTime(),
165+
async_status: "error",
166+
};
161167
if (err) {
162168
asyncCache.set(id, {
163169
stdout: "",
@@ -166,7 +172,11 @@ async function executeCodeNoAggregate(
166172
...info,
167173
});
168174
} else if (result != null) {
169-
asyncCache.set(id, { ...result, ...info });
175+
asyncCache.set(id, {
176+
...result,
177+
...info,
178+
...{ async_status: "completed" },
179+
});
170180
} else {
171181
asyncCache.set(id, {
172182
stdout: "",
@@ -190,6 +200,18 @@ async function executeCodeNoAggregate(
190200
}
191201
}
192202

203+
function update_async(
204+
async_id: string | undefined,
205+
stream: "stdout" | "stderr",
206+
data: string,
207+
) {
208+
if (!async_id) return;
209+
const obj = asyncCache.get(async_id);
210+
if (obj != null) {
211+
obj[stream] = data;
212+
}
213+
}
214+
193215
function doSpawn(
194216
opts,
195217
cb: (err: string | undefined, result?: ExecuteCodeOutput) => void,
@@ -256,6 +278,7 @@ function doSpawn(
256278
} else {
257279
stdout += data;
258280
}
281+
update_async(opts.async_id, "stdout", stdout);
259282
});
260283

261284
r.stderr.on("data", (data) => {
@@ -267,6 +290,7 @@ function doSpawn(
267290
} else {
268291
stderr += data;
269292
}
293+
update_async(opts.async_id, "stderr", stderr);
270294
});
271295

272296
let stderr_is_done = false;

src/packages/next/lib/api/schema/exec.ts

+112-72
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,134 @@
11
import { z } from "../framework";
22

33
import { FailedAPIOperationSchema } from "./common";
4-
import { ProjectIdSchema } from "./projects/common";
54
import { ComputeServerIdSchema } from "./compute/common";
5+
import { ProjectIdSchema } from "./projects/common";
66

77
// OpenAPI spec
88
//
99
export const ExecInputSchema = z
10-
.object({
11-
project_id: ProjectIdSchema,
12-
compute_server_id: ComputeServerIdSchema.describe(
13-
`If provided, the desired shell command will be run on the compute server whose id
10+
.union([
11+
z.object({
12+
project_id: ProjectIdSchema,
13+
compute_server_id: ComputeServerIdSchema.describe(
14+
`If provided, the desired shell command will be run on the compute server whose id
1415
is specified in this field (if available).`,
15-
).optional(),
16-
filesystem: z
17-
.boolean()
18-
.optional()
19-
.describe(
20-
`If \`true\`, this shell command runs in the fileserver container on the compute
16+
).optional(),
17+
filesystem: z
18+
.boolean()
19+
.optional()
20+
.describe(
21+
`If \`true\`, this shell command runs in the fileserver container on the compute
2122
server; otherwise, it runs on the main compute container.`,
22-
),
23-
path: z
24-
.string()
25-
.optional()
26-
.describe(
27-
"Path to working directory in which the shell command should be executed.",
28-
),
29-
command: z.string().describe("The shell command to execute."),
30-
args: z
31-
.array(z.string())
32-
.optional()
33-
.describe("An array of arguments to pass to the shell command."),
34-
timeout: z
35-
.number()
36-
.min(0)
37-
.default(60)
38-
.optional()
39-
.describe("Number of seconds before this shell command times out."),
40-
max_output: z
41-
.number()
42-
.min(0)
43-
.optional()
44-
.describe("Maximum number of bytes to return from shell command output."),
45-
bash: z
46-
.boolean()
47-
.optional()
48-
.describe(
49-
`If \`true\`, this command runs in a \`bash\` shell. To do so, the provided shell
23+
),
24+
path: z
25+
.string()
26+
.optional()
27+
.describe(
28+
"Path to working directory in which the shell command should be executed.",
29+
),
30+
command: z.string().describe("The shell command to execute."),
31+
args: z
32+
.array(z.string())
33+
.optional()
34+
.describe("An array of arguments to pass to the shell command."),
35+
timeout: z
36+
.number()
37+
.min(0)
38+
.default(60)
39+
.optional()
40+
.describe("Number of seconds before this shell command times out."),
41+
max_output: z
42+
.number()
43+
.min(0)
44+
.optional()
45+
.describe(
46+
"Maximum number of bytes to return from shell command output.",
47+
),
48+
bash: z
49+
.boolean()
50+
.optional()
51+
.describe(
52+
`If \`true\`, this command runs in a \`bash\` shell. To do so, the provided shell
5053
command is written to a file and then executed via the \`bash\` command.`,
51-
),
52-
aggregate: z
53-
.union([
54-
z.number(),
55-
z.string(),
56-
z.object({ value: z.union([z.string(), z.number()]) }),
57-
])
58-
.optional()
59-
.describe(
60-
`If provided, this shell command is aggregated as in
54+
),
55+
aggregate: z
56+
.union([
57+
z.number(),
58+
z.string(),
59+
z.object({ value: z.union([z.string(), z.number()]) }),
60+
])
61+
.optional()
62+
.describe(
63+
`If provided, this shell command is aggregated as in
6164
\`src/packages/backend/aggregate.js\`. This parameter allows one to specify
6265
multiple callbacks to be executed against the output of the same command
6366
(given identical arguments) within a 60-second window.`,
64-
),
65-
err_on_exit: z
66-
.boolean()
67-
.optional()
68-
.describe(
69-
`When \`true\`, this call will throw an error whenever the provided shell command
67+
),
68+
err_on_exit: z
69+
.boolean()
70+
.optional()
71+
.describe(
72+
`When \`true\`, this call will throw an error whenever the provided shell command
7073
exits with a non-zero exit code.`,
71-
),
72-
env: z
73-
.record(z.string(), z.string())
74-
.optional()
75-
.describe(
76-
"Environment variables to be passed to the shell command upon execution.",
77-
),
78-
async_exec: z.boolean().optional()
79-
.describe(`If \`true\`, the execution happens asynchroneously.
80-
This means it the API call does not block and returns an ID (\`async_id\`).
81-
Later, use that ID in a call to \`async_get\` to eventually get the result`),
82-
async_get: z.string().optional()
83-
.describe(`For a given \`async_id\` returned by \`async\`,
84-
retun the status, or the result as if it is called synchroneously.
85-
Results are only cached temporarily!`),
86-
})
74+
),
75+
env: z
76+
.record(z.string(), z.string())
77+
.optional()
78+
.describe(
79+
"Environment variables to be passed to the shell command upon execution.",
80+
),
81+
async_mode: z.boolean().optional()
82+
.describe(`If \`true\`, the execution happens asynchroneously.
83+
This means this API call does not block and returns an ID (\`async_id\`).
84+
Later, use that ID in a call to \`async_get\` to eventually get the result.
85+
86+
Additionally and if not specified: \`max_output\` is set to 1MB and and \`timeout\` to 10 minutes.`),
87+
}),
88+
89+
z.object({
90+
project_id: ProjectIdSchema,
91+
async_get: z.string().optional()
92+
.describe(`For a given \`async_id\` returned by \`async\`,
93+
retun the status, or the result as if it is called synchroneously.
94+
Results are only cached temporarily!`),
95+
}),
96+
])
8797
.describe("Perform arbitrary shell commands in a compute server or project.");
8898

8999
export const ExecOutputSchema = z.union([
100+
z
101+
.object({
102+
stdout: z.string().describe("Output to stdout"),
103+
stderr: z.string().describe("Output to stderr"),
104+
exit_code: z
105+
.number()
106+
.describe(
107+
"The numeric exit code. 0 usually means it ran without any issues.",
108+
),
109+
async_id: z
110+
.string()
111+
.optional()
112+
.describe("The ID identifying the async operation (async only)"),
113+
async_start: z
114+
.number()
115+
.optional()
116+
.describe("UNIX timestamp when execution started (async only)"),
117+
elapsed_s: z
118+
.string()
119+
.optional()
120+
.describe("How long the execution took (async only)"),
121+
async_status: z // AsyncStatus
122+
.union([
123+
z.literal("running"),
124+
z.literal("completed"),
125+
z.literal("error"),
126+
])
127+
.optional()
128+
.describe("Status of async operation."),
129+
})
130+
.describe("Output of executed command."),
90131
FailedAPIOperationSchema,
91-
z.any().describe("Output of executed command."),
92132
]);
93133

94134
export type ExecInput = z.infer<typeof ExecInputSchema>;

src/packages/next/pages/api/v2/exec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async function get(req) {
3838
aggregate,
3939
err_on_exit,
4040
env,
41-
async_exec,
41+
async_mode,
4242
async_get,
4343
} = getParams(req);
4444

@@ -63,7 +63,7 @@ async function get(req) {
6363
aggregate,
6464
err_on_exit,
6565
env,
66-
async_exec,
66+
async_mode,
6767
async_get,
6868
},
6969
});

src/packages/project/exec_shell_code.ts

+4
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export async function exec_shell_code(socket: CoCalcSocket, mesg) {
4040
stdout: out?.stdout,
4141
stderr: out?.stderr,
4242
exit_code: out?.exit_code,
43+
async_id: out?.async_id,
44+
async_start: out?.async_start,
45+
async_status: out?.async_status,
46+
elapsed_s: out?.elapsed_s,
4347
}),
4448
);
4549
} catch (err) {

src/packages/util/message.js

+4
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,10 @@ message({
824824
stdout: required,
825825
stderr: required,
826826
exit_code: required,
827+
async_id: undefined,
828+
async_start: undefined,
829+
async_status: undefined,
830+
elapsed_s: undefined,
827831
});
828832

829833
//#####################################################################

src/packages/util/types/execute-code.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
export type AsyncStatus = "running" | "completed" | "error";
2+
13
export interface ExecuteCodeOutput {
24
stdout: string;
35
stderr: string;
46
exit_code: number;
7+
async_start?: number;
58
async_id?: string;
9+
async_status?: AsyncStatus;
610
elapsed_s?: number; // how long it took, async execution
711
}
812

@@ -22,7 +26,8 @@ export interface ExecuteCodeOptions {
2226
env?: object; // if given, added to exec environment
2327
aggregate?: string | number; // if given, aggregates multiple calls with same sequence number into one -- see @cocalc/util/aggregate; typically make this a timestamp for compiling code (e.g., latex).
2428
verbose?: boolean; // default true -- impacts amount of logging
25-
async_exec?: boolean; // default false -- if true, return an ID and execute it asynchroneously
29+
async_mode?: boolean; // default false -- if true, return an ID and execute it asynchroneously
30+
async_get?: string; // if given, retrieve status or result of that async operation
2631
}
2732

2833
export interface ExecuteCodeOptionsAsyncGet {

0 commit comments

Comments
 (0)