eyeprolog 1.6.3 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.6.3",
6
+ "version": "1.6.4",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/cleanup.js CHANGED
@@ -163,6 +163,13 @@ function closeSearchStack(stack, suppressErrors) {
163
163
  closeFrames(removed, suppressErrors);
164
164
  }
165
165
 
166
+ // A caught exception discards only the protected goal's alternatives. Cleanup
167
+ // must run before recovery, without replacing the exception being unwound.
168
+ export function unwindSearchStack(stack, start) {
169
+ const removed = Array.prototype.splice.call(stack, start, stack.length - start);
170
+ closeFrames(removed, true);
171
+ }
172
+
166
173
  function closeFrames(frames, suppressErrors) {
167
174
  let firstError = null;
168
175
  for (let index = frames.length - 1; index >= 0; index--) {
package/src/iso.js CHANGED
@@ -141,7 +141,7 @@ const isoBuiltins = {
141
141
  for (let arity = 2; arity <= 8; arity++) {
142
142
  registry.add('call', arity, callClosureBuiltin, { expandGoal: expandCallClosureGoal });
143
143
  }
144
- registry.add('catch', 3, catchBuiltin);
144
+ registry.add('catch', 3, catchBuiltin, { catchControl });
145
145
  registry.add('throw', 1, throwBuiltin, { deterministic: true });
146
146
  registry.add('\\+', 1, negationBuiltin, { deterministic: true });
147
147
  registry.add('once', 1, onceBuiltin, { deterministic: true });
@@ -3106,6 +3106,21 @@ function prologErrorBall(error) {
3106
3106
  }
3107
3107
  const catchBuiltin = pendingBuiltin(catchSolutions, true);
3108
3108
 
3109
+ // The solver runs protected goals on its explicit search stack. Keep ball
3110
+ // conversion and callable validation here, shared with the builtin semantics.
3111
+ const catchControl = {
3112
+ prepare: ({ goal, env }) => callable(goal.args[0], env),
3113
+ recover({ goal, env, error }) {
3114
+ const ball = error instanceof ThrownTerm
3115
+ ? error.term
3116
+ : error instanceof PrologError ? prologErrorBall(error) : null;
3117
+ if (ball == null) return null;
3118
+ const recovered = env.clone();
3119
+ if (!unify(goal.args[1], ball, recovered)) return null;
3120
+ return { goal: callable(goal.args[2], recovered), env: recovered };
3121
+ },
3122
+ };
3123
+
3109
3124
  function* catchSolutions({ solver, goal, env }, state) {
3110
3125
  let child = null;
3111
3126
  try {
@@ -3297,6 +3312,7 @@ export class BuiltinRegistry {
3297
3312
  deterministic: options.deterministic ?? false,
3298
3313
  deterministicWhen: options.deterministicWhen ?? null,
3299
3314
  expandGoal: options.expandGoal ?? null,
3315
+ catchControl: options.catchControl ?? null,
3300
3316
  ready: options.ready ?? null,
3301
3317
  fallbackWhenNotReady: options.fallbackWhenNotReady ?? false,
3302
3318
  shouldUse: options.shouldUse ?? null,
package/src/solver.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from './term.js';
8
8
  import { numberValueKey, sameNumberValue } from './number-value.js';
9
9
  import { attachBuiltinErrorContext } from './errors.js';
10
+ import { unwindSearchStack } from './cleanup.js';
10
11
  import { PrologError, getStrictIsoRegistry } from './iso.js';
11
12
  import { getEyePrologRegistry } from './standard-library.js';
12
13
  import { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program-indexing.js';
@@ -462,7 +463,11 @@ export class Solver {
462
463
  const stack = [{ kind: 'goals', goals, env, depth, active: savedActive.slice() }];
463
464
  registeredStack = stack;
464
465
  this.solveStacks.push(stack);
466
+ let active = savedActive;
465
467
  while (stack.length) {
468
+ const frame = stack.pop();
469
+ active = frame.active ?? active;
470
+ try {
466
471
  this.inferences++;
467
472
  this.inferenceObservation.value++;
468
473
  this.checkMemoryLimit();
@@ -470,7 +475,6 @@ export class Solver {
470
475
  this.inferenceLimitExceeded = true;
471
476
  break;
472
477
  }
473
- const frame = stack.pop();
474
478
  this.syncProgramRevision();
475
479
  if (frame.kind === 'resumeBuiltin') {
476
480
  if (this.solutionsSeen >= this.solutionLimit) continue;
@@ -623,7 +627,7 @@ export class Solver {
623
627
  env.setOccursCheckHandler(this.occursCheckHandler);
624
628
  env.setAttributeHookRunner(this.attributeHookRunner);
625
629
  depth = frame.depth;
626
- let active = frame.active;
630
+ active = frame.active;
627
631
 
628
632
  while (true) {
629
633
  this.inferences++;
@@ -673,6 +677,12 @@ export class Solver {
673
677
  }
674
678
 
675
679
  const first = goals[0];
680
+ if (first?.kind === 'exitCatch') {
681
+ active = first.active;
682
+ depth = first.depth;
683
+ goals = first.goals;
684
+ continue;
685
+ }
676
686
  if (first?.kind === 'continueGoals') {
677
687
  if (first.releaseActive) active = active.slice(0, -1);
678
688
  depth = first.depth;
@@ -749,6 +759,27 @@ export class Solver {
749
759
  const def = callable ? this.lookupBuiltin(goal.name, goal.arity) : null;
750
760
  this.active = active;
751
761
  const builtinReady = def && builtinIsReadyOrAuthoritative(def, this, goal, env);
762
+ if (builtinReady && def.catchControl != null) {
763
+ // Backtracking frames retain this handler in their active path;
764
+ // exitCatch removes it before executing the caller's continuation.
765
+ // Nesting protected goals therefore needs no recursive solve() call.
766
+ const handler = {
767
+ goal, env: env.clone(), definition: def,
768
+ parentActive: active, goals: rest, depth,
769
+ searchStack: stack, stackDepth: stack.length,
770
+ };
771
+ active = [...active, handler];
772
+ let invoked;
773
+ try {
774
+ invoked = def.catchControl.prepare({ goal, env });
775
+ } catch (error) {
776
+ throw attachBuiltinErrorContext(error, def, goal);
777
+ }
778
+ goals = [invoked, { kind: 'exitCatch', active: handler.parentActive, goals: rest, depth }];
779
+ env = env.clone();
780
+ depth++;
781
+ continue;
782
+ }
752
783
  if (builtinReady && typeof def.expandGoal === 'function') {
753
784
  // Meta-calls execute in this continuation instead of hiding their
754
785
  // search behind a host-generator frame. Their real clause, control,
@@ -968,20 +999,43 @@ export class Solver {
968
999
  pushUserGoalUncachedFrames(stack, this, group, goal, rest, env, depth, active);
969
1000
  break;
970
1001
  }
971
- }
972
- } catch (error) {
973
- const normalized = normalizeHostResourceError(error);
974
- if (normalized instanceof PrologError && normalized.formal === 'resource_error(memory)') {
975
- // Unwinding makes query-local terms unreachable, but hosts are free to
976
- // postpone collection. Give the shared solver family bounded breathing
977
- // room on its next query so a GC can observe those released references.
978
- if (!this.memoryRecovery.active) {
979
- this.memoryRecovery.active = true;
980
- this.memoryRecovery.reservationBytes = 1024 * 1024;
981
- this.memoryRecovery.checks = 16;
1002
+ } catch (caught) {
1003
+ let error = normalizeSolverResourceError(this, caught);
1004
+ let recovered = false;
1005
+ for (let index = active.length - 1; index >= 0; index--) {
1006
+ const handler = active[index];
1007
+ // Reentrant control builtins inherit active cut markers, but must
1008
+ // propagate exceptions back to the solve() that owns the handler.
1009
+ if (handler.searchStack !== stack) continue;
1010
+ unwindSearchStack(stack, handler.stackDepth);
1011
+ active = handler.parentActive;
1012
+ let recovery;
1013
+ try {
1014
+ recovery = handler.definition.catchControl.recover({ ...handler, error });
1015
+ } catch (replacement) {
1016
+ error = attachBuiltinErrorContext(replacement, handler.definition, handler.goal);
1017
+ index = active.length;
1018
+ continue;
1019
+ }
1020
+ if (recovery == null) {
1021
+ index = active.length;
1022
+ continue;
1023
+ }
1024
+ // Recovery is outside this handler and has its own opaque cut scope.
1025
+ const invocation = { goal: recovery.goal, env: recovery.env._snapshotForSolverRead() };
1026
+ stack.push({
1027
+ kind: 'goals',
1028
+ goals: [recovery.goal, { kind: 'exitCatch', active, goals: handler.goals, depth: handler.depth }],
1029
+ env: recovery.env, depth: handler.depth + 1, active: [...active, invocation],
1030
+ });
1031
+ recovered = true;
1032
+ break;
982
1033
  }
1034
+ if (!recovered) throw error;
983
1035
  }
984
- throw normalized;
1036
+ }
1037
+ } catch (error) {
1038
+ throw normalizeSolverResourceError(this, error);
985
1039
  } finally {
986
1040
  const stackIndex = this.solveStacks.indexOf(registeredStack);
987
1041
  if (stackIndex >= 0) this.solveStacks.splice(stackIndex, 1);
@@ -1152,6 +1206,20 @@ export class Solver {
1152
1206
 
1153
1207
  }
1154
1208
 
1209
+ function normalizeSolverResourceError(solver, error) {
1210
+ const normalized = normalizeHostResourceError(error);
1211
+ if (normalized instanceof PrologError && normalized.formal === 'resource_error(memory)') {
1212
+ // Leave bounded breathing room for recovery after abandoned query terms
1213
+ // become garbage, including when a handler in this search catches the error.
1214
+ if (!solver.memoryRecovery.active) {
1215
+ solver.memoryRecovery.active = true;
1216
+ solver.memoryRecovery.reservationBytes = 1024 * 1024;
1217
+ solver.memoryRecovery.checks = 16;
1218
+ }
1219
+ }
1220
+ return normalized;
1221
+ }
1222
+
1155
1223
  function normalizeHostResourceError(error) {
1156
1224
  if (error?.name !== 'RangeError') return error;
1157
1225
  const message = String(error?.message ?? '');
@@ -1160,9 +1228,14 @@ function normalizeHostResourceError(error) {
1160
1228
  // with the resource atom implementation dependent. A finite host capacity
1161
1229
  // ceiling is reported as `memory`; reserve `finite_memory` for the separate
1162
1230
  // convention where no finite amount of memory can complete the computation.
1163
- if (/^(?:Map|Set) maximum size exceeded$/.test(message)) {
1231
+ // Do not run a regular expression here: V8 can overflow while compiling it
1232
+ // on an already exhausted stack, masking the original error (issue #117).
1233
+ if (message === 'Map maximum size exceeded' || message === 'Set maximum size exceeded') {
1164
1234
  return new PrologError('resource_error(memory)');
1165
1235
  }
1236
+ if (message === 'Maximum call stack size exceeded') {
1237
+ return new PrologError('resource_error(stack)');
1238
+ }
1166
1239
  return error;
1167
1240
  }
1168
1241
 
@@ -1080,11 +1080,12 @@ answer(ok) :-
1080
1080
  },
1081
1081
  },
1082
1082
  {
1083
- name: 'host Map/Set capacity errors become resource_error(memory)',
1083
+ name: 'host capacity and stack errors become Prolog resource errors',
1084
1084
  run: () => {
1085
- for (const [predicate, message] of [
1086
- ['exhaust_map', 'Map maximum size exceeded'],
1087
- ['exhaust_set', 'Set maximum size exceeded'],
1085
+ for (const [predicate, message, resource] of [
1086
+ ['exhaust_map', 'Map maximum size exceeded', 'memory'],
1087
+ ['exhaust_set', 'Set maximum size exceeded', 'memory'],
1088
+ ['exhaust_stack', 'Maximum call stack size exceeded', 'stack'],
1088
1089
  ]) {
1089
1090
  const registry = new BuiltinRegistry();
1090
1091
  registry.add(predicate, 0, function* () {
@@ -1099,10 +1100,55 @@ answer(ok) :-
1099
1100
  caught = error;
1100
1101
  }
1101
1102
  assertEqual(caught?.name, 'PrologError', `${predicate} normalized error type`);
1102
- assertEqual(caught?.formal, 'resource_error(memory)', `${predicate} normalized resource error`);
1103
+ assertEqual(caught?.formal, `resource_error(${resource})`, `${predicate} normalized resource error`);
1103
1104
  }
1104
1105
  },
1105
1106
  },
1107
+ {
1108
+ name: 'unrelated host RangeErrors retain their identity',
1109
+ run: () => {
1110
+ const original = new RangeError('unrelated host failure');
1111
+ const registry = new BuiltinRegistry();
1112
+ registry.add('host_failure', 0, function* () { throw original; });
1113
+ const solver = new Solver(Program.parse(''), { registry });
1114
+ let caught;
1115
+ try {
1116
+ [...solver.solve([parseGoalText('host_failure')], new Env(), 0)];
1117
+ } catch (error) {
1118
+ caught = error;
1119
+ }
1120
+ assertEqual(caught, original, 'unrecognized host error');
1121
+ },
1122
+ },
1123
+ {
1124
+ name: 'nested catch succeeds on cold and warm runs with a small host stack (issue #117)',
1125
+ run: () => {
1126
+ const engineUrl = new URL('../src/index.js', testDirUrl).href;
1127
+ const script = `
1128
+ import { Program, Solver, Env, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
1129
+ const program = Program.parse('deep(0,G) :- !, G. deep(N,G) :- M is N-1, catch(deep(M,G),-,true).');
1130
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
1131
+ const solve = (text) => [...solver.solve([parseGoalText(text)], new Env(), 0)];
1132
+ for (let attempt = 0; attempt < 3; attempt++) {
1133
+ for (const depth of [550, 1500]) {
1134
+ if (solve('deep(' + depth + ',true)').length !== 1) {
1135
+ throw new Error('nested catch failed');
1136
+ }
1137
+ if (solve('catch(deep(' + depth + ',throw(ball)),ball,true)').length !== 1) {
1138
+ throw new Error('nested catch did not unwind to the matching handler');
1139
+ }
1140
+ }
1141
+ }
1142
+ process.stdout.write('passed');
1143
+ `;
1144
+ const result = spawnSync(process.execPath, [
1145
+ '--stack-size=256', '--input-type=module', '--eval', script,
1146
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 10000 });
1147
+ if (result.error) throw result.error;
1148
+ assertEqual(result.status, 0, `issue #117 child status; stderr=${result.stderr}`);
1149
+ assertEqual(result.stdout, 'passed', 'issue #117 repeated deep catch execution');
1150
+ },
1151
+ },
1106
1152
  {
1107
1153
  name: 'unbounded length/2 reaches a catchable memory resource error (issue #49)',
1108
1154
  run: () => {
@@ -37,6 +37,19 @@ import {
37
37
 
38
38
  export function regressionCases() {
39
39
  return [
40
+ {
41
+ name: 'REPL repeated nested catch succeeds from the first query (issue #117)',
42
+ run: () => {
43
+ const file = path.join(temp.dir, 'deep-catch-117.pl');
44
+ fs.writeFileSync(file, 'deep(0,G) :- !, G.\ndeep(N,G) :- M is N-1, catch(deep(M,G),-,true).\n');
45
+ const result = runCli([], { input:
46
+ `[${sourceAtom(file)}].\ndeep(550,true).\ndeep(550,true).\ndeep(550,true).\nhalt.\n`,
47
+ });
48
+ assertEqual(result.status, 0, result.stderr);
49
+ assertNotIncludes(result.stdout + result.stderr, 'error(', 'all repeated queries succeed');
50
+ assertEqual((result.stdout.match(/\btrue\./g) ?? []).length, 4, 'consult and three successful queries');
51
+ },
52
+ },
40
53
  {
41
54
  name: 'close/1 accepts a $stream/1 term rebuilt via functor/3 and arg/3 that is structurally == to the real handle (issue #109)',
42
55
  run: () => {