Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,19 +152,25 @@ by the engine.
Called when Lua executes `redis.pcall(...)`. Return `{ err: Buffer, code?: Buffer }`
instead of throwing to match Redis behavior.

### Error decoration
### Error metadata

The engine, not the host, owns Redis error formatting:
The engine composes **no** user-facing error wording — it classifies the error and
lets the host render. When a script aborts, the reply carries:

- An error that **aborts** a script (a `redis.call` error that propagates out, or an
uncaught Lua runtime error) is decorated with `script: <sha>, on @user_script:<line>.`,
matching Redis.
- An error **value** the script returns (e.g. `return redis.pcall(...)`) is passed
through untouched.
- `code` — the RESP error class (e.g. `WRONGTYPE`, default `ERR`); preserved from
`redis.call`. See [Reply Types](#reply-types).
- `meta` — `{ line, sha }` always, plus `{ kind, name }` for errors the engine itself
originates (the globals protection). `kind` is an opaque machine tag the host maps
to wording (`global-read`, `global-write`); `name` is the variable involved.
- `err` — for engine-originated errors, the bare `kind` (a machine default). For Lua
runtime / `redis.call` errors, the original message, passed through untouched.

So hosts should return plain, undecorated error messages; the engine adds the script
context. The error `code` (e.g. `WRONGTYPE`) is preserved through this process — see
[Reply Types](#reply-types).
The host owns wording: map `kind` to the Redis message (version-specific if you care)
and decorate with `line`/`sha` as needed
(`<message> script: <sha>, on @user_script:<line>.`). An error **value** the script
returns (e.g. `return redis.pcall(...)`) is passed through untouched.

Hosts should return plain, undecorated error messages.

### log

Expand All @@ -181,7 +187,7 @@ type ReplyValue =
| bigint // Integer (64-bit)
| Buffer // Bulk string
| { ok: Buffer } // Status reply (+OK)
| { err: Buffer; code?: Buffer } // Error reply (-ERR); code is the leading token, e.g. WRONGTYPE
| { err: Buffer; code?: Buffer; meta?: ReplyErrorMeta } // Error reply (-ERR); code e.g. WRONGTYPE, meta for rendering
| ReplyValue[]; // Array
```

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lua-redis-wasm",
"version": "1.4.0",
"version": "1.4.1",
"description": "WebAssembly-based Redis Lua 5.1 script engine for Node.js - Execute Redis-compatible Lua scripts without a live Redis server",
"keywords": [
"redis",
Expand Down
65 changes: 56 additions & 9 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import type {
EngineLimits,
LoadOptions,
ReplyValue,
ReplyErrorMeta,
RedisHost,
RedisCallHandler,
RedisLogHandler,
Expand Down Expand Up @@ -336,32 +337,78 @@ export class LuaEngine {
typeof value === "object" &&
"err" in value
) {
return decorateScriptError(value, sha);
return buildScriptError(value, sha);
}

return value;
}
}

/**
* Appends the Redis script source context to a script-aborting error message.
* Builds a script-aborting error reply. The engine composes no user-facing prose:
*
* - Engine-originated errors (globals protection) arrive as a coded marker; we
* forward `{ kind, name }` in `meta` and the host chooses the wording. `err`
* carries the bare `kind` as a machine-readable default.
* - Lua runtime / redis.call errors already carry their own message (and code);
* they pass through untouched, with only `line`/`sha` attached for the host to
* decorate.
*
* Lua runtime errors carry a `user_script:N:` prefix (N is the line); command
* errors propagated out of redis.call have no prefix and are reported at line 1.
* The error `code` (if any) is preserved.
*/
function decorateScriptError(
function buildScriptError(
value: { err: Buffer; code?: Buffer },
sha: string,
): ReplyValue {
): { err: Buffer; code: Buffer; meta: ReplyErrorMeta } {
const errStr = value.err.toString("utf8");
let line = "1";
let line = 1;
if (errStr.startsWith("user_script:")) {
const colonIdx = errStr.indexOf(":", 12); // after "user_script:"
line = colonIdx > 12 ? errStr.substring(12, colonIdx) : "1";
if (colonIdx > 12) {
line = Number(errStr.substring(12, colonIdx)) || 1;
}
}

const marker = parseErrorMarker(errStr);
if (marker) {
return {
err: Buffer.from(marker.kind, "utf8"),
code: Buffer.from("ERR", "utf8"),
meta: { kind: marker.kind, name: marker.name, line, sha },
};
}

return {
err: value.err,
// Preserve a propagated command code (e.g. WRONGTYPE); otherwise "ERR".
code: value.code ?? Buffer.from("ERR", "utf8"),
meta: { line, sha },
};
}

const ERROR_MARKER = "__RLUA_E__:";

/**
* Engine-originated errors (globals protection, see runtime.c) cross the
* Lua->WASM->JS boundary as a coded string `__RLUA_E__:<kind>:<name>` (Lua errors
* carry no type tag, so the discriminator travels in the string). Splits out the
* opaque `kind` and `name`; the library forwards them and never interprets the
* kind. Returns undefined for ordinary error messages.
*/
function parseErrorMarker(
errStr: string,
): { kind: string; name: string } | undefined {
const idx = errStr.indexOf(ERROR_MARKER);
if (idx < 0) {
return undefined;
}
const rest = errStr.slice(idx + ERROR_MARKER.length); // "<kind>:<name>"
const sep = rest.indexOf(":");
if (sep < 0) {
return undefined;
}
const formatted = `${errStr} script: ${sha}, on @user_script:${line}.`;
return { err: Buffer.from(formatted, "utf8"), code: value.code };
return { kind: rest.slice(0, sep), name: rest.slice(sep + 1) };
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type {
EngineLimits,
LoadOptions,
ReplyValue,
ReplyErrorMeta,
RedisCallHandler,
RedisHost,
RedisLogHandler,
Expand Down
26 changes: 25 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,37 @@
* const arr: ReplyValue = [1, Buffer.from("a"), null];
* ```
*/
/**
* Machine-readable detail attached to every script-aborting error reply. The
* engine composes NO user-facing prose; it classifies the error and lets the
* host pick the wording (which for these is Redis-version-specific).
*
* - `line` (1-based script line) and `sha` (the script's SHA1, already computed
* by the engine) are always present.
* - `kind`/`name` are present only for errors the engine itself originates (the
* globals protection). `kind` is an opaque machine tag the host maps to wording;
* `name` is the variable involved. The reply's `err` carries the bare `kind`.
* Known kinds:
* - `global-read`: read of a nonexistent global. Redis >= 7.0:
* "Script attempted to access nonexistent global variable '<name>'".
* - `global-write`: create/modify of a global. Redis >= 7.0:
* "Attempt to modify a readonly table"; Redis < 7.0:
* "Script attempted to create global variable '<name>'".
*/
export type ReplyErrorMeta = {
kind?: string;
name?: string;
line: number;
sha: string;
};

export type ReplyValue =
| null
| number
| bigint
| Buffer
| { ok: Buffer }
| { err: Buffer; code?: Buffer }
| { err: Buffer; code?: Buffer; meta?: ReplyErrorMeta }
| ReplyValue[];

/**
Expand Down
126 changes: 108 additions & 18 deletions test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,16 +194,14 @@ test("eval: Lua error format matches Redis format", async () => {
const result = engine.eval(script);

assert.ok(result && typeof result === "object" && "err" in result);
const errStr = (result as { err: Buffer }).err.toString("utf8");

// Should match Redis format: "user_script:N: message script: <sha>, on @user_script:N."
// Lua runtime error: err is Lua's raw message (line prefix, no decoration); the
// engine adds no prose, only line/sha metadata for the host to render with.
const r = result as { err: Buffer; meta?: { line: number; sha: string } };
const errStr = r.err.toString("utf8");
assert.ok(errStr.startsWith("user_script:1:"), `Error should start with 'user_script:1:', got: ${errStr}`);
assert.ok(errStr.includes(" script: "), `Error should contain ' script: ', got: ${errStr}`);
assert.ok(errStr.includes(", on @user_script:1."), `Error should end with ', on @user_script:1.', got: ${errStr}`);

// SHA should be 40 hex chars
const shaMatch = errStr.match(/script: ([a-f0-9]{40}),/);
assert.ok(shaMatch, `Error should contain 40-char SHA hex, got: ${errStr}`);
assert.ok(!errStr.includes(" script: "), `Raw err should not be decorated, got: ${errStr}`);
assert.equal(r.meta?.line, 1);
assert.match(r.meta?.sha ?? "", /^[a-f0-9]{40}$/);
});

test("eval: Lua error on different line includes correct line number", async () => {
Expand All @@ -218,11 +216,95 @@ redis.nonexistent() -- line 4
const result = engine.eval(script);

assert.ok(result && typeof result === "object" && "err" in result);
const errStr = (result as { err: Buffer }).err.toString("utf8");
const r = result as { err: Buffer; meta?: { line: number } };

// Raw err references line 4; meta carries it for the host to decorate.
assert.ok(r.err.toString("utf8").startsWith("user_script:4:"), `Error should start with 'user_script:4:', got: ${r.err}`);
assert.equal(r.meta?.line, 4);
});

test("eval: reading a nonexistent global is classified as global-read", async () => {
await resolveWasmPath();
const module = await load();
const engine = module.create(createTestHost());
const result = engine.eval("print('a')") as {
err: Buffer;
meta?: { kind: string; name: string; line: number; sha: string };
};

assert.ok(result && typeof result === "object" && "err" in result);
// Engine emits a machine kind, not wording; no marker leaks into err.
assert.ok(!result.err.toString("utf8").includes("__RLUA_E__"), `marker leaked: ${result.err}`);
assert.equal(result.meta?.kind, "global-read");
assert.equal(result.meta?.name, "print");
assert.equal(result.meta?.line, 1);
assert.match(result.meta?.sha ?? "", /^[a-f0-9]{40}$/);
});

test("eval: creating a global is classified as global-write", async () => {
await resolveWasmPath();
const module = await load();
const engine = module.create(createTestHost());
const result = engine.eval("x = 5") as {
err: Buffer;
code?: Buffer;
meta?: { kind: string; name: string; line: number; sha: string };
};

assert.ok(result && typeof result === "object" && "err" in result);
// The engine composes no user-facing wording; it forwards a machine kind and
// the host maps it (e.g. to "Attempt to modify a readonly table"). No leak.
assert.ok(!result.err.toString("utf8").includes("__RLUA_E__"), `marker leaked: ${result.err}`);
assert.equal(result.meta?.kind, "global-write");
assert.equal(result.meta?.name, "x");
assert.equal(result.meta?.line, 1);
assert.match(result.meta?.sha ?? "", /^[a-f0-9]{40}$/);
assert.equal(result.code?.toString("utf8"), "ERR");
});

test("eval: non-integer number return is truncated to integer", async () => {
await resolveWasmPath();
const module = await load();
const engine = module.create(createTestHost());
assert.equal(engine.eval("return 3.7"), 3);
assert.equal(engine.eval("return 3.3"), 3);
});

test("eval: script with no return value replies with nil", async () => {
await resolveWasmPath();
const module = await load();
const engine = module.create(createTestHost());
assert.equal(engine.eval("local a = 1"), null);
assert.equal(engine.eval("return"), null);
});

test("eval: table with both ok and err is an error (err wins)", async () => {
await resolveWasmPath();
const module = await load();
const engine = module.create(createTestHost());
const result = engine.eval("return {ok='STAT', err='ERRR'}");
assert.ok(result && typeof result === "object" && "err" in result);
assert.equal((result as { err: Buffer }).err.toString("utf8"), "ERRR");
});

test("eval: coroutine library remains available", async () => {
await resolveWasmPath();
const module = await load();
const engine = module.create(createTestHost());
assert.equal((engine.eval("return type(coroutine)") as Buffer).toString(), "table");
});

// Should reference line 4
assert.ok(errStr.startsWith("user_script:4:"), `Error should start with 'user_script:4:', got: ${errStr}`);
assert.ok(errStr.includes(", on @user_script:4."), `Error should end with ', on @user_script:4.', got: ${errStr}`);
test("redis.call: boolean argument is rejected like Redis", async () => {
await resolveWasmPath();
const module = await load();
const engine = module.create(createTestHost());
const result = engine.eval("return redis.call('set','k',true)");
assert.ok(result && typeof result === "object" && "err" in result);
const errStr = (result as { err: Buffer }).err.toString("utf8");
assert.ok(
errStr.includes("must be strings or integers"),
`got: ${errStr}`,
);
});

// =============================================================================
Expand Down Expand Up @@ -840,7 +922,7 @@ test("redis.call() with no args is delegated to the host", async () => {
assert.ok(result && typeof result === "object" && "err" in result);
});

test("redis.call command error is decorated and code is split out", async () => {
test("redis.call command error passes through with code and line/sha metadata", async () => {
await resolveWasmPath();
const module = await load();
const engine = module.create(
Expand All @@ -854,14 +936,22 @@ test("redis.call command error is decorated and code is split out", async () =>
}),
);

const result = engine.eval("return redis.call('GET', 'k')") as { err: Buffer; code?: Buffer };
const result = engine.eval("return redis.call('GET', 'k')") as {
err: Buffer;
code?: Buffer;
meta?: { line: number; sha: string; kind?: string };
};
assert.ok(result && typeof result === "object" && "err" in result);
// Command errors have no user_script prefix -> reported at line 1, code preserved.
assert.match(
// Command errors already carry their message + code; the engine passes them
// through undecorated (no kind), attaching only line/sha for the host.
assert.equal(
result.err.toString("utf8"),
/^Operation against a key holding the wrong kind of value script: [a-f0-9]{40}, on @user_script:1\.$/,
"Operation against a key holding the wrong kind of value",
);
assert.equal(result.code?.toString("utf8"), "WRONGTYPE");
assert.equal(result.meta?.line, 1);
assert.match(result.meta?.sha ?? "", /^[a-f0-9]{40}$/);
assert.equal(result.meta?.kind, undefined);
});

test("redis.pcall error value returned by the script is not decorated", async () => {
Expand Down
Loading
Loading