diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md new file mode 100644 index 00000000..2c84c0e6 --- /dev/null +++ b/di/handlers/handlers.md @@ -0,0 +1,180 @@ +# di.handlers + +Central registry for a KDB-X process's `.z.*` connection-lifecycle callbacks — connection open and close, websocket open and close, process exit, and the message, auth and console handlers. It lets multiple independent components hook the same event without silently overwriting one another, and is the single place in a process that assigns `.z.*` directly. + +--- + +## Features + +- Register any number of **observer** handlers on an event whose return value KDB-X discards (connection open/close, websocket open/close, process exit). Every handler runs, in registration order, and each call is isolated — one handler throwing is logged and does not stop the rest. +- Register a single **owner** for a **decider** event whose return value KDB-X uses as the outcome (query, async, console, HTTP POST, auth, websocket message). A second registrant under a different name is rejected rather than silently replacing the first. +- Attach any number of side-effect-only **observers** to a decider event (`observe`/`unobserve`) — they run after the single owner, receive its result and the call arguments, and cannot change the outcome. +- Whatever was bound to an observer event before the first registration is preserved and still runs, last, after every registered handler. +- Remove a handler by name; removing a decider restores the KDB-X built-in default. +- Inspect what is registered on any event with `list`. + +Managed events, by whether KDB-X consumes the callback's return value: + +| Event | Mode | Meaning | +|---|---|---| +| `.z.pc` | observer | connection closed | +| `.z.po` | observer | connection opened | +| `.z.exit` | observer | process exit | +| `.z.wo` | observer | websocket opened | +| `.z.wc` | observer | websocket closed | +| `.z.pg` | decider | synchronous message (query) | +| `.z.ps` | decider | asynchronous message | +| `.z.pi` | decider | console input | +| `.z.pp` | decider | HTTP POST | +| `.z.pw` | decider | password / connection check | +| `.z.ws` | decider | websocket message | + +--- + +## Dependencies + +| Dependency | Key | Required | Description | +|---|---|---|---| +| logger | `` `log `` | yes | dict with `info`, `warn`, and `error`, each binary `{[c;m]}` where `c` is a symbol context and `m` is a string | + +**No hard dependencies** on other `di.*` modules — the module works standalone. + +The `log` dependency is passed to `init` keyed on `` `log ``. It must be a dict containing all three of `info`, `warn`, and `error`: `info` records register/remove activity, `warn` reports an isolated handler that threw during dispatch, and `error` reports a rejected call before it is signalled. `init` throws immediately if `log` is absent, is not a dict, or is missing any key. No adaptation is performed — pass a dict that already conforms to the `{[c;m]}` contract (for example one built from `di.log`). + +--- + +## Initialisation + +```q +handlers:use`di.handlers + +/ a conforming log dict (or one supplied by di.log) +logdep:`info`warn`error!( + {[c;m] -1 string[c],": INFO ",m;}; + {[c;m] -1 string[c],": WARN ",m;}; + {[c;m] -2 string[c],": ERROR ",m;}); + +handlers.init[enlist[`log]!enlist logdep] +``` + +`init` must be called before any other function — there is no default logger. It is idempotent: calling it again re-wires the log dependency and leaves existing registrations and installed dispatchers intact. + +--- + +## Exported Functions + +### `init[deps]` +Validate the required `log` dependency and set up the registries. `deps` is a dict with a `` `log `` key. Idempotent. +```q +handlers.init[enlist[`log]!enlist logdep] +``` + +### `register[event;name;func]` +Register `func` under `name` for a `.z.*` `event`. + +For an **observer** event, `func` is added to the fan-out in registration order; re-registering the same `[event;name]` replaces that entry in place. For a **decider** event, `func` becomes the single owner; re-registering under the same `name` replaces it, while a different `name` on an already-owned event throws. +```q +handlers.register[`.z.pc;`mytracker;{[w] .track.onclose w}] / observer +handlers.register[`.z.pw;`myauth;{[u;p] .auth.check[u;p]}] / decider +``` + +### `remove[event;name]` +Remove a previously registered handler. Removing an observer deletes its entry from the fan-out; removing a decider relinquishes ownership, restores the KDB-X built-in default, and drops any attached decider observers. Removing a name that is not currently registered is a harmless no-op, logged at `info` (removing a decider under a name that does *not* own it is an error, not a no-op — the event has a single owner). +```q +handlers.remove[`.z.pc;`mytracker] +``` + +### `observe[event;name;func]` +Attach a side-effect-only observer to a **decider** `event` that already has an owner (`observe` before `register` throws). After the owner returns, `func` is called as `func[result;args]` — `result` is the owner's outcome and `args` is the call's arguments as a list. Observers never change the outcome, and each is isolated: one throwing is logged at `warn` and affects neither the result nor the other observers. Re-observing the same `[event;name]` replaces it in place. For `.z.pw`, `args` is `(user;"***")` — the password is redacted, so only the owner ever sees it. +```q +handlers.observe[`.z.pg;`querylog;{[result;args] .log.query[first args;result]}] +``` + +### `unobserve[event;name]` +Detach an observer from a decider event. The dispatcher stays installed even after the last observer is removed (the owner still runs). Detaching a name that was never attached is a no-op, logged at info. +```q +handlers.unobserve[`.z.pg;`querylog] +``` + +### `list[event]` +Return the registrations for a single `event`: observers as a `name`/`func` table in registration order; a decider as a `role`/`name` table — the owner row first, then any attached observers (empty if unowned). +```q +exec name from handlers.list[`.z.pc] +``` + +### `version` +The module version string. +```q +handlers.version / "0.1.0" +``` + +--- + +## Usage Example + +```q +handlers:use`di.handlers + +/ a no-op log dep for illustration (di.log would supply a real one) +logdep:`info`warn`error!(3#{[c;m]}); +handlers.init[enlist[`log]!enlist logdep] + +/ observer event - two independent close handlers, both run in order +handlers.register[`.z.pc;`audit; {[w] }] +handlers.register[`.z.pc;`metrics;{[w] }] +exec name from handlers.list[`.z.pc] / `audit`metrics + +/ decider event - a single owner for the connection auth check +handlers.register[`.z.pw;`auth;{[u;p] 1b}] +exec name from handlers.list[`.z.pw] / owner: ,`auth + +/ attach a side-effect-only observer - runs after the owner, sees (result;args), never changes the outcome +handlers.observe[`.z.pw;`audit;{[result;args] }] +exec role from handlers.list[`.z.pw] / `owner`observer + +/ a different owner on an already-owned decider is rejected +handlers.register[`.z.pw;`other;{[u;p] 1b}] / signals: already owned by auth + +/ remove them again - decider remove restores the built-in default and drops observers +handlers.unobserve[`.z.pw;`audit] +handlers.remove[`.z.pc;`audit] +handlers.remove[`.z.pc;`metrics] +handlers.remove[`.z.pw;`auth] +``` + +--- + +## Running Tests + +**Unit suite** (`test.csv`) — needs no sockets; it drives dispatch by invoking the function bound to each `.z.*` event with synthetic arguments. `moduletest` loads and runs it: + +```q +k4unit:use`di.k4unit +k4unit.moduletest`di.handlers +``` + +**Integration suite** (`test_integration.csv`) — stands up a real child q process on an OS-assigned ephemeral port, opens and closes a connection, and confirms a registered `.z.pc` observer actually fired. It needs a q/kdb-x binary reachable via `QHOME`, needs no other configuration, and skips cleanly when no usable binary is available. `moduletest` only ever loads `test.csv`, so load and run this suite directly: + +```q +k4unit:use`di.k4unit +.m.di.0k4unit.KUltf .Q.dd[hsym`$.Q.m.mp`di.handlers;`test_integration.csv] +.m.di.0k4unit.KUrt[] +k4unit.getresults[] / one row per assertion; ok=1 is a pass +``` + +Run the integration suite in a fresh q session — running it after `moduletest` in the same session re-runs the still-loaded unit tests against dirty module state and reports spurious failures. + +--- + +## Notes + +- `init` must be called before any other function, and is idempotent. +- `register` on `.z.ph` throws — HTTP GET permissioning uses `.h.val`, not `.z.*`, and is not managed by this module. +- A decider event has exactly one owner that produces the outcome; any number of side-effect-only observers can be attached with `observe` to watch the result without owning it. +- Observers on a decider run synchronously after the owner and before its result is returned, so they add to that event's response time — unlike observer-event handlers, where nothing awaits the return. +- A decider observer runs in the decider's own execution context; under multithreaded input (a negative `\p` port) that is not the main thread, so an observer that writes a global hits kdb's `'noupdate` restriction, as any `.z.pg` code would. di.handlers' own dispatch only reads its registries and is unaffected. +- `.z.ts` is not managed here — it belongs to `di.timer`. +- The handler bound to an observer event before its first registration is captured once and always runs last, after every registered handler. +- Removing a decider restores the KDB-X built-in default, not any handler that happened to be bound before the module took ownership. +- Removing an observer with `remove` drops it from the fan-out, but the event's dispatcher — installed once on first registration — stays installed permanently; removing the last observer does not revert the event, which keeps running an empty fan-out plus the captured original. Unlike a decider (whose `remove` restores the KDB-X built-in default via `\x`), an observer event has no path back to its pre-di.handlers binding. +- All errors raised after `init` are logged at `error` before being signalled. diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q new file mode 100644 index 00000000..3b5f8f7c --- /dev/null +++ b/di/handlers/handlers.q @@ -0,0 +1,215 @@ +/ central registry for KDB-X .z.* connection-lifecycle callbacks +/ the single sanctioned place to hook .z.* events - replaces per-file hand-rolled closure wrapping +/ observer events fan out to any number of registrants; decider events allow exactly one owner + +/ module version - placeholder only; no project-wide version / di.depcheck convention exists yet +version:"0.1.0"; + +/ ============================================================ +/ constants (load-time) +/ ============================================================ + +/ observer events - KDB-X discards the return value, so any number of side-effect-only handlers can coexist +observerevents:`.z.pc`.z.po`.z.exit`.z.wo`.z.wc; + +/ decider events - KDB-X uses the return value as the outcome, so only one owner can coherently exist +/ .z.ph is deliberately absent - HTTP GET permissioning uses .h.val, not .z.*, and is out of scope (see register) +deciderevents:`.z.pg`.z.ps`.z.pi`.z.pp`.z.pw`.z.ws; + +/ per-event observer registry template - keyed by handler name in registration order +registryschema:([name:`symbol$()] func:()); + +/ ============================================================ +/ internal helpers +/ ============================================================ + +runobserver:{[event;nm;func;arg] + / apply one observer under protection - a throw is logged at warn and swallowed so the chain continues + @[func;arg;{[event;nm;e] .z.m.logwarn[`handlers;"observer ",string[nm]," on ",string[event]," failed: ",e]}[event;nm]]; + }; + +runoriginal:{[event;arg] + / apply the captured pre-existing handler last, isolated the same way as registered observers + @[.z.m.original event;arg;{[event;e] .z.m.logwarn[`handlers;"original ",string[event]," handler failed: ",e]}[event]]; + }; + +dispatchobs:{[event;arg] + / fan out to every registered observer in registration order, then run the captured original last, each isolated + t:0!.z.m.registry event; + runobserver[event;;;arg]'[t`name;t`func]; + runoriginal[event;arg]; + }; + +installobserver:{[event] + / on first registration for an event, capture whatever is currently bound (KDB-X default if unset) and install the dispatcher + .z.m.original[event]:@[value;event;{(::)}]; + .z.m.registry[event]:registryschema; + set[event;dispatchobs[event;]]; + }; + +registerobserver:{[event;nm;func] + / lazily install on first use, then add or replace this handler in registration order + if[not event in key .z.m.original;installobserver event]; + .z.m.registry[event]:.z.m.registry[event] upsert (nm;func); + .z.m.loginfo[`register;"registered observer ",string[nm]," on ",string[event]]; + }; + +registerdecider:{[event;nm;func] + / single owner - a different name claiming an owned event is rejected; the same name reclaims (idempotent re-init) + if[event in key .z.m.owner; + if[not nm~.z.m.owner event; + .z.m.logerr[`register;err:"di.handlers: ",string[event]," already owned by ",string[.z.m.owner event]," - call remove first"];'err]]; + / if a dispatcher is installed (event has observers), update the owner function in place - the dispatcher reads it fresh + $[event in key .z.m.ownerfunc;.z.m.ownerfunc[event]:func;set[event;func]]; + .z.m.owner[event]:nm; + .z.m.loginfo[`register;"registered decider ",string[nm]," on ",string[event]]; + }; + +removeobserver:{[event;nm] + / drop the named handler; the dispatcher and captured original keep firing regardless of remaining count + if[not event in key .z.m.registry;.z.m.loginfo[`remove;"no observers registered on ",string[event],"; nothing to remove for ",string[nm]];:(::)]; + if[not nm in exec name from .z.m.registry event;.z.m.loginfo[`remove;"observer ",string[nm]," not registered on ",string[event],"; nothing to remove"];:(::)]; + .z.m.registry[event]:.z.m.registry[event] _ nm; + .z.m.loginfo[`remove;"removed observer ",string[nm]," on ",string[event]]; + }; + +removedecider:{[event;nm] + / relinquish ownership only if this name owns it, restore the KDB-X built-in default via \x, and clear all decider state for the event + if[not event in key .z.m.owner;.z.m.loginfo[`remove;"no decider owner on ",string[event],"; nothing to remove for ",string[nm]];:(::)]; + if[not nm~.z.m.owner event; + .z.m.logerr[`remove;err:"di.handlers: ",string[event]," is owned by ",string[.z.m.owner event],", not ",string[nm]];'err]; + system"x ",string[event]; + .z.m.owner:.z.m.owner _ event; + / clear the owner function and any attached observers so a later register on this event starts clean (direct set, no dispatcher) + .z.m.ownerfunc:.z.m.ownerfunc _ event; + .z.m.deciderobs:.z.m.deciderobs _ event; + .z.m.loginfo[`remove;"removed decider ",string[nm]," on ",string[event],"; restored default"]; + }; + +/ ------------------------------------------------------------ +/ decider-observer machinery (side-effect-only watchers on a decider event's owner) +/ ------------------------------------------------------------ + +rundeciderobs:{[event;nm;func;result;args] + / apply one decider observer under protection - side-effect only, never alters the result; a throw is logged at warn and swallowed + .[func;(result;args);{[event;nm;e] .z.m.logwarn[`handlers;"decider observer ",string[nm]," on ",string[event]," failed: ",e]}[event;nm]]; + }; + +rundeciderobsall:{[event;result;args] + / run every attached observer for event in registration order, each isolated, with the uniform func[result;args] signature + t:0!.z.m.deciderobs event; + rundeciderobs[event;;;result;args]'[t`name;t`func]; + }; + +dispatchdeciderunary:{[event;x] + / unary decider dispatcher - call the single owner unprotected, then run side-effect observers, return the owner result + r:.z.m.ownerfunc[event] x; + rundeciderobsall[event;r;enlist x]; + r + }; + +dispatchdeciderpw:{[u;p] + / .z.pw is the only binary decider - the owner sees the real password; observers see it redacted to "***" + r:.z.m.ownerfunc[`.z.pw][u;p]; + rundeciderobsall[`.z.pw;r;(u;"***")]; + r + }; + +installdecider:{[event] + / on first observe for a decider, capture the owner function THEN install the dispatcher - order matters, installing before capturing loses the owner + .z.m.ownerfunc[event]:value event; + .z.m.deciderobs[event]:registryschema; + set[event;$[event~`.z.pw;dispatchdeciderpw;dispatchdeciderunary[event;]]]; + }; + +deciderlist:{[event] + / owner row first, then any attached observers in registration order + obs:$[event in key .z.m.deciderobs;exec name from .z.m.deciderobs event;`symbol$()]; + ([]role:`owner,(count obs)#`observer;name:(.z.m.owner event),obs) + }; + +/ ============================================================ +/ public api +/ ============================================================ + +register:{[event;nm;func] + / register a handler for a .z.* event - fan-out for observers, single-owner for deciders + if[not -11h=type event;.z.m.logerr[`register;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type nm;.z.m.logerr[`register;err:"di.handlers: name must be a symbol"];'err]; + if[not type[func] within 100 112h;.z.m.logerr[`register;err:"di.handlers: func must be a function"];'err]; + if[event~`.z.ph; + .z.m.logerr[`register;err:"di.handlers: .z.ph is not managed here - HTTP GET permissioning uses .h.val, not .z.*"];'err]; + $[event in observerevents; registerobserver[event;nm;func]; + event in deciderevents; registerdecider[event;nm;func]; + [.z.m.logerr[`register;err:"di.handlers: unsupported event ",string[event]];'err]]; + }; + +remove:{[event;nm] + / remove a handler - observers drop from the fan-out, deciders relinquish ownership and restore the KDB-X default + if[not -11h=type event;.z.m.logerr[`remove;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type nm;.z.m.logerr[`remove;err:"di.handlers: name must be a symbol"];'err]; + $[event in observerevents; removeobserver[event;nm]; + event in deciderevents; removedecider[event;nm]; + [.z.m.logerr[`remove;err:"di.handlers: unsupported event ",string[event]];'err]]; + }; + +list:{[event] + / return the registered handlers for a single event - observers as a name/func table, deciders as their owner + if[not -11h=type event;.z.m.logerr[`list;err:"di.handlers: event must be a symbol"];'err]; + :$[event in observerevents; $[event in key .z.m.registry;0!.z.m.registry event;0!registryschema]; + event in deciderevents; $[event in key .z.m.owner;deciderlist event;([]role:`symbol$();name:`symbol$())]; + [.z.m.logerr[`list;err:"di.handlers: unsupported event ",string[event]];'err]]; + }; + +observe:{[event;nm;func] + / attach a side-effect-only observer to a decider event's owner - it runs after a successful owner call and never alters the result + if[not -11h=type event;.z.m.logerr[`observe;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type nm;.z.m.logerr[`observe;err:"di.handlers: name must be a symbol"];'err]; + if[not type[func] within 100 112h;.z.m.logerr[`observe;err:"di.handlers: func must be a function"];'err]; + if[event~`.z.ph; + .z.m.logerr[`observe;err:"di.handlers: .z.ph is not managed here - HTTP GET permissioning uses .h.val, not .z.*"];'err]; + if[not event in deciderevents; + .z.m.logerr[`observe;err:"di.handlers: only decider events can be observed; ",string[event]," is not a decider event"];'err]; + if[not event in key .z.m.owner; + .z.m.logerr[`observe;err:"di.handlers: cannot observe ",string[event]," before it has an owner - call register first"];'err]; + / capture the owner and install the dispatcher on first observe only, then add or replace this observer in registration order + if[not event in key .z.m.ownerfunc;installdecider event]; + .z.m.deciderobs[event]:.z.m.deciderobs[event] upsert (nm;func); + .z.m.loginfo[`observe;"attached observer ",string[nm]," to ",string[event]]; + }; + +unobserve:{[event;nm] + / detach an observer from a decider event - the dispatcher stays installed even after the last observer is removed + if[not -11h=type event;.z.m.logerr[`unobserve;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type nm;.z.m.logerr[`unobserve;err:"di.handlers: name must be a symbol"];'err]; + if[not event in key .z.m.deciderobs;.z.m.loginfo[`unobserve;"no observers attached to ",string[event],"; nothing to detach for ",string[nm]];:(::)]; + if[not nm in exec name from .z.m.deciderobs event;.z.m.loginfo[`unobserve;"observer ",string[nm]," not attached to ",string[event],"; nothing to detach"];:(::)]; + .z.m.deciderobs[event]:.z.m.deciderobs[event] _ nm; + .z.m.loginfo[`unobserve;"detached observer ",string[nm]," from ",string[event]]; + }; + +init:{[deps] + / initialise di.handlers - validate the required log dependency and set up empty registries (idempotent) + / deps: a dict with a `log key; log must be a dict of binary {[c;m]} functions with `info`warn`error keys + / example: handlers.init[enlist[`log]!enlist logdep] + if[99h<>type deps; + '"di.handlers: deps must be a dict with `log key"]; + if[not `log in key deps; + '"di.handlers: log dependency is required; pass `info`warn`error functions keyed on `log"]; + if[99h<>type deps`log; + '"di.handlers: log value must be a dict; pass `info`warn`error functions"]; + if[not all `info`warn`error in key deps`log; + '"di.handlers: log dict must have `info`warn`error keys; got: ",(", " sv string key deps`log)]; + .z.m.loginfo:(deps`log)`info; + .z.m.logwarn:(deps`log)`warn; + .z.m.logerr:(deps`log)`error; + / initialise the registries only on first init - a direct (module-rewritten) reference detects prior setup + if[not @[{.z.m.registry;1b};::;0b]; + .z.m.registry:(`$())!(); + .z.m.original:(`$())!(); + .z.m.owner:(`$())!(); + .z.m.ownerfunc:(`$())!(); + .z.m.deciderobs:(`$())!(); + ]; + .z.m.loginfo[`init;"di.handlers initialised"]; + }; diff --git a/di/handlers/init.q b/di/handlers/init.q new file mode 100644 index 00000000..fa0b19d8 --- /dev/null +++ b/di/handlers/init.q @@ -0,0 +1,5 @@ +/ di.handlers - central registry for KDB-X .z.* connection-lifecycle callbacks + +\l ::handlers.q + +export:([init;register;remove;list;version;observe;unobserve]) diff --git a/di/handlers/test.csv b/di/handlers/test.csv new file mode 100644 index 00000000..1ea85fa8 --- /dev/null +++ b/di/handlers/test.csv @@ -0,0 +1,172 @@ +action,ms,bytes,lang,code,repeat,minver,comment +comment,,,,,,,setup - load module and init with a capturing logger +before,0,0,q,handlers:use`di.handlers,1,1,load di.handlers module +before,0,0,q,.hh.captbl:([]lvl:`symbol$();ctx:`symbol$();msg:()),1,1,log capture table for assertions +before,0,0,q,caplog:`info`warn`error!({[c;m] `.hh.captbl insert (`info;c;m)};{[c;m] `.hh.captbl insert (`warn;c;m)};{[c;m] `.hh.captbl insert (`error;c;m)}),1,1,capturing binary logger {[c;m]} +before,0,0,q,handlers.init[enlist[`log]!enlist caplog],1,1,init with the capturing logger +before,0,0,q,.hh.ev:([]who:`symbol$()),1,1,dispatch-order capture table + +comment,,,,,,,init - dependency validation +fail,0,0,q,handlers.init[(::)],1,1,init rejects a non-dict deps +fail,0,0,q,handlers.init[()!()],1,1,init rejects missing log key +fail,0,0,q,handlers.init[enlist[`log]!enlist 42],1,1,init rejects a non-dict log value +fail,0,0,q,handlers.init[enlist[`log]!enlist `info`warn!(caplog`info;caplog`warn)],1,1,init rejects a log dict missing the error key +run,0,0,q,.hh.errstr:@[{handlers.init[()!()]};(::);{x}],1,1,capture the error string from a bad init +true,0,0,q,.hh.errstr like "di.handlers:*",1,1,init error is prefixed di.handlers: + +comment,,,,,,,module metadata - exported version +true,0,0,q,10h=type handlers.version,1,1,version is a string +true,0,0,q,0/dev/null 2>&1 & echo $!"),1,1,launch a blank child q on the OS-assigned port and capture its PID +before,0,0,q,h:.it.wait[cport;20],1,1,connect to the child with a bounded retry +before,0,0,q,if[null h;@[{system "" sv ("kill -9 ";string cpid)};::;{x}];exit 0],1,1,if the child never came up kill it by PID and skip cleanly +before,0,0,q,@[{h ".z.pc:{exit 0}"};::;{x}],1,1,backstop - tell the child to self-exit whenever its connection to this process drops +before,0,0,q,.it.roundtrip:@[{4=h "2+2"};::;{0b}],1,1,capture a real synchronous round-trip while still connected +before,0,0,q,.it.closed:@[h;"exit 0";{x}],1,1,synchronous exit request - the child dies mid-reply so the parent detects the close and .z.pc fires +before,0,0,q,@[{system "" sv ("kill -9 ";string cpid)};::;{x}],1,1,forceful kill by PID as a belt in case graceful shutdown did not take +before,0,0,q,system"sleep 0.3",1,1,give the OS a moment to reap the child +before,0,0,q,.it.gone:0=count @[{system "" sv ("ps -p ";string cpid;" -o pid=")};::;{()}],1,1,verify the child process is actually gone (ps errors when absent so the probe is protected) + +comment,,,,,,,a real IPC round-trip works while di.handlers is loaded +true,0,0,q,.it.roundtrip,1,1,synchronous query to the real child process returned the expected result + +comment,,,,,,,closing the real connection drives the registered .z.pc observer +true,0,0,q,`closed in exec who from .hh.iev,1,1,the .z.pc observer fired on the real disconnect + +comment,,,,,,,the child process is fully cleaned up +true,0,0,q,.it.gone,1,1,the child process is gone after the test