• source navigation  • diff markup  • identifier search  • freetext search  • 

Sources/ucode/tests/custom/99_debugger/run_debugger_tests.uc

  1 #!/usr/bin/env -S ucode -S
  2 
  3 // Debugger Protocol Test Runner (Standalone)
  4 // ===========================================
  5 // Standalone test runner for the debugger's line-based protocol
  6 // (see lib/debug_proto.h) that doesn't rely on cram-style infrastructure.
  7 //
  8 // Each test starts the target script unmodified via `-X1` (attach mode with
  9 // an initial breakpoint at line 1 - the same mechanism the interactive `-x`
 10 // CLI and SIGUSR1 attach use), connects to the resulting PID-derived attach
 11 // socket directly with the `socket` module, sends a batch of protocol
 12 // messages, and asserts on the *parsed* response messages and/or the target
 13 // script's own stdout - not on rendered ANSI text, since the server no
 14 // longer renders anything.
 15 //
 16 // `-X1` (rather than `debug.listen(path)`, which drives the whole session
 17 // from *inside* a nested native call and needs `uc_vm_resume()` to hand
 18 // control back to the VM's bytecode loop) matters here, not just for
 19 // realism: breakpoints set *during* a debug.listen() session and hit via a
 20 // later CONTINUE don't reliably re-fire through that nested-resume path,
 21 // whereas `-X`/`-x`'s breakpoint-loop-driven pause (bk_enter_session invoked
 22 // directly from the VM's per-instruction dispatch, see vm.c
 23 // uc_vm_decode_insn()) does not have this problem - every test below that
 24 // needs a *second* pause after a CONTINUE relies on this.
 25 
 26 import * as fs from 'fs';
 27 import * as sock from 'socket';
 28 
 29 let testdir = sourcepath(0, true);
 30 let topdir = fs.realpath(`${testdir}/..`);
 31 let tmpdir = '/tmp/debugger_test.' + system('echo $$');
 32 
 33 // UCODE_BIN may be a plain executable path, or (as set by the "custom"/
 34 // "debugger" ctest targets) a full command line like
 35 // "valgrind --quiet --leak-check=full /path/to/ucode" that needs to be
 36 // invoked word-split, not treated as a single path - so fs.dirname(ucode_bin)
 37 // would be nonsense for such a value. UCODE_LIB is set alongside it by those
 38 // same ctest targets specifically to give the correct library directory
 39 // without needing to parse UCODE_BIN at all.
 40 let ucode_bin = getenv('UCODE_BIN') || '/home/jow/devel/ucode.git/build/ucode';
 41 let libdir = getenv('UCODE_LIB') || fs.dirname(ucode_bin);
 42 
 43 function shq(s) {
 44         return `'${replace(s, "'", "'\\''")}'`;
 45 }
 46 
 47 let n_tests = 0;
 48 let n_passed = 0;
 49 let n_failed = 0;
 50 let n_crashed = 0;
 51 let n_timeout = 0;
 52 
 53 // Generous enough to tolerate running under valgrind --leak-check=full
 54 // (the "custom"/"debugger" ctest targets always do), which can slow
 55 // process startup and breakpoint hits down substantially.
 56 let TEST_TIMEOUT = 30;
 57 
 58 function mkdir_p(path) {
 59         let parts = split(rtrim(path, '/') || '/', /\/+/);
 60         let current = '';
 61         for (let part in parts) {
 62                 current += part + '/';
 63                 if (!fs.access(current)) {
 64                         fs.mkdir(current);
 65                 }
 66         }
 67 }
 68 
 69 // Connect to the given Unix domain socket path, retrying for a bit while
 70 // the target process is still starting up / hasn't armed its attach socket
 71 // yet.
 72 function connect_retry(path, timeout_sec) {
 73         let deadline = time() + timeout_sec;
 74 
 75         while (time() < deadline) {
 76                 let conn = sock.connect({ family: sock.AF_UNIX, path });
 77 
 78                 if (conn) {
 79                         // Bound recv() below so a quiet socket never blocks forever.
 80                         conn.setopt(sock.SOL_SOCKET, sock.SO_RCVTIMEO, { sec: 0, usec: 20000 });
 81                         return conn;
 82                 }
 83 
 84                 sleep(10);
 85         }
 86 
 87         return null;
 88 }
 89 
 90 // Read every complete "VERB [json]" line already available on `conn` right
 91 // now (non-blocking-ish: short poll loop), parsing each into
 92 // { verb, payload }. Stops once nothing new arrives for a short quiet
 93 // period, since responses may legitimately be a variable-length burst
 94 // (e.g. BREAK + BREAKPOINT_ADDED, then a later EVENT exit).
 95 function drain_messages(conn, quiet_ms) {
 96         let messages = [];
 97         let buf = '';
 98         let idle = 0;
 99 
100         while (idle < quiet_ms) {
101                 let chunk = conn.recv(65536);
102 
103                 if (chunk == null || chunk == '') {
104                         idle += 10;
105                         sleep(10);
106                         continue;
107                 }
108 
109                 idle = 0;
110                 buf += chunk;
111 
112                 let nl;
113                 while ((nl = index(buf, "\n")) >= 0) {
114                         let line = substr(buf, 0, nl);
115                         buf = substr(buf, nl + 1);
116 
117                         if (line == '')
118                                 continue;
119 
120                         let sp = index(line, ' ');
121                         let verb = (sp >= 0) ? substr(line, 0, sp) : line;
122                         let payload = (sp >= 0) ? json(substr(line, sp + 1)) : null;
123 
124                         push(messages, { verb, payload });
125                 }
126         }
127 
128         return messages;
129 }
130 
131 // Run `source_code` unmodified via `-X1` (attach mode, breaking at line 1)
132 // with a batch of protocol messages sent all at once - pacing doesn't
133 // matter since the server processes them strictly in order off its
134 // blocking read loop regardless of when they were written, and these tests
135 // only care about the final observable state (script stdout + which
136 // responses came back), not interactive timing.
137 function run_debugger(source_code, steps, timeout_sec) {
138         if (timeout_sec == null) timeout_sec = TEST_TIMEOUT;
139         mkdir_p(tmpdir);
140 
141         let source_file = `${tmpdir}/source.uc`;
142         let stdout_file = `${tmpdir}/stdout.out`;
143         let stderr_file = `${tmpdir}/stderr.err`;
144         let wrapper_file = `${tmpdir}/wrapper.sh`;
145         let pid_file = `${tmpdir}/pid`;
146 
147         fs.writefile(source_file, source_code);
148         fs.unlink(pid_file);
149 
150         // Runs the target in the background (recording its real PID, *not*
151         // some wrapping shell's) and waits for it, so this test process can
152         // concurrently drive the PID-derived attach socket while the target is
153         // paused at its line-1 breakpoint. Enforces its own timeout by killing
154         // the PID directly rather than via `timeout`, since `timeout` would be
155         // the one owning the PID `$!` reports otherwise.
156         // resolve_breakpoint() can't default the path from a current frame
157         // before the program has started running (there is none yet), so the
158         // pre-execution `-X` breakpoint spec needs an explicit "path:line"
159         // rather than a bare line number.
160         // `ucode_bin` is deliberately left unquoted below: under the "custom"/
161         // "debugger" ctest targets it is itself a multi-word command
162         // ("valgrind --quiet --leak-check=full /path/ucode") that needs to be
163         // word-split so valgrind sees its own flags, not one opaque argument.
164         fs.writefile(wrapper_file, sprintf(
165                 'cd %s\n' +
166                 'export LD_LIBRARY_PATH=%s\n' +
167                 '%s -L %s -X%s:1 %s > %s 2> %s &\n' +
168                 'echo $! > %s\n' +
169                 'wait $!\n' +
170                 'echo "EXIT:$?"\n',
171                 shq(topdir), shq(libdir), ucode_bin, shq(libdir), shq(source_file), shq(source_file),
172                 shq(stdout_file), shq(stderr_file), shq(pid_file)
173         ));
174 
175         let proc = fs.popen(`sh ${wrapper_file}`, 'r');
176         let deadline = time() + timeout_sec;
177         let pid = null;
178 
179         while (time() < deadline && !pid) {
180                 if (fs.access(pid_file)) {
181                         let s = trim(fs.readfile(pid_file) ?? '');
182                         if (s != '')
183                                 pid = int(s);
184                 }
185 
186                 if (!pid)
187                         sleep(10);
188         }
189 
190         let sock_path = pid ? sprintf('/tmp/ucode-debug-%d.sock', pid) : null;
191         let conn = sock_path ? connect_retry(sock_path, timeout_sec) : null;
192         let messages = [];
193 
194         if (conn) {
195                 // First message is always the initial PAUSED (from the -X1
196                 // breakpoint at line 1). The quiet periods below are tuned short
197                 // (150/300ms) to keep the suite fast: the server emits each verb's
198                 // response as a tight burst with no deliberate pacing, so a burst
199                 // settles well inside that window even under valgrind. If a slow CI
200                 // host ever drops a late frame, nudge the second value up.
201                 for (let msg in drain_messages(conn, 150))
202                         push(messages, msg);
203 
204                 let lines = [];
205                 for (let step in steps)
206                         push(lines, step);
207                 // Let anything already paused (including the initial -X1 pause,
208                 // for tests with no steps of their own) run to completion first;
209                 // QUIT is just a safety net in case something is still paused
210                 // afterward (a harmless no-op otherwise, since the connection is
211                 // already gone by the time it'd be read).
212                 push(lines, 'CONTINUE');
213                 push(lines, 'QUIT');
214 
215                 conn.send(join("\n", lines) + "\n");
216 
217                 for (let msg in drain_messages(conn, 300))
218                         push(messages, msg);
219 
220                 conn.close();
221         }
222         else if (pid) {
223                 // Never connected (e.g. no attach socket appeared) - don't leave
224                 // the target hanging around forever.
225                 system(`kill -9 ${pid} 2>/dev/null`);
226         }
227 
228         let wrapper_out = proc.read('all') ?? '';
229         proc.close();
230 
231         let exitcode = -1;
232         let m = match(wrapper_out, /EXIT:(-?[0-9]+)/);
233         if (m) exitcode = int(m[1]);
234 
235         let stdout = fs.access(stdout_file) ? fs.readfile(stdout_file) ?? '' : '';
236         let stderr = fs.access(stderr_file) ? fs.readfile(stderr_file) ?? '' : '';
237 
238         let timed_out = (exitcode == -1 && !conn);
239 
240         return { stdout, stderr, exitcode, timed_out, messages, connected: !!conn };
241 }
242 
243 // True if any received message has the given verb (optionally further
244 // filtered by a predicate over its payload).
245 function has_message(messages, verb, pred) {
246         for (let msg in messages) {
247                 if (msg.verb != verb)
248                         continue;
249 
250                 if (!pred || pred(msg.payload))
251                         return true;
252         }
253 
254         return false;
255 }
256 
257 function run_test(name, source_code, steps, expectations) {
258         n_tests++;
259 
260         let result = run_debugger(source_code, steps);
261         let failed = false;
262         let exp = expectations ?? {};
263 
264         if (exp.must_connect && !result.connected) {
265                 printf("FAIL %s: could not connect to debug socket\n", name);
266                 n_failed++;
267                 n_crashed++;
268                 return false;
269         }
270 
271         if (result.exitcode < 0 && result.exitcode != -1) {
272                 if (exp.no_crash) {
273                         printf("FAIL %s: crashed (exit code %d)\n", name, result.exitcode);
274                         printf("  stderr: %s\n", substr(result.stderr, 0, 200));
275                         n_failed++;
276                         n_crashed++;
277                         return false;
278                 }
279         }
280 
281         if (result.timed_out) {
282                 if (exp.no_timeout) {
283                         printf("FAIL %s: timed out after %ds\n", name, TEST_TIMEOUT);
284                         n_failed++;
285                         n_timeout++;
286                         return false;
287                 }
288         }
289 
290         if (exp.stdout_contains) {
291                 for (let pattern in exp.stdout_contains) {
292                         let re = (type(pattern) == 'string') ? regexp(pattern) : pattern;
293                         if (!match(result.stdout, re)) {
294                                 printf("FAIL %s: stdout does not contain '%s'\n", name, pattern);
295                                 printf("  Got: %s\n", substr(result.stdout, 0, 200));
296                                 failed = true;
297                         }
298                 }
299         }
300 
301         if (exp.messages_contain) {
302                 for (let verb in exp.messages_contain) {
303                         if (!has_message(result.messages, verb)) {
304                                 printf("FAIL %s: no '%s' response received\n", name, verb);
305                                 printf("  Got verbs: %s\n", join(', ', map(result.messages, (m) => m.verb)));
306                                 failed = true;
307                         }
308                 }
309         }
310 
311         if (exp.check) {
312                 let msg = exp.check(result);
313                 if (msg) {
314                         printf("FAIL %s: %s\n", name, msg);
315                         failed = true;
316                 }
317         }
318 
319         if (!failed) {
320                 printf("PASS %s\n", name);
321                 n_passed++;
322                 return true;
323         }
324 
325         n_failed++;
326         return false;
327 }
328 
329 // ============================================================================
330 // TEST SUITES
331 // ============================================================================
332 
333 function test_basic_breakpoint() {
334         printf("\n## Basic Breakpoint Tests\n\n");
335 
336         run_test("break_at_line",
337                 `print("hello");
338 print("world");
339 print("done");`,
340                 ['BREAK {"spec":"2"}', 'CONTINUE'],
341                 { messages_contain: ['PAUSED', 'BREAKPOINT_ADDED'], no_crash: true }
342         );
343 
344         run_test("break_function",
345                 `function test() {
346         print("in test");
347 }
348 test();`,
349                 ['BREAK {"spec":"test"}', 'CONTINUE'],
350                 { messages_contain: ['PAUSED', 'BREAKPOINT_ADDED'], no_crash: true }
351         );
352 
353         run_test("break_multiple",
354                 `print("a");
355 print("b");
356 print("c");`,
357                 ['BREAK {"spec":"1"}', 'BREAK {"spec":"2"}', 'LIST_BREAKPOINTS', 'CONTINUE'],
358                 {
359                         messages_contain: ['BREAKPOINTS'],
360                         no_crash: true,
361                         check: (r) => {
362                                 let bp = null;
363                                 for (let m in r.messages)
364                                         if (m.verb == 'BREAKPOINTS') bp = m.payload;
365                                 if (!bp || length(bp.items) < 2)
366                                         return "expected at least 2 breakpoints listed";
367                                 return null;
368                         }
369                 }
370         );
371 
372         run_test("delete_breakpoint",
373                 `print("a");
374 print("b");`,
375                 ['BREAK {"spec":"1"}', 'DELETE {"id":1}', 'LIST_BREAKPOINTS', 'CONTINUE'],
376                 { messages_contain: ['OK'], no_crash: true }
377         );
378 }
379 
380 function test_execution_control() {
381         printf("\n## Execution Control Tests\n\n");
382 
383         run_test("step_command",
384                 `let x = 1;
385 let y = 2;
386 let z = x + y;`,
387                 ['STEP', 'STEP', 'STEP', 'CONTINUE'],
388                 { must_connect: true, no_crash: true }
389         );
390 
391         run_test("next_command",
392                 `function inner() { return 1; }
393 function outer() { return inner() + 1; }
394 outer();`,
395                 ['BREAK {"spec":"outer"}', 'CONTINUE', 'NEXT', 'NEXT', 'CONTINUE'],
396                 { must_connect: true, no_crash: true }
397         );
398 
399         run_test("continue_command",
400                 `print("a");
401 print("b");
402 print("c");`,
403                 ['BREAK {"spec":"2"}', 'CONTINUE', 'CONTINUE'],
404                 { stdout_contains: ['a', 'b', 'c'], no_crash: true }
405         );
406 
407         run_test("return_command",
408                 `function inner() { return 1; }
409 function outer() { return inner() + 1; }
410 outer();`,
411                 ['BREAK {"spec":"inner"}', 'CONTINUE', 'RETURN', 'CONTINUE'],
412                 { must_connect: true, no_crash: true }
413         );
414 
415         run_test("quit_command",
416                 `print("a");
417 print("b");
418 print("c");`,
419                 [],
420                 { no_crash: true }
421         );
422 }
423 
424 function test_variable_inspection() {
425         printf("\n## Variable Inspection Tests\n\n");
426 
427         // STEP (rather than BREAK+CONTINUE to a line number) advances past the
428         // declarations here: resolving a bare line-number breakpoint against a
429         // script that is only variable declarations is a pre-existing, narrow
430         // edge case in resolve_breakpoint()/lookup_stmt_boundary() (unrelated
431         // to the protocol) that single-instruction stepping avoids entirely.
432         run_test("print_simple_var",
433                 `let x = 42;
434 let y = "hello";
435 1;`,
436                 ['STEP', 'STEP', 'PRINT {"expr":"x"}', 'PRINT {"expr":"y"}', 'CONTINUE'],
437                 {
438                         no_crash: true,
439                         check: (r) => {
440                                 let vals = [];
441                                 for (let m in r.messages)
442                                         if (m.verb == 'VALUE') push(vals, m.payload.repr);
443                                 if (!vals[0] || index(vals[0], '42') < 0) return "expected 42 in first VALUE";
444                                 if (!vals[1] || index(vals[1], 'hello') < 0) return "expected hello in second VALUE";
445                                 return null;
446                         }
447                 }
448         );
449 
450         run_test("print_expression",
451                 `let a = 10;
452 let b = 20;
453 print(a + b);`,
454                 ['STEP', 'STEP', 'PRINT {"expr":"a + b"}', 'CONTINUE'],
455                 {
456                         no_crash: true,
457                         check: (r) => {
458                                 for (let m in r.messages)
459                                         if (m.verb == 'VALUE' && index(m.payload.repr, '30') >= 0) return null;
460                                 return "expected VALUE containing 30";
461                         }
462                 }
463         );
464 
465         run_test("print_object",
466                 `let obj = { foo: "bar", num: 123 };
467 1;`,
468                 ['STEP', 'PRINT {"expr":"obj"}', 'CONTINUE'],
469                 {
470                         no_crash: true,
471                         check: (r) => {
472                                 for (let m in r.messages)
473                                         if (m.verb == 'VALUE' && index(m.payload.repr, 'foo') >= 0 && index(m.payload.repr, 'bar') >= 0)
474                                                 return null;
475                                 return "expected VALUE containing foo/bar";
476                         }
477                 }
478         );
479 
480         run_test("print_array",
481                 `let arr = [1, 2, 3, 4, 5];
482 1;`,
483                 ['STEP', 'PRINT {"expr":"arr"}', 'CONTINUE'],
484                 {
485                         no_crash: true,
486                         check: (r) => {
487                                 for (let m in r.messages)
488                                         if (m.verb == 'VALUE' && index(m.payload.repr, '1') >= 0 && index(m.payload.repr, '3') >= 0)
489                                                 return null;
490                                 return "expected VALUE containing array elements";
491                         }
492                 }
493         );
494 
495         run_test("variables_command",
496                 `let x = 1;
497 let y = 2;
498 let z = 3;`,
499                 ['VARIABLES'],
500                 { messages_contain: ['VARIABLES'], no_crash: true }
501         );
502 
503         run_test("print_nested",
504                 `let obj = { nested: { deep: "value" } };
505 1;`,
506                 ['STEP', 'PRINT {"expr":"obj.nested.deep"}', 'CONTINUE'],
507                 {
508                         no_crash: true,
509                         check: (r) => {
510                                 for (let m in r.messages)
511                                         if (m.verb == 'VALUE' && index(m.payload.repr, 'value') >= 0) return null;
512                                 return "expected VALUE containing 'value'";
513                         }
514                 }
515         );
516 }
517 
518 function test_stack_tracing() {
519         printf("\n## Stack Tracing Tests\n\n");
520 
521         run_test("backtrace_simple",
522                 `function level3() { return 3; }
523 function level2() { return level3(); }
524 function level1() { return level2(); }
525 level1();`,
526                 ['BREAK {"spec":"level3"}', 'CONTINUE', 'BACKTRACE {}', 'CONTINUE'],
527                 {
528                         no_crash: true,
529                         check: (r) => {
530                                 // Tail call optimization reuses the current call frame for the
531                                 // callee, so the level1/level2 tail-call frames are collapsed
532                                 // into the level3 frame rather than appearing as separate stack
533                                 // entries. The backtrace therefore reports the current frame
534                                 // (level3) plus a tco count of the frames folded into it.
535                                 for (let m in r.messages) {
536                                         if (m.verb != 'BACKTRACE') continue;
537                                         for (let f in m.payload.frames) {
538                                                 if (f.function == 'level3' && f.tco >= 1)
539                                                         return null;
540                                         }
541                                 }
542                                 return "expected level3 frame with a tco count (tail calls collapsed)";
543                         }
544                 }
545         );
546 
547         run_test("backtrace_full",
548                 `function callee() { return 1; }
549 function caller() { return callee(); }
550 caller();`,
551                 ['BREAK {"spec":"callee"}', 'CONTINUE', 'BACKTRACE {"full":true}', 'CONTINUE'],
552                 {
553                         no_crash: true,
554                         check: (r) => {
555                                 // `caller() { return callee(); }` is a tail call, so the caller
556                                 // frame is folded into the callee frame by TCO. The full
557                                 // backtrace reports the current frame (callee) with a tco count
558                                 // and, because full:true, its local variables.
559                                 for (let m in r.messages) {
560                                         if (m.verb != 'BACKTRACE') continue;
561                                         for (let f in m.payload.frames) {
562                                                 if (f.function == 'callee' && f.tco >= 1 && f.variables)
563                                                         return null;
564                                         }
565                                 }
566                                 return "expected callee frame with tco count and variables";
567                         }
568                 }
569         );
570 
571         run_test("bt_alias",
572                 `print("test");`,
573                 ['BREAK {"spec":"1"}', 'CONTINUE', 'BACKTRACE {}', 'CONTINUE'],
574                 { messages_contain: ['BACKTRACE'], no_crash: true }
575         );
576 }
577 
578 function test_source_view() {
579         printf("\n## Source Viewing Tests\n\n");
580 
581         run_test("lines_current",
582                 `// line 1
583 // line 2
584 // line 3
585 print("test");`,
586                 // A bare, comment-only ":line" spec resolves to the next real
587                 // statement (there is no bytecode to break on within a comment),
588                 // exactly like -X1's own initial breakpoint already demonstrates.
589                 ['BREAK {"spec":"1"}', 'CONTINUE', 'LINES {}', 'CONTINUE'],
590                 { messages_contain: ['SOURCE_RANGE'], no_crash: true }
591         );
592 
593         run_test("lines_with_context",
594                 `// 1
595 // 2
596 // 3
597 // 4
598 // 5
599 print("test");`,
600                 ['BREAK {"spec":"1"}', 'CONTINUE', 'LINES {"before":3,"after":1}', 'CONTINUE'],
601                 { messages_contain: ['SOURCE_RANGE'], no_crash: true }
602         );
603 
604         run_test("sources_command",
605                 `print("test");`,
606                 ['BREAK {"spec":"1"}', 'CONTINUE', 'SOURCES', 'CONTINUE'],
607                 { messages_contain: ['SOURCES'], no_crash: true }
608         );
609 
610         run_test("source_fetch",
611                 `print("test");`,
612                 ['LINES {}'],
613                 {
614                         no_crash: true,
615                         check: (r) => {
616                                 let file = null;
617                                 for (let m in r.messages)
618                                         if (m.verb == 'SOURCE_RANGE') file = m.payload.file;
619                                 if (!file) return "no SOURCE_RANGE received";
620                                 return null;
621                         }
622                 }
623         );
624 }
625 
626 function test_disassembly() {
627         printf("\n## Disassembly Tests\n\n");
628 
629         run_test("disasm_current",
630                 `let x = 1 + 2;`,
631                 ['BREAK {"spec":"1"}', 'CONTINUE', 'DISASSEMBLE', 'CONTINUE'],
632                 {
633                         no_crash: true,
634                         check: (r) => {
635                                 for (let m in r.messages)
636                                         if (m.verb == 'DISASSEMBLY' && length(m.payload.instructions) > 0) return null;
637                                 return "expected non-empty DISASSEMBLY";
638                         }
639                 }
640         );
641 
642         run_test("disasm_function",
643                 `function test() {
644         return 42;
645 }
646 test();`,
647                 ['BREAK {"spec":"test"}', 'CONTINUE', 'DISASSEMBLE {"spec":"test"}', 'CONTINUE'],
648                 {
649                         no_crash: true,
650                         check: (r) => {
651                                 for (let m in r.messages)
652                                         if (m.verb == 'DISASSEMBLY' && m.payload.function == 'test') return null;
653                                 return "expected DISASSEMBLY for function 'test'";
654                         }
655                 }
656         );
657 }
658 
659 function test_tail_call_optimization() {
660         printf("\n## Tail Call Optimization Tests\n\n");
661 
662         // A tail-recursive function: the compiler emits `I_CALL ... I_RETURN 0x00`
663         // for the tail call, where the trailing 0x00 is a marker byte (never
664         // executed) that flags the call as a tail call. The disassembler must
665         // surface both the call and its terminating return as tail-call-related.
666         run_test("disasm_tail_call",
667                 `function count(n) {
668         if (n === 0)
669                 return "done";
670         return count(n - 1);
671 }
672 count(1);`,
673                 ['DISASSEMBLE {"spec":"count"}'],
674                 {
675                         no_crash: true,
676                         check: (r) => {
677                                 for (let m in r.messages) {
678                                         if (m.verb != 'DISASSEMBLY') continue;
679                                         let has_call_tail = false, has_return_tail = false;
680 
681                                         for (let ins in m.payload.instructions) {
682                                                 if (ins.call_tail) has_call_tail = true;
683                                                 if (ins.return_tailcall) has_return_tail = true;
684                                         }
685 
686                                         if (has_call_tail && has_return_tail) return null;
687                                         return "expected call_tail and return_tailcall in disassembly " +
688                                                `(got call_tail=${has_call_tail} return_tailcall=${has_return_tail})`;
689                                 }
690                                 return "no DISASSEMBLY received";
691                         }
692                 }
693         );
694 
695         // The 0x00 marker byte is consumed as part of the I_RETURN that terminates
696         // the tail call, so it must NOT appear as a separate NOOP instruction in
697         // the disassembly. (The compiler never emits I_NOOP anywhere else, so any
698         // NOOP in this chunk would be a spurious marker that failed to consume.)
699         run_test("disasm_tail_call_marker_consumed",
700                 `function count(n) {
701         if (n === 0)
702                 return "done";
703         return count(n - 1);
704 }
705 count(1);`,
706                 ['DISASSEMBLE {"spec":"count"}'],
707                 {
708                         no_crash: true,
709                         check: (r) => {
710                                 for (let m in r.messages) {
711                                         if (m.verb != 'DISASSEMBLY') continue;
712                                         for (let ins in m.payload.instructions)
713                                                 if (ins.mnemonic == 'NOOP')
714                                                         return "found a spurious NOOP (marker not consumed)";
715                                         return null;
716                                 }
717                                 return "no DISASSEMBLY received";
718                         }
719                 }
720         );
721 
722         // A non-tail call (the result is used in a further expression) must NOT be
723         // flagged as a tail call: there is no 0x00 marker after its I_RETURN.
724         run_test("disasm_non_tail_call",
725                 `function non_tail(n) {
726         if (n === 0)
727                 return 0;
728         return non_tail(n - 1) + 1;
729 }
730 non_tail(1);`,
731                 ['DISASSEMBLE {"spec":"non_tail"}'],
732                 {
733                         no_crash: true,
734                         check: (r) => {
735                                 for (let m in r.messages) {
736                                         if (m.verb != 'DISASSEMBLY') continue;
737                                         for (let ins in m.payload.instructions) {
738                                                 if (ins.call_tail || ins.return_tailcall)
739                                                         return "non-tail call was wrongly flagged as a tail call";
740                                         }
741                                         return null;
742                                 }
743                                 return "no DISASSEMBLY received";
744                         }
745                 }
746         );
747 
748         // Stepping into a tail-recursive function reuses the current frame for each
749         // tail call, accumulating the count of collapsed frames in frame->tco. The
750         // backtrace must expose that count so the client can show how many frames
751         // were optimized away.
752         run_test("backtrace_tail_call_tco",
753                 `function deep(n) {
754         if (n === 0)
755                 return "bottom";
756         return deep(n - 1);
757 }
758 function outer() {
759         return deep(50);
760 }
761 print(outer(), "\n");`,
762                 // Step into the recursion; each tail call reuses the frame and bumps tco.
763                 ['STEP', 'STEP', 'STEP', 'STEP', 'STEP', 'STEP', 'STEP',
764                  'BACKTRACE {}', 'CONTINUE'],
765                 {
766                         no_crash: true,
767                         check: (r) => {
768                                 for (let m in r.messages) {
769                                         if (m.verb != 'BACKTRACE') continue;
770                                         for (let f in m.payload.frames) {
771                                                 if (f.function == 'deep' && f.tco > 0) return null;
772                                         }
773                                 }
774                                 return "expected a backtrace frame with tco > 0";
775                         }
776                 }
777         );
778 }
779 
780 function test_eval_sandbox() {
781         printf("\n## Eval Sandbox Tests\n\n");
782 
783         // "return" at the top of an eval sandbox: the expression's outermost frame
784         // has no parent (the call was tail-optimized into the entry frame), so a
785         // normal "return" cannot arm a step breakpoint anywhere. Before the fix
786         // this wedged the client: the inner session ended, the expression ran to
787         // completion, the outer program (still paused) never re-paused, and the
788         // client waited forever for a PAUSED that never came. The server now
789         // synthesizes a PAUSED for the outer frame when the eval sandbox
790         // completes, so the client's prompt comes back and the session continues.
791         run_test("return_in_toplevel_eval",
792                 `print("done");`,
793                 ['EVAL {"expr":"fn = x => x"}',
794                  'BREAK {"spec":"fn"}',
795                  'EVAL {"expr":"fn()"}',
796                  'RETURN'],
797                 {
798                         must_connect: true,
799                         no_crash: true,
800                         no_timeout: true,
801                         messages_contain: ['PAUSED', 'BREAKPOINT_ADDED']
802                 }
803         );
804 
805         // A plain eval that hits no breakpoint still completes and re-pauses at
806         // the outer frame (the synthesized PAUSED), leaving the session usable.
807         run_test("eval_no_breakpoint_repauses",
808                 `print("done");`,
809                 ['EVAL {"expr":"1 + 2"}', 'CONTINUE'],
810                 {
811                         must_connect: true,
812                         no_crash: true,
813                         no_timeout: true,
814                         messages_contain: ['PAUSED', 'OK']
815                 }
816         );
817 
818         // Stepping off the end of the program ("No next instruction"): the step
819         // cannot resume, so before the fix the server sent a bare ERROR and the
820         // client - having cleared its prompt on sending the resuming STEP - never
821         // got a PAUSED back and wedged with no prompt and no input. The server
822         // now follows the ERROR with a synthesized PAUSED for the still-paused
823         // frame, so the prompt comes back and the session continues to a clean
824         // exit.
825         run_test("step_off_end_repauses",
826                 `print("done");`,
827                 ['STEP', 'STEP', 'CONTINUE'],
828                 {
829                         must_connect: true,
830                         no_crash: true,
831                         no_timeout: true,
832                         stdout_contains: ['done'],
833                         messages_contain: ['PAUSED'],
834                         check: (r) => {
835                                 // The "No next instruction" error must have been reported.
836                                 for (let m in r.messages) {
837                                         if (m.verb != 'ERROR') continue;
838                                         if (match(m.payload.message ?? '', /No next instruction/))
839                                                 return null;
840                                 }
841                                 return "expected a 'No next instruction' ERROR";
842                         }
843                 }
844         );
845 
846         // Nested evals: an eval issued while already paused inside another eval's
847         // sandbox. The eval state save/restore is a plain struct copy into each
848         // eval_expr()'s own C frame (no explicit stack), so this only works
849         // because C's LIFO call-stack order restores each capture in reverse and
850         // the sandbox never frees the buffer the outer frame still points at. Pin
851         // the behavior: define/call fn, then (paused in fn) define/call fn2, then
852         // return out of both - the session must stay coherent and reach main.
853         run_test("nested_eval",
854                 `print("done");`,
855                 ['EVAL {"expr":"fn = x => x"}',
856                  'BREAK {"spec":"fn"}',
857                  'EVAL {"expr":"fn()"}',
858                  'EVAL {"expr":"fn2 = x => x"}',
859                  'BREAK {"spec":"fn2"}',
860                  'EVAL {"expr":"fn2()"}',
861                  'RETURN',
862                  'RETURN'],
863                 {
864                         must_connect: true,
865                         no_crash: true,
866                         no_timeout: true,
867                         stdout_contains: ['done'],
868                         messages_contain: ['PAUSED', 'BREAKPOINT_ADDED'],
869                         check: (r) => {
870                                 // After returning out of both evals we must be back in the
871                                 // top-level frame, not wedged inside an eval expression.
872                                 let saw_main = false;
873                                 for (let m in r.messages) {
874                                         if (m.verb != 'PAUSED') continue;
875                                         let f = m.payload.function ?? '';
876                                         if (f == 'main') saw_main = true;
877                                 }
878                                 return saw_main ? null : "never re-paused in the main frame";
879                         }
880                 }
881         );
882 }
883 
884 function test_help_and_misc() {
885         printf("\n## Help and Miscellaneous Tests\n\n");
886 
887         run_test("help_command",
888                 `print("test");`,
889                 ['BREAK {"spec":"1"}', 'CONTINUE', 'HELP', 'CONTINUE'],
890                 {
891                         no_crash: true,
892                         check: (r) => {
893                                 for (let m in r.messages) {
894                                         if (m.verb != 'HELP') continue;
895                                         let verbs = join(',', map(m.payload.commands, (c) => c.verb));
896                                         if (index(verbs, 'BREAK') >= 0 && index(verbs, 'CONTINUE') >= 0 &&
897                                             index(verbs, 'STEP') >= 0 && index(verbs, 'NEXT') >= 0)
898                                                 return null;
899                                 }
900                                 return "expected HELP listing BREAK/CONTINUE/STEP/NEXT";
901                         }
902                 }
903         );
904 
905         run_test("list_command",
906                 `print("a");
907 print("b");`,
908                 ['BREAK {"spec":"1"}', 'BREAK {"spec":"2"}', 'LIST_BREAKPOINTS', 'CONTINUE'],
909                 {
910                         no_crash: true,
911                         check: (r) => {
912                                 for (let m in r.messages)
913                                         if (m.verb == 'BREAKPOINTS' && length(m.payload.items) >= 2) return null;
914                                 return "expected at least 2 listed breakpoints";
915                         }
916                 }
917         );
918 
919         run_test("invalid_command",
920                 `print("test");`,
921                 ['BREAK {"spec":"1"}', 'CONTINUE', 'BOGUS', 'CONTINUE'],
922                 { messages_contain: ['ERROR'], no_crash: true }
923         );
924 }
925 
926 function test_debug_api() {
927         printf("\n## Debug API Tests\n\n");
928 
929         run_test("traceback_function",
930                 `function level3() { return debug.traceback(); }
931 function level2() { return level3(); }
932 function level1() { return level2(); }
933 let result = level1();
934 print("done");
935 print(result);`,
936                 [],
937                 // level1/level2 are tail calls, so TCO folds their frames into the
938                 // level3 frame; debug.traceback() therefore reports level3 (where the
939                 // traceback is taken) and main, but not the collapsed intermediate
940                 // frames.
941                 { stdout_contains: ['done', 'level3', 'main'], no_crash: true }
942         );
943 
944         run_test("sourcepos_function",
945                 `function test() {
946         let pos = debug.sourcepos();
947         print("line", pos.line);
948 }
949 test();`,
950                 [],
951                 { stdout_contains: ['line'], no_crash: true }
952         );
953 
954         run_test("getinfo_function",
955                 `function test() { return 1; }
956 let info = debug.getinfo(test);
957 print("done");`,
958                 [],
959                 { stdout_contains: ['done'], no_crash: true }
960         );
961 
962         run_test("debugger_api",
963                 `function test() {
964         print("inside test");
965 }
966 test();
967 print("after");`,
968                 ['BREAK {"spec":"test"}', 'CONTINUE', 'CONTINUE'],
969                 { stdout_contains: ['inside test', 'after'], no_crash: true }
970         );
971 }
972 
973 function test_edge_cases() {
974         printf("\n## Edge Cases and Bug Tests\n\n");
975 
976         run_test("empty_commands",
977                 `print("test");`,
978                 [],
979                 { no_crash: true }
980         );
981 
982         run_test("rapid_breakpoints",
983                 `print("a");
984 print("b");
985 print("c");
986 print("d");
987 print("e");`,
988                 ['BREAK {"spec":"1"}', 'BREAK {"spec":"2"}', 'BREAK {"spec":"3"}', 'BREAK {"spec":"4"}',
989                  'BREAK {"spec":"5"}', 'LIST_BREAKPOINTS', 'CONTINUE'],
990                 { no_crash: true }
991         );
992 
993         run_test("invalid_breakpoint",
994                 `print("test");`,
995                 ['BREAK {"spec":"999"}', 'CONTINUE'],
996                 { no_crash: true }
997         );
998 
999         run_test("delete_invalid",
1000                 `print("test");`,
1001                 ['DELETE {"id":999}', 'CONTINUE'],
1002                 { messages_contain: ['ERROR'], no_crash: true }
1003         );
1004 
1005         run_test("print_undefined",
1006                 `print("test");`,
1007                 ['BREAK {"spec":"1"}', 'CONTINUE', 'PRINT {"expr":"undefined_var"}', 'CONTINUE'],
1008                 { no_crash: true }
1009         );
1010 
1011         run_test("deep_recursion",
1012                 `function recurse(n) {
1013         if (n <= 0) return 0;
1014         return recurse(n - 1) + 1;
1015 }
1016 recurse(100);`,
1017                 ['BREAK {"spec":"recurse"}', 'CONTINUE'],
1018                 { no_crash: true, no_timeout: true }
1019         );
1020 
1021         run_test("large_object",
1022                 `let obj = {};
1023 for (let i = 0; i < 100; i++) {
1024         obj["key" + i] = i;
1025 }`,
1026                 ['BREAK {"spec":"1"}', 'CONTINUE', 'PRINT {"expr":"obj"}', 'CONTINUE'],
1027                 { no_crash: true }
1028         );
1029 
1030         run_test("closure_upvalues",
1031                 `function makeCounter() {
1032         let count = 0;
1033         return function() { count++; return count; };
1034 }
1035 let counter = makeCounter();
1036 counter();`,
1037                 ['BREAK {"spec":"counter"}', 'CONTINUE', 'PRINT {"expr":"counter()"}', 'CONTINUE'],
1038                 { no_crash: true }
1039         );
1040 
1041         run_test("exception_in_debug",
1042                 `try {
1043         die("test error");
1044 } catch (e) {
1045         print("caught");
1046 }`,
1047                 // One CONTINUE for the initial -X1 pause, one more for the
1048                 // automatic BK_CATCH pause the try/catch's die() triggers.
1049                 ['CONTINUE'],
1050                 { stdout_contains: ['caught'], no_crash: true }
1051         );
1052 }
1053 
1054 function test_memory_safety() {
1055         printf("\n## Memory Safety Tests\n\n");
1056 
1057         run_test("repeated_inspection",
1058                 `let x = 1;
1059 let y = 2;
1060 let z = 3;`,
1061                 ['BREAK {"spec":"1"}', 'CONTINUE', 'PRINT {"expr":"x"}', 'PRINT {"expr":"y"}',
1062                  'PRINT {"expr":"z"}', 'PRINT {"expr":"x"}', 'PRINT {"expr":"y"}', 'CONTINUE'],
1063                 { no_crash: true }
1064         );
1065 
1066         run_test("disasm_variants",
1067                 `let a = 1;
1068 let b = "str";
1069 let c = [1, 2, 3];
1070 let d = { x: 1 };
1071 function f() { return 1; }`,
1072                 ['BREAK {"spec":"1"}', 'CONTINUE', 'DISASSEMBLE', 'DISASSEMBLE {"spec":"f"}', 'CONTINUE'],
1073                 { no_crash: true }
1074         );
1075 
1076         run_test("mixed_frames",
1077                 `replace("test", "t", function(m) {
1078         return m.toUpperCase();
1079 });`,
1080                 [],
1081                 { no_crash: true }
1082         );
1083 }
1084 
1085 // ============================================================================
1086 // MAIN
1087 // ============================================================================
1088 
1089 printf('\n##\n## Running Debugger Tests\n##\n\n');
1090 
1091 try {
1092         mkdir_p(tmpdir);
1093 
1094         test_basic_breakpoint();
1095         test_execution_control();
1096         test_variable_inspection();
1097         test_stack_tracing();
1098         test_source_view();
1099         test_disassembly();
1100         test_tail_call_optimization();
1101         test_eval_sandbox();
1102         test_help_and_misc();
1103         test_debug_api();
1104         test_edge_cases();
1105         test_memory_safety();
1106 }
1107 catch (e) {
1108         warn(`Test runner error: ${e.type}: ${e.message}\n${e.stacktrace[0].context}\n`);
1109 }
1110 
1111 // Cleanup
1112 system(['rm', '-rf', tmpdir]);
1113 
1114 printf('\n##\n## Test Summary\n##\n\n');
1115 printf('Ran %d tests: %d passed, %d failed', n_tests, n_passed, n_failed);
1116 if (n_crashed > 0) printf(' (%d crashes)', n_crashed);
1117 if (n_timeout > 0) printf(' (%d timeouts)', n_timeout);
1118 printf('\n');
1119 
1120 exit(n_failed > 0 ? 1 : 0);

This page was automatically generated by LXR 0.3.1.  •  OpenWrt