AtlatestRepositoryslate
1
;;; The `--log` / `--log-level` CLI contract, and the stream-child gate.2
;;;3
;;; THE LOAD-BEARING TEST IN THIS FILE is "stream child argv carries the4
;;; logging flags AND still strips back to the _stream arity". It exists5
;;; because the failure it guards is SILENT and lands on the exact path this6
;;; whole feature was built for:7
;;;8
;;; `main.sgl` builds the child's argv EXPLICITLY at the spawn — the child9
;;; inherits nothing from the parent's command line. If the flags are not10
;;; forwarded, the child configures itself silent (no flags means logging11
;;; off, by design) and everything it would say disappears. Nothing errors.12
;;; The parent's log looks healthy. The one path we are instrumenting for13
;;; goes dark.14
;;;15
;;; SCOPE, STATED HONESTLY: today that is TWO log lines (the stream child's16
;;; start and exit, in main.sgl). (slate-cli enclave-stream) — the module17
;;; whose process death IS the remote session's death — has NOT been18
;;; instrumented yet; it currently contains no log calls and does not19
;;; import (sigil log). An earlier version of this comment said forwarding20
;;; protects "every log line in enclave-stream", which was a claim about21
;;; work not yet done. The gate is here first ON PURPOSE: the forwarding is22
;;; the precondition for that instrumentation being worth anything, and a23
;;; gate written after the code it guards is a gate written too late.24
;;;25
;;; And the mirror failure: `run-node!` dispatches `_stream` only when26
;;; `(= (length (cdr args)) 3)`. Forwarding the flags without stripping27
;;; them first breaks that check, and every remote terminal with it.28
;;;29
;;; Those two halves are load-bearing on each other, so the test asserts30
;;; them together: forward the flags, then run the child's own parse over31
;;; the result and assert the arity the dispatcher will count.32
;;;33
;;; SABOTAGE (how to show these gates can go red — do this before trusting34
;;; a green, per procedures/worker-instructions):35
;;; 1. In `stream-child-argv`, drop the `--log-level` clause.36
;;; -> "stream child argv carries the logging flags..." fails on the37
;;; `--log-level` assertion.38
;;; 2. In `stream-child-argv`, append the flags BEFORE the three39
;;; positional arguments instead of after, and simultaneously make40
;;; `extract-log-opts` return `args` unchanged.41
;;; -> the same test fails on the arity assertion (5, not 3).42
;;; 3. In `extract-log-opts`, accept the `=` spelling instead of refusing.43
;;; -> "the = spelling is refused..." fails.44
;;; 4. In `validate-value`, drop the level check.45
;;; -> "an unknown level is refused..." fails.46
;;; Each sabotage must be reverted after the red is recorded.48
(import (sigil test)49
(sigil core)50
(sigil string)51
(sigil fs)52
(slate-cli logging))54
;; Locate main.sgl for the structural arm at the end of this file. `sigil55
;; test` may be invoked from the workspace root or from the package, so try56
;; both; returns #f if neither exists and the arm then FAILS rather than57
;; skipping. A gate that quietly skips is not a gate.58
(define (main-source-path)59
(let loop ((candidates60
(list "packages/slate-cli/src/slate-cli/main.sgl"61
"src/slate-cli/main.sgl"62
"../src/slate-cli/main.sgl")))63
(cond64
((null? candidates) #f)65
((file-exists? (car candidates)) (car candidates))66
(else (loop (cdr candidates))))))68
;; Count non-overlapping occurrences of `needle` in `haystack`. Defined69
;; before the test group that uses it, not after: `test-group` runs at load.70
(define (count-occurrences haystack needle)71
(let ((step (string-length needle)))72
(let loop ((from 0) (n 0))73
(let ((hit (string-find haystack needle from)))74
(if hit (loop (+ hit step) (+ n 1)) n)))))76
(test-group "slate-cli logging CLI contract"78
;; ---------------------------------------------------------------79
;; Parsing80
;; ---------------------------------------------------------------82
(test "flags are extracted and removed from the argument list"83
(let ((r (extract-log-opts84
(list "node" "run" "--log" "/var/log/slate.log"85
"--log-level" "debug"))))86
(assert-true (log-opts-ok? r))87
(assert-equal "/var/log/slate.log" (log-opts-file r))88
(assert-equal "debug" (log-opts-level r))89
(assert-equal (list "node" "run") (log-opts-rest r))))91
(test "flags are accepted before the subcommand as well as after"92
(let ((r (extract-log-opts93
(list "--log" "/tmp/s.log" "node" "run"))))94
(assert-true (log-opts-ok? r))95
(assert-equal "/tmp/s.log" (log-opts-file r))96
(assert-equal (list "node" "run") (log-opts-rest r))))98
;; The additive-only guarantee: an invocation with no logging flags must99
;; come back through the parser completely untouched, because every100
;; existing subcommand parses whatever this returns.101
(test "an invocation with no logging flags is passed through unchanged"102
(let* ((args (list "serve" "--app-dir" "/build/web" "--port" "32650"))103
(r (extract-log-opts args)))104
(assert-true (log-opts-ok? r))105
(assert-false (log-opts-file r))106
(assert-false (log-opts-level r))107
(assert-equal args (log-opts-rest r))))109
(test "order within the list does not matter and both flags are optional"110
(let ((only-level (extract-log-opts (list "serve" "--log-level" "trace"))))111
(assert-true (log-opts-ok? only-level))112
(assert-false (log-opts-file only-level))113
(assert-equal "trace" (log-opts-level only-level))114
(assert-equal (list "serve") (log-opts-rest only-level))))116
;; ---------------------------------------------------------------117
;; Refusals118
;; ---------------------------------------------------------------120
;; The `=` spelling must be REFUSED WITH THE CORRECTION, not accepted.121
;; (sigil log)'s own `log-configure-from-args!` matches its flags with122
;; `equal?` and never splits on `=`, so anything that accepts the spelling123
;; upstream hands the user a flag that is silently ignored downstream --124
;; a diagnostic that lies about being on. Refusing keeps slate identical125
;; to folio, whose test asserts the same pair of properties.126
(test "the = spelling is refused, and the message gives the correct spelling"127
(let ((r (extract-log-opts (list "node" "run" "--log-level=trace"))))128
(assert-false (log-opts-ok? r))129
(assert-true (string-contains? (log-opts-message r) "--log-level trace")))130
(let ((r (extract-log-opts (list "node" "run" "--log=/tmp/x.log"))))131
(assert-false (log-opts-ok? r))132
(assert-true (string-contains? (log-opts-message r) "--log /tmp/x.log"))))134
;; Scoped refusal: the `=` rule applies to OUR two flags only. Everything135
;; else keeps whatever behaviour it has today, so this stays additive.136
(test "an = form on a flag we do not own is left alone for the existing parsers"137
(let ((r (extract-log-opts (list "serve" "--app-upstream=https://x"))))138
(assert-true (log-opts-ok? r))139
(assert-equal (list "serve" "--app-upstream=https://x")140
(log-opts-rest r))))142
(test "a flag with no value is refused rather than silently ignored"143
(let ((r (extract-log-opts (list "node" "run" "--log"))))144
(assert-false (log-opts-ok? r))145
(assert-true (string-contains? (log-opts-message r) "requires a value"))))147
;; folio measured this one: `folio --log --web` was blessed as "--log with148
;; the value --web", after which a second parser started an HTTP server149
;; nobody asked for and the logger wrote to a file named `--web`.150
(test "a value that looks like an option is a forgotten argument, not a value"151
(let ((r (extract-log-opts (list "serve" "--log" "--port"))))152
(assert-false (log-opts-ok? r))153
(assert-true (string-contains? (log-opts-message r) "--port"))))155
;; `log-configure!` maps an unknown level through `level-name->number` and156
;; only assigns `(when n ...)`, so without this check `--log-level tarce`157
;; would quietly give the operator `info`.158
(test "an unknown level is refused, and the message names the valid ones"159
(let ((r (extract-log-opts (list "node" "run" "--log-level" "tarce"))))160
(assert-false (log-opts-ok? r))161
(assert-true (string-contains? (log-opts-message r) "trace"))162
(assert-true (string-contains? (log-opts-message r) "fatal"))))164
(test "every level (sigil log) understands is accepted"165
(for-each166
(lambda (level)167
(let ((r (extract-log-opts (list "serve" "--log-level" level))))168
(assert-true (log-opts-ok? r))169
(assert-equal level (log-opts-level r))))170
log-levels))172
;; ---------------------------------------------------------------173
;; Child log paths174
;; ---------------------------------------------------------------176
;; Per-pid child files are not a naming preference. sigil has no O_APPEND,177
;; so appending is emulated as read-truncate-rewrite, which two processes178
;; cannot do to one file without the kernel zero-filling the gap -- and NUL179
;; bytes make `grep` report zero matches for lines `tail` shows plainly.180
(test "a stream child derives its own per-pid log file from the parent's"181
(assert-equal "/var/log/slate-node-stream-4242.log"182
(stream-child-log-path "/var/log/slate-node.log" 4242))183
;; Two children never collide, which is the entire point.184
(assert-false (string=? (stream-child-log-path "/var/log/n.log" 1)185
(stream-child-log-path "/var/log/n.log" 2))))187
(test "the stream-child invocation is recognised before dispatch"188
(assert-true (stream-child-invocation? (list "node" "_stream" "u" "c" "t")))189
(assert-false (stream-child-invocation? (list "node" "run")))190
(assert-false (stream-child-invocation? (list "serve")))191
(assert-false (stream-child-invocation? '())))193
;; ---------------------------------------------------------------194
;; THE GATE195
;; ---------------------------------------------------------------197
(test "stream child argv carries the logging flags AND still strips back to the _stream arity"198
(let ((argv (stream-child-argv "/usr/bin/slate" "wss://relay/stream"199
"alice" "tkt-opaque"200
"/var/log/slate-node.log" "debug")))201
;; Half one: the child is actually TOLD to log. Without this the whole202
;; enclave-stream instrumentation is silent and nothing complains.203
(assert-true (member "--log" argv))204
(assert-true (member "/var/log/slate-node.log" argv))205
(assert-true (member "--log-level" argv))206
(assert-true (member "debug" argv))208
;; Half two: what the child's own `main` will see is `(cdr argv)`, and209
;; after its own parse `run-node!` must still count exactly three210
;; positional arguments after `_stream`, or the dispatch arm does not211
;; fire and every remote terminal breaks.212
(let* ((child-args (cdr argv))213
(parsed (extract-log-opts child-args))214
(rest (log-opts-rest parsed)))215
(assert-true (log-opts-ok? parsed))216
(assert-equal "/var/log/slate-node.log" (log-opts-file parsed))217
(assert-equal "debug" (log-opts-level parsed))218
(assert-equal "node" (car rest))219
(assert-equal "_stream" (cadr rest))220
;; `run-node!` checks `(= (length (cdr args)) 3)` where args is221
;; `(cdr rest)`. This is that number.222
(assert-equal 3 (length (cddr rest)))223
;; And the three are the real ones, in order.224
(assert-equal (list "wss://relay/stream" "alice" "tkt-opaque")225
(cddr rest)))))227
;; The unlogged case must be byte-identical to what the spawn site built228
;; before this change, or "additive only" is not true.229
(test "with no parent logging the child argv is exactly the original six"230
(assert-equal231
(list "/usr/bin/slate" "node" "_stream" "wss://relay/stream" "alice" "tkt")232
(stream-child-argv "/usr/bin/slate" "wss://relay/stream" "alice" "tkt"233
#f #f)))235
;; A parent logging to STDERR ONLY must leave its children silent, and this236
;; is a deadlock guard rather than a preference. `process-spawn` gives the237
;; child a stderr PIPE, and nothing in slate-cli ever reads it; a child238
;; logging into it would block forever once ~64 KiB accumulated, taking the239
;; terminal session with it. Give the node `--log` and the children get240
;; their own files instead.241
(test "a parent logging to stderr only leaves the child silent"242
(let ((argv (stream-child-argv "/usr/bin/slate" "u" "c" "t" #f "trace")))243
(assert-false (member "--log" argv))244
(assert-false (member "--log-level" argv))245
(assert-equal246
(list "/usr/bin/slate" "node" "_stream" "u" "c" "t")247
argv)))249
;; ---------------------------------------------------------------250
;; THE STRUCTURAL ARM — the seam cannot be bypassed251
;; ---------------------------------------------------------------252
;;253
;; Every test above proves `stream-child-argv` behaves. None of them254
;; proves `main.sgl` still CALLS it. Someone inlining the spawn again --255
;; which is exactly how it was written before this change -- would leave256
;; the whole suite green while the child silently lost its flags.257
;;258
;; WHAT THIS ARM IS AND IS NOT. It is a SMOKE CHECK over source text: it259
;; catches the regression that actually happened once (a hand-rolled260
;; `_stream` spawn) and the careless reintroduction of it. It is NOT proof261
;; that the seam cannot be bypassed -- a reviewer rightly pointed out that262
;; a line break, different spacing, or a second spawn site added alongside263
;; the constructor would all slip past. Claiming otherwise would make this264
;; the kind of gate that is trusted more than it has earned.265
;;266
;; The counting assertion below is the part with real teeth: exactly ONE267
;; `process-spawn` call in the file, so a second spawn site fails here268
;; whatever it looks like.269
(test "the stream child spawn goes through the tested argv constructor"270
(let ((path (main-source-path)))271
(if (not path)272
;; Fail, do not skip, and do not fall through into a type error on273
;; `(read-file-string #f)`: an arm that cannot find its subject has274
;; checked nothing, and reporting that as green is how a gate comes275
;; to be quiet rather than correct. `assert-true` records the276
;; failure without aborting, so the `if` is what keeps us out of277
;; the read.278
(assert-true path)279
(let ((src (read-file-string path)))280
;; The spawn is reached through the tested constructor, exactly281
;; once. Note the form: it is `(apply process-spawn ...)` because282
;; the argv is variable-length. Counting `"(process-spawn"` here283
;; would be a gate that can never pass -- that exact mistake was284
;; made writing this test and caught by running the count against285
;; the real file before trusting it.286
(assert-equal 1 (count-occurrences src "(apply process-spawn"))287
(assert-true (string-contains? src "(stream-child-argv"))288
;; And there is NO direct-call spawn anywhere in the file, which289
;; is the form a hand-rolled second site would take.290
(assert-equal 0 (count-occurrences src "(process-spawn "))291
;; The pre-change shape, kept as a named tripwire.292
;;293
;; MEASURED to discriminate, both directions, against the real294
;; pre-change source (`git show HEAD:...main.sgl`):295
;; pre-change: (apply process-spawn = 0, (process-spawn = 1,296
;; "process-spawn exe \"node\"" present297
;; current: (apply process-spawn = 1, (process-spawn = 0,298
;; tripwire absent299
;; So reverting the spawn site to how it was written before this300
;; change fails all three assertions rather than none.301
(assert-false (string-contains? src "process-spawn exe \"node\"")))))))