diff --git a/.changeset/archive-session-lifecycle-research.md b/.changeset/archive-session-lifecycle-research.md new file mode 100644 index 000000000..41bf32d8d --- /dev/null +++ b/.changeset/archive-session-lifecycle-research.md @@ -0,0 +1,4 @@ +--- +--- + +Archive the discarded session lifecycle Effect experiment and its reusable findings. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..7992d966e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +research/**/artifacts/** linguist-generated=true +research/**/artifacts/**/*.patch binary diff --git a/.oxfmtrc.json b/.oxfmtrc.json index c8717300f..0f277b448 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,3 +1,3 @@ { - "ignorePatterns": [] + "ignorePatterns": ["research/**/artifacts/**/*.json"] } diff --git a/research/README.md b/research/README.md new file mode 100644 index 000000000..1f9976945 --- /dev/null +++ b/research/README.md @@ -0,0 +1,23 @@ +# Research + +This directory preserves bounded engineering experiments that inform Hunk without becoming product +code by default. + +Research entries are historical evidence, not supported APIs, release artifacts, benchmarks that +apply to every machine, or approval to ship their treatments. Each entry must state its status and +base revision, distinguish observations from conclusions, document material limitations, and provide +checksums for retained artifacts. + +A completed entry should contain: + +- `README.md` — status, hypothesis, outcome, and navigation; +- `plan.md` — the experiment's frozen scope and decision gates; +- `report.md` — results, limitations, and adoption decision; +- `artifacts/` — selected reconstruction and measurement evidence with a manifest. + +Treatment implementations should remain isolated from `src/` and `packages/` in the archival change. +Prefer a reconstructable patch over merging discarded dependencies or runtime code. Exclude secrets, +credentials, bulky generated bundles, and redundant logs. + +An experiment may be reconsidered only through a new plan with new evidence. Editing an old report +must not silently turn a discarded treatment into an accepted design. diff --git a/research/session-broker-effect-lifecycle/README.md b/research/session-broker-effect-lifecycle/README.md new file mode 100644 index 000000000..a9780212d --- /dev/null +++ b/research/session-broker-effect-lifecycle/README.md @@ -0,0 +1,54 @@ +# Session broker Effect lifecycle experiment + +> **Status: discarded experiment — not production code and not approved for merge.** + +This entry preserves a disposable comparison of the existing explicit TypeScript session-broker +lifecycle with a treatment built on exactly `effect@3.22.1`. It tested whether one process-owned +Effect runtime and Clock could simplify producer startup, launcher polling, connection handshake, +heartbeat, reconnect, and ordered shutdown without converting Hunk's security or domain core. + +The experiment started from Hunk commit +`42d2b9dd2f3144e33080f159f4cc9c2824ad1708`. The treatment was intentionally stopped before daemon +and native-adapter migration. + +## Outcome + +The treatment removed the targeted native timing owners from the connection and client, but did not +make the lifecycle easier to audit. The decisive observations were: + +- standalone Bun broker bundle: 90,116 to 331,670 bytes (`+268.0%`); +- compiled Hunk: `+622,592` bytes (`+0.35%`); +- lifecycle-ready median: 12.093 to 37.875 milliseconds (`+213.2%`); +- every retained isolated producer microbenchmark regressed; +- the treatment added 515 lines of lifecycle implementation and retained substantial manual + Promise, wake, state, and fiber ownership; +- independent review found unresolved repeated starts, incorrect retained retry timing, incomplete + lifecycle closure, failed restart after natural completion, and a pre-existing failed-connection + retention path exposed by the experiment; +- the clean-install Bun linker workaround weakened reproducibility. + +The decision was **discard Phase 1 and do not begin Phase 2**. The useful ideas are being pursued as +small plain-TypeScript changes: adversarial lifecycle tests, failed-connection rollback, explicit +startup states, a minimal clock seam, late-settlement fences, process-exit fixtures, and bounded +defect reporting. + +## Contents + +- [`plan.md`](./plan.md) records the hypothesis, boundaries, gates, and frozen control design. +- [`report.md`](./report.md) records measurements, validation, defects, limitations, and the final + decision. +- [`artifacts/`](./artifacts/) contains selected control and treatment evidence plus reconstructable + patches. + +## Evidence boundary + +The retained hashes establish consistency of these local artifacts. They do not establish signer +identity, a trusted timestamp, universal benchmark results, or production approval. Bulky compiled +bundles and redundant raw logs were omitted; their sizes and command results remain recorded in the +structured result files and original evidence manifests. + +Verify the retained archive from the repository root: + +```sh +sha256sum -c research/session-broker-effect-lifecycle/artifacts/MANIFEST.sha256 +``` diff --git a/research/session-broker-effect-lifecycle/artifacts/MANIFEST.sha256 b/research/session-broker-effect-lifecycle/artifacts/MANIFEST.sha256 new file mode 100644 index 000000000..a41fdfba7 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/MANIFEST.sha256 @@ -0,0 +1,22 @@ +1295cbf7ec22d91b728ac5408a20fddc8a52ad7d680729e67e807352e530fc58 research/session-broker-effect-lifecycle/artifacts/control/canary.json +e7b9a0c6e639841fe65532ebf1cd3c9ce5a14e688b9c3fe28cf1c53288674757 research/session-broker-effect-lifecycle/artifacts/control/complete-experiment.patch +d284decdee198773a3bff2e9ae6bca6164ef6b23286f8a1fd74db26636361fe7 research/session-broker-effect-lifecycle/artifacts/control/original-MANIFEST.attestation.sha256 +7b0ecea5b2d133424c0fbf680a58914af73de09c5ac602a148f66520b2ff52b2 research/session-broker-effect-lifecycle/artifacts/control/original-MANIFEST.sha256 +78d6558d0470a3117c9e690c3807d1f2dc4ee374de11b7f481af7377b2391bb9 research/session-broker-effect-lifecycle/artifacts/control/ownership-inventory.json +28ee15b4b02724041782170d73c84205ab5c406ca873f3b093184b5a284fe7cd research/session-broker-effect-lifecycle/artifacts/control/result.json +8463b7aa889245684ee1f08d67bb034aea0d77df40a8c3390410cd437a424475 research/session-broker-effect-lifecycle/artifacts/control/semantics.json +d78b3e4580194ea5aaca1d894117efe0688990d2ee28dbb4e6fa799ed13072a5 research/session-broker-effect-lifecycle/artifacts/control/source-identity.json +780a54aae35df7d39ed1bb75e824e92aaa3540184b161029e23a89686acd374a research/session-broker-effect-lifecycle/artifacts/control/validation-manifest.json +ed47adbb7c193cd487d0542782337561d7a85a51b16fc73791d3b01bf73c6141 research/session-broker-effect-lifecycle/artifacts/README.md +c2e44b51e0b6ebc3fe9d15b4380d73e964972b13de1b0d03914e51568c350e55 research/session-broker-effect-lifecycle/artifacts/treatment/canary.json +2227069a83141c9d8a22b91e778f6c7c9a233ae8aff114b3d0db8d141b7d79cb research/session-broker-effect-lifecycle/artifacts/treatment/complete-experiment.patch +b6b16e108dcaf23ac66c2feb38e069694f3f49695479830d4d165e2a6ea8b451 research/session-broker-effect-lifecycle/artifacts/treatment/original-MANIFEST.attestation.sha256 +0c31e4128daef210dcbcd4e0d49d4187ea44cbd98e8fe1c5f137faec8080e22c research/session-broker-effect-lifecycle/artifacts/treatment/original-MANIFEST.sha256 +d8a03ae57031ed4d217449aa220f275d57d346b8d6a8f4b42f364b261b48ccbb research/session-broker-effect-lifecycle/artifacts/treatment/ownership-inventory.json +c409137e7750f2b7251cf020aaef22abcfc4ff4d6af043a3f49397362536c5cc research/session-broker-effect-lifecycle/artifacts/treatment/result.json +3ee72e4e8fcaa8f070a2ec7844483ed52fdf79aa2d7aca604ebcfabb105d8653 research/session-broker-effect-lifecycle/artifacts/treatment/semantics.json +66f12c5702c0b10f114a47bc3fd2ee5bfc8140e74a49af39364d1d0ecc1cd452 research/session-broker-effect-lifecycle/artifacts/treatment/source-identity.json +f15cd14853e4816dceeca82bf6b0512a0f6d9ee4bd4353c9f55b7f5119bf6e5f research/session-broker-effect-lifecycle/artifacts/treatment/validation-manifest.json +9f9ffdd2068e2a02f6f4380d8928105e1cd592b2b43f955855b149ed42633cf5 research/session-broker-effect-lifecycle/plan.md +772225da2c5ccf4972765b783e10e48113f3deb2ad0f9c8db4de3a9f33bf7582 research/session-broker-effect-lifecycle/README.md +85bf74e6f45c530ef14dd6bbce6f649c0d64ba1b45e9d7daf2c1d486aed76551 research/session-broker-effect-lifecycle/report.md diff --git a/research/session-broker-effect-lifecycle/artifacts/README.md b/research/session-broker-effect-lifecycle/artifacts/README.md new file mode 100644 index 000000000..ba7921c83 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/README.md @@ -0,0 +1,22 @@ +# Retained artifacts + +This directory keeps the smallest practical evidence set for reconstructing and reviewing both arms. +It deliberately omits generated bundles, declarations, and repetitive raw test logs. + +Each arm contains: + +- `complete-experiment.patch` — binary-safe patch against the recorded base, including untracked + experiment files at collection time; +- `result.json` — runtime metadata, raw benchmark samples, derived measurements, and command results; +- `semantics.json` — characterized and intentionally changed semantics; +- `ownership-inventory.json` — lifecycle ownership analysis; +- `source-identity.json` — source and support-file identities at collection time; +- `validation-manifest.json` — validation commands, statuses, and transcript hashes; +- `canary.json` — bounded sensitive-defect canary result; +- `original-MANIFEST.sha256` and `original-MANIFEST.attestation.sha256` — manifests from the original + local evidence directory. Some referenced bulky files are intentionally not copied here, so use + the archive-level `MANIFEST.sha256` for direct verification of retained files. + +The treatment patch is the authoritative reconstruction artifact for the discarded implementation. +The current worktree or later report edits may not equal the source identity captured by that patch. +No manifest in this directory proves authorship or trusted time. diff --git a/research/session-broker-effect-lifecycle/artifacts/control/canary.json b/research/session-broker-effect-lifecycle/artifacts/control/canary.json new file mode 100644 index 000000000..1040143f6 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/canary.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 3, + "label": "control", + "markerSha256": "040853adaaf981a16d808604f1a668f177932312c1625ca2af44ffce2af86d3a", + "exercised": "resolveClose callback defect during reconnect close policy", + "fixedTreatmentMessage": "Session broker lifecycle failed unexpectedly.", + "defectCallbackMessages": [], + "outputMessages": [], + "synchronousEscapeCharacterized": true, + "controlLimitation": "The raw Error remains synchronously observable to the close caller in memory; the harness catches it and never persists the marker. This does not exercise credentials, payloads, finalizers, or rendered Effect Causes.", + "treatmentAssertionsApplied": false +} diff --git a/research/session-broker-effect-lifecycle/artifacts/control/complete-experiment.patch b/research/session-broker-effect-lifecycle/artifacts/control/complete-experiment.patch new file mode 100644 index 000000000..08abf6a42 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/complete-experiment.patch @@ -0,0 +1,3722 @@ +diff --git a/docs/session-broker-effect-lifecycle-experiment-results.md b/docs/session-broker-effect-lifecycle-experiment-results.md +new file mode 100644 +index 00000000..614a4803 +--- /dev/null ++++ b/docs/session-broker-effect-lifecycle-experiment-results.md +@@ -0,0 +1,79 @@ ++# Session broker Effect lifecycle experiment results ++ ++Status: Phase 0 control recollected after independent review; Phase 1 treatment not started. ++ ++## Control ++ ++The control is fresh `origin/main` at ++`42d2b9dd2f3144e33080f159f4cc9c2824ad1708`. The experiment plan, characterization tests, ++fixtures, and harness form a separate experiment-support patch. Every candidate production file, ++package manifest, and lockfile remains byte-identical to the control commit. ++ ++Control evidence is stored locally under `tmp/session-lifecycle-effect/control/`. Each harness ++invocation removes that complete directory before running validation. The current invocation itself ++ran focused tests, the real Node suite, typecheck, lint, dependency boundaries, the full unit suite, ++and PTY integration; it accepts no inherited transcript or hard-coded count. ++ ++The recollected control establishes: ++ ++- the Bun-native and Node-native child fixtures each create a real `SessionBrokerAuthenticator`, ++ real `SessionBrokerDaemon`, and native transport, complete the signed producer hello, and wait ++ until the actual broker observes `register` before stopping; ++- focused lifecycle/broker/source-boundary validation: 379 passed and 0 failed; ++- real Node adapter/producer validation: 4 passed and 0 failed; ++- the full unit command passed; its multi-suite runner emits multiple summaries, so the harness ++ records exact exit/transcript hashes but deliberately does not present the first summary as an ++ aggregate count; ++- PTY integration: 139 passed, 1 skipped, and 0 failed; ++- typecheck, lint, dependency boundaries, declaration emission, Bun/Node process-exit modes, and ++ the bounded canary scan passed; ++- minified standalone broker builds are deterministic at 90,116 bytes for Bun and 90,118 bytes for ++ Node across three retained builds; ++- compiled Hunk is deterministic at 177,268,864 bytes across three measurements. Its identity is ++ recorded, but the binaries remain in `dist/` and are not retained as arm artifacts; ++- cold Bun process-to-lifecycle-ready is measured by fresh subprocesses. Control dynamically proves ++ the factory is absent; treatment must create, close, and settle the factory; ++- direct synchronous connection-stop latency is measured separately from fixture construction; ++- listener acquisition and daemon ordered stop are labeled unchanged transport context, not ++ producer lifecycle startup or stop; ++- CPU and RSS are coarse, non-isolated observations and support no strong performance or security ++ conclusion; ++- source identity includes all dirty files, dynamically classifies production changes, and stores a ++ reconstructable binary patch including untracked experiment support. ++ ++Authenticated-registration latency remains `not-measured`: the signed runtime fixtures prove the ++complete hello/register behavior but do not expose one stable isolated timestamp in both arms. Hunk ++startup polling also remains `not-measured` because it has no honest portable real-Node equivalent. ++The canary exercises only a reconnect close-policy defect. Control exposes its raw Error to the ++synchronous caller in memory; the harness catches it without persisting the random marker. It does ++not claim credential, payload, finalizer, diagnostic, snapshot, or rendered-Cause coverage. ++ ++## Treatment ++ ++Not started. No Effect dependency or production lifecycle change is present in the Phase 0 tree. ++The frozen treatment harness requires an externally supplied control attestation-file hash before ++it trusts control identities. It freezes every existing control support input except this results ++report and raw evidence, and enforces the exact allowed production change set. It then fails closed ++on named AST-derived mechanical patterns: lifecycle factory/façade shape, exact class property sets, ++Promise/native timing owners, Effect declaration/import placement, registries/classes, ++runtime/Clock/fixed-heartbeat construction sites, and factory call location. Bun executes only a ++fixture bundle produced through a `ws`-rejecting build-graph plugin whose direct `ws` probe must fail. ++The defect canary permits no synchronous treatment escape, enumerates callable console methods ++across the console prototype chain, self-tests error/warn/log/info/debug/trace/dir/table capture, and ++intercepts direct stdout/stderr writes while requiring exactly one total fixed hook message. Production ++imports into test-named or experiment-support modules are forbidden, and Effect scans include every ++tracked or untracked repository TypeScript source with only the dedicated lifecycle test allowed. These ++checks are reproducible source-shape evidence, not proof of lifecycle semantics. ++ ++All control support remains byte-frozen except the results report/raw evidence and exactly ++`connection.test.ts` plus `brokerClient.test.ts`. Those two lifecycle-injection tests instead retain ++AST-derived ordered test names and assertion-call locations/counts; treatment must preserve every ++control name, may not reduce a matching body's assertion count, and may not introduce skip/todo/only. ++New tests remain allowed. This is mechanical anti-weakening evidence requiring independent review, ++not behavioral proof. ++Independent correctness/security review remains required. ++ ++## Verdict ++ ++Pending Phase 1. This document must end with either **discard Phase 1** or **recommend a separately ++approved Phase 2 experiment** after treatment evidence and independent review exist. +diff --git a/docs/session-broker-effect-lifecycle-experiment.md b/docs/session-broker-effect-lifecycle-experiment.md +new file mode 100644 +index 00000000..d6881af4 +--- /dev/null ++++ b/docs/session-broker-effect-lifecycle-experiment.md +@@ -0,0 +1,252 @@ ++# Session broker Effect lifecycle experiment ++ ++Status: Phase 0 control frozen; Phase 1 treatment not started. ++ ++## Question ++ ++The first experiment replaced only the generic connection's three native timers. It paid Effect's ++fixed runtime and audit cost without removing lifecycle ownership. This follow-up asks a narrower, ++falsifiable question: ++ ++> Can one process-owned Effect lifecycle supervise Hunk's complete producer startup and connection ++> path while deleting independent retry/timer ownership across both the generic connection and the ++> Hunk client? ++ ++This is a disposable experiment, not production migration approval. ++ ++## Identities ++ ++- Control source: `42d2b9dd2f3144e33080f159f4cc9c2824ad1708` ++- Control source description: `fix(ui): restore review stream responsiveness (#942)` ++- Required treatment dependency: exactly `effect@3.22.1`, only in ++ `packages/session-broker/package.json` ++- Required Bun: `1.3.14` ++- First experiment patch SHA-256: ++ `f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56` ++- First experiment remains untouched in `/home/bentlegen/Projects/hunk-effect-experiment`. ++ ++The control harness records the system Node version, Bun's embedded Node version, OS, kernel, ++architecture, lockfile, fixtures, all changed-file identities, and fresh command transcripts. It ++creates a complete reconstructable binary patch through a temporary Git index so untracked support ++files are included. The first-experiment patch path is optional; when unavailable, evidence records ++only its frozen expected hash. Raw evidence lives under ignored `tmp/session-lifecycle-effect/`. ++ ++## Scope ++ ++Phase 1 may change only the producer lifecycle path: ++ ++```text ++ensure daemon ++-> signed hello/connect ++-> register ++-> heartbeat/reconnect ++-> terminal stop ++``` ++ ++Candidate production modules are: ++ ++- `packages/session-broker/src/connection.ts` ++- a new private Effect-backed lifecycle module in `packages/session-broker/src/` ++- `src/session/broker/brokerClient.ts` ++- polling mechanics only in `src/session/broker/brokerLauncher.ts` ++- the interactive-process composition root in `src/ui/runInteractiveApp.tsx` ++ ++Phase 1 must not change the daemon engine, Node adapter, Bun adapter, authentication, ++cryptography, exact validation, broker state, resource budgets, ownership policy, delivery ++semantics, browser-review authority, or public Effect-free API style. Phase 2 is forbidden without a ++new decision after the Phase 1 gate. ++ ++## Frozen control semantics ++ ++The treatment must preserve these observations unless this document names an explicit difference: ++ ++1. `SessionBrokerConnection.start()` constructs its first socket synchronously, so a construction ++ throw escapes synchronously. ++2. Heartbeat uses delayed fixed-rate native `setInterval`: activation arms one interval, no heartbeat ++ is sent before its first callback, and the runtime owns cadence/catch-up behavior. ++3. Producer handshake timeout starts with socket creation and is cleared by activation, close, or ++ terminal stop. ++4. Reconnect preparation is not cancellable. Stop may win while preparation is pending; the foreign ++ Promise still settles, but no new socket may be created afterward. ++5. Connection stop is synchronous and terminal. It closes the current socket and refuses future ++ starts, but it does not await crypto, reconnect preparation, or app bridge work. ++6. Concurrent first `SessionBrokerClient.start()` calls return the same active attempt Promise. ++7. A first startup failure is warned once, resolves the public first-attempt Promise, and schedules a ++ complete retry. An explicit `start()` while that retry timer is waiting starts an immediate new ++ attempt; the existing retry timer remains owned until stop or callback. ++8. Client stop is synchronous and terminal. It does not cancel already-started health, credential, ++ crypto, filesystem, or detached-spawn work, but late settlement cannot connect, warn, or schedule ++ another retry. ++9. Launcher lock owners receive a fresh full health-poll timeout after the outer deadline was ++ established. This can exceed the nominal startup timeout. Lock release remains in `finally`. ++10. Fetch abort timers and TCP socket timeouts are native transport controls, not lifecycle-loop ++ ownership targeted by Phase 1. ++11. One generic connection object retains registration, snapshot, bridge queue, command budgets, ++ and reconnect ownership across socket generations. ++ ++## Explicit treatment decisions ++ ++These are decisions, not parity claims: ++ ++- **Heartbeat:** use a delayed first tick followed by Effect `Schedule.fixed`. Any observable ++ catch-up difference from native `setInterval` is reported as a treatment difference. ++- **Defects:** `createSessionBrokerProcessLifecycle` accepts the ordinary TypeScript option ++ `{ onDefect?: (message: string) => void }`. Unexpected lifecycle defects call it only with the ++ fixed text `Session broker lifecycle failed unexpectedly.` This is an intentional replacement for ++ today's mixture of uncaught timer exceptions and unhandled Promise rejections. Raw Effect Causes, ++ URLs, credentials, payloads, callback values, and defect objects must never reach this hook or the ++ console. ++- **Shutdown:** public `start()`/`stop()` gates remain synchronous and terminal. A separate internal ++ settlement Promise describes eventual scope closure; synchronous stop never claims that async ++ finalization has completed. ++- **Cancellation:** Effect interruption does not prove cancellation of foreign Promises or detached ++ processes. Phase 1 does not add `AbortSignal` to health, credential, crypto, filesystem, TCP, or ++ spawn operations. Synchronous terminal/epoch gates prevent late commits while native work settles. ++- **Runtime ownership:** `runInteractiveApp` constructs exactly one process lifecycle, runtime, and ++ unref-aware Clock and injects it through an ordinary TypeScript façade. There is no package-global ++ singleton, fallback runtime, runtime per connection, or Effect import outside the private package ++ lifecycle implementation. ++ ++## Frozen treatment source shape ++ ++The disposable treatment uses these exact mechanical names so Phase 1 evidence is falsifiable: ++ ++- implementation classes: `EffectSessionBrokerProcessLifecycle`, ++ `EffectSessionBrokerStartupSupervisor`, and `EffectSessionBrokerConnectionSupervisor`; ++- `SessionBrokerConnection` owns exactly one ++ `connectionSupervisor: SessionBrokerConnectionSupervisor` property. Its complete treatment ++ property set is frozen as `socket`, `activeSocket`, `bridge`, `limits`, `queuedMessages`, ++ `executingMessages`, `queuedCommandCountBudget`, `queuedCommandByteBudget`, `draining`, `stopped`, ++ `registration`, `snapshot`, `producerHellos`, `options`, and `connectionSupervisor`; ++- `SessionBrokerClient` owns exactly one ++ `startupSupervisor: SessionBrokerStartupSupervisor` property and retains its terminal `stopped` ++ commit gate. Its complete treatment property set is frozen as `connection`, `bridge`, `stopped`, ++ `lastConnectionWarning`, `credentials`, `waitingForIncumbentExit`, `incumbentLaunchFingerprint`, ++ `registration`, `snapshot`, `timing`, and `startupSupervisor`; ++- `createUnrefClock()` is the only lexical region allowed to call raw `setTimeout` or ++ `clearTimeout`; the process lifecycle calls it at exactly one construction site; ++- `runDelayedFixedHeartbeat()` performs the initial `Effect.sleep` and contains the only ++ `Schedule.fixed` call; ++- `ManagedRuntime.make` occurs exactly once in `processLifecycle.ts`; ++- `createSessionBrokerProcessLifecycle()` is called in production exactly once, from ++ `src/ui/runInteractiveApp.tsx`. ++ ++Conditional AST gates reject native timing calls in converted connection/client paths, `Bun.sleep` ++in the launcher, any property outside those exact domain sets, Promise/timer/task/fiber/handle ++owners, task registries, per-delay classes, additional runtime/Clock construction, or factory calls ++elsewhere. Existing connection queues and `producerHellos` remain allowed only by exact name. These ++named mechanical gates do ++not prove scheduling, interruption, shutdown, authentication, or resource semantics; independent ++review remains mandatory. ++ ++## Mechanical ownership inventory ++ ++The control owns these independent lifecycle mechanisms: ++ ++| Owner | Mechanism | Phase 1 disposition | ++| ------------------ | ------------------------------------------- | ------------------------------------- | ++| generic connection | reconnect timeout handle | delete into one generation supervisor | ++| generic connection | heartbeat interval handle | delete into one generation supervisor | ++| generic connection | handshake timeout WeakMap | delete into one generation supervisor | ++| Hunk client | outer reconnect timeout handle | delete into one startup supervisor | ++| Hunk client | active startup Promise field | delete into one startup supervisor | ++| Hunk client | terminal `stopped` gate | retain as public commit fence | ++| launcher | health deadline/poll loop and `Bun.sleep` | route through supervised attempt time | ++| launcher | launch-lock ownership and `finally` release | retain as cross-process authority | ++| launcher | fetch abort and TCP socket timeout | retain as native transport controls | ++ ++Renaming native timer fields to task/fiber fields, adding equivalent task Sets/maps, or retaining ++parallel native scheduling is not ownership reduction. ++ ++## Phase 1 gate ++ ++Phase 1 is promising only if all of the following hold: ++ ++1. The connection's reconnect, heartbeat, and handshake timer ownership becomes one whole-generation ++ supervisor with no per-delay task handles or task registry. ++2. The client's outer retry timer and startup Promise owner become one startup supervisor. ++3. The reduction spans both modules and deletes at least three independent native ownership ++ mechanisms. ++4. Exactly one runtime and one custom Clock are constructed at the interactive process root. ++5. Security/domain state stays explicit and byte-for-byte protocol behavior remains unchanged. ++6. Every frozen control characterization remains green, or a difference listed above is measured ++ and reported honestly. ++7. Bun and real Node authenticate/register where supported and exit with handshake, heartbeat, and ++ reconnect maintenance pending. Hunk startup timing is a Bun product path; absence of a stable ++ real-Node Hunk startup fixture is recorded rather than simulated. ++8. No Effect type appears in public declarations; core and `src/**` do not import Effect; Bun never ++ imports or executes `ws`. ++9. The bounded reconnect-close-policy canary reaches the fixed defect hook in treatment, its random ++ marker appears in no generated text evidence, and the hook/console contain only the fixed message. ++ This canary does not claim coverage of credentials, application payloads, finalizers, diagnostics, ++ snapshots, or rendered Effect Causes. ++10. Independent reviewers can find stop gates, retries, authority rechecks, and releases without ++ relying on Effect expertise. ++11. Bundle, startup, registration, command, reconnect, stop, CPU, and RSS evidence is recorded. A ++ greater-than-25% standalone broker increase requires a material, independently reviewed ++ ownership win; the coarse 1 MiB ceiling alone is insufficient. ++ ++Failure of any hard boundary, ownership, security, process-exit, or auditability item ends the ++experiment as a null result. Phase 2 never starts automatically. ++ ++## Evidence protocol ++ ++One unchanged harness, `scripts/session-broker-effect-lifecycle-experiment.ts`, collects both arms. ++It stores full command transcripts and hashes rather than truncated output. Each arm contains: ++ ++- `result.json` ++- `source-identity.json` ++- `ownership-inventory.json` ++- `semantics.json` ++- `validation/manifest.json` ++- three retained Bun broker bundles and three retained Node broker bundles ++- identities and timings for three compiled Hunk builds whose binaries remain in `dist/` and are not ++ retained as arm artifacts ++- raw warmups/samples for stable local fixtures ++- process-exit results ++- dependency/declaration/canary evidence ++- `MANIFEST.sha256` and `MANIFEST.attestation.sha256` ++ ++Every invocation clears the complete arm output first, then itself runs focused tests, the real Node ++suite, typecheck, lint, dependency boundaries, the full unit suite, and PTY integration. Non-zero ++exit fails the arm; counts are recorded only when a stable Bun/TAP summary can be parsed. A requested ++metric without a stable fixture is recorded as `not-measured` with a reason. It is never replaced ++with test-command duration or synthetic evidence. Listener acquisition and daemon ordered stop are ++unchanged transport context, not producer lifecycle startup/stop. Cold Bun subprocess lifecycle ++readiness and synchronous connection stop are the lifecycle measurements. CPU and RSS are coarse, ++non-isolated observations from which no strong performance or security conclusion may be drawn. ++ ++Treatment invocation additionally requires ++`--control-attestation-sha256 `. The harness first verifies that external hash against ++`control/MANIFEST.attestation.sha256`, verifies the attested `MANIFEST.sha256`, and then verifies ++every manifest entry before trusting control identities. It freezes every existing control ++experiment-support input hash except the results report, raw evidence, and exactly ++`packages/session-broker/src/connection.test.ts` plus `src/session/broker/brokerClient.test.ts`. ++Those two tests may change only to inject the process lifecycle: AST evidence requires every ordered ++control test name to remain, each matching body to retain at least its control assertion-call count, ++and no treatment test to use skip/todo/only. New treatment tests may be added. The plan, harness, ++analyzer, source-boundary test, launcher tests, fixtures, and runners remain exact-frozen. This is ++mechanical anti-weakening evidence plus independent review, not proof. The parent must anchor the final attestation-file hash outside the arm before ++Phase 1. This is local integrity and reproducibility evidence, not a cryptographic signer or trusted ++timestamp. ++ ++The treatment production change set is exact: `bun.lock`, `packages/session-broker/package.json`, ++`packages/session-broker/src/{index,processLifecycle,connection}.ts`, ++`src/session/broker/{brokerClient,brokerLauncher}.ts`, and `src/ui/runInteractiveApp.tsx` are the only ++allowed production changes. Every item except the optional launcher change is required. Daemon, ++adapters, authentication, crypto, validation, core, budgets, and browser-review production files ++remain forbidden. The Bun fixture is bundled by `Bun.build` through a `ws`/`ws/*` rejecting ++`onResolve` plugin using a package-bundling target, a direct `ws` import probe must fail under that ++plugin, and only the produced bundle is executed by Bun. AST specifier checks remain an independent ++backstop. Production modules may not import/re-export/require/dynamically import `.test.ts`, `test/`, ++`docs/`, `scripts/`, or any experiment-support module. Effect specifier scans include every tracked ++or untracked TypeScript source in the repository; ++only `processLifecycle.ts` and its unreachable dedicated `processLifecycle.test.ts` may import it. ++ ++## Operating constraints ++ ++- Use one writer and fresh independent reviewers. ++- Do not edit or delete the first experiment. ++- Do not commit, push, open a PR, merge, publish, or begin production migration. ++- Do not begin daemon/adapters Phase 2 without separate approval. ++- A well-attested null result is a successful experiment outcome. +diff --git a/packages/session-broker-bun/src/connectionRuntime.test.ts b/packages/session-broker-bun/src/connectionRuntime.test.ts +new file mode 100644 +index 00000000..069cade3 +--- /dev/null ++++ b/packages/session-broker-bun/src/connectionRuntime.test.ts +@@ -0,0 +1,92 @@ ++import { expect, test } from "bun:test"; ++import { mkdtemp, rm, writeFile } from "node:fs/promises"; ++import { tmpdir } from "node:os"; ++import { join, resolve } from "node:path"; ++ ++const fixture = resolve( ++ import.meta.dir, ++ "../../../test/session-broker-runtime/bun-connection-fixture.ts", ++); ++ ++/** Reject any ws module reachable from a Bun fixture build graph. */ ++function rejectWsPlugin(onRejected: (specifier: string) => void): Bun.BunPlugin { ++ return { ++ name: "reject-ws-in-bun-session-fixture", ++ setup(builder) { ++ builder.onResolve({ filter: /^ws(?:\/|$)/ }, (args) => { ++ onRejected(args.path); ++ throw new Error(`Forbidden ws resolution in Bun lifecycle fixture: ${args.path}`); ++ }); ++ }, ++ }; ++} ++ ++/** Run one bundled Bun fixture with a hard test-owned timeout and retain its transcript. */ ++async function runFixture(bundle: string, args: readonly string[]) { ++ const child = Bun.spawn({ ++ cmd: [process.execPath, bundle, ...args], ++ cwd: resolve(import.meta.dir, "../../.."), ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const timeout = setTimeout(() => child.kill(), 3_000); ++ const [stdout, stderr, exitCode] = await Promise.all([ ++ new Response(child.stdout).text(), ++ new Response(child.stderr).text(), ++ child.exited, ++ ]); ++ clearTimeout(timeout); ++ return { stdout, stderr, exitCode }; ++} ++ ++test("bundles without ws, authenticates, registers, and leaves maintenance time unref'ed", async () => { ++ const directory = await mkdtemp(join(tmpdir(), "hunk-bun-lifecycle-fixture-")); ++ try { ++ const probe = join(directory, "ws-probe.ts"); ++ await writeFile(probe, 'import WebSocket from "ws"; console.log(WebSocket);\n'); ++ const rejected: string[] = []; ++ let probeFailed = false; ++ try { ++ await Bun.build({ ++ entrypoints: [probe], ++ outdir: join(directory, "probe"), ++ target: "node", ++ plugins: [rejectWsPlugin((specifier) => rejected.push(specifier))], ++ }); ++ } catch { ++ probeFailed = true; ++ } ++ expect(probeFailed).toBe(true); ++ expect(rejected).toEqual(["ws"]); ++ ++ const fixtureRejected: string[] = []; ++ const bundle = join(directory, "bun-connection-fixture.js"); ++ const fixtureBuild = await Bun.build({ ++ entrypoints: [fixture], ++ outdir: directory, ++ naming: "bun-connection-fixture.js", ++ target: "node", ++ format: "esm", ++ sourcemap: "none", ++ plugins: [rejectWsPlugin((specifier) => fixtureRejected.push(specifier))], ++ }); ++ expect(fixtureBuild.success).toBe(true); ++ expect(fixtureRejected).toEqual([]); ++ ++ const real = await runFixture(bundle, ["real"]); ++ expect(real).toMatchObject({ exitCode: 0, stderr: "" }); ++ expect(real.stdout).toBe("signed-producer-register-observed\n"); ++ ++ const ready = await runFixture(bundle, ["lifecycle-ready"]); ++ expect(ready).toMatchObject({ exitCode: 0, stderr: "" }); ++ expect(ready.stdout).toMatch(/^lifecycle-ready:(?:control|treatment)\n$/); ++ ++ for (const mode of ["unref-handshake", "unref-heartbeat", "unref-reconnect"] as const) { ++ const result = await runFixture(bundle, [mode]); ++ expect(result).toMatchObject({ exitCode: 0, stderr: "" }); ++ expect(result.stdout).toContain(mode); ++ } ++ } finally { ++ await rm(directory, { force: true, recursive: true }); ++ } ++}); +diff --git a/packages/session-broker/src/connection.test.ts b/packages/session-broker/src/connection.test.ts +index 2eed8032..2d13ddf7 100644 +--- a/packages/session-broker/src/connection.test.ts ++++ b/packages/session-broker/src/connection.test.ts +@@ -9,7 +9,10 @@ import { + SESSION_BROKER_REGISTRATION_VERSION, + SESSION_BROKER_SIGNATURE_ALGORITHM, + } from "@hunk/session-broker-core"; ++import { SessionBroker } from "./broker"; ++import { SessionBrokerAuthenticator } from "./authentication"; + import { createSessionBrokerConnection } from "./connection"; ++import { createSessionBrokerDaemon } from "./daemon"; + import { createSessionBrokerProtocolParsers } from "./protocolParsers"; + import type { SessionBrokerSocketLike } from "./types"; + +@@ -28,6 +31,7 @@ class TestSocket implements SessionBrokerSocketLike { + sent: string[] = []; + throwOnSend = false; + lastClose: { code?: number; reason?: string } | null = null; ++ onSend?: (data: string) => void; + onopen: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; +@@ -36,6 +40,7 @@ class TestSocket implements SessionBrokerSocketLike { + send(data: string) { + if (this.throwOnSend) throw new Error("socket exploded"); + this.sent.push(data); ++ this.onSend?.(data); + } + + close(code?: number, reason?: string) { +@@ -109,6 +114,220 @@ const protocolParsers = createSessionBrokerProtocolParsers< + }); + + describe("session broker connection", () => { ++ test("throws an initial socket construction failure synchronously from start", () => { ++ const failure = new Error("socket construction failed"); ++ const connection = createSessionBrokerConnection({ ++ url: "ws://broker.test/session", ++ createSocket: (): TestSocket => { ++ throw failure; ++ }, ++ registration: createRegistration(), ++ snapshot: createSnapshot(), ++ protocolParsers, ++ }); ++ ++ expect(() => connection.start()).toThrow(failure); ++ connection.stop(); ++ }); ++ ++ test("arms delayed fixed-rate heartbeats through one cleared native interval", () => { ++ const socket = new TestSocket(); ++ const originalSetInterval = globalThis.setInterval; ++ const originalClearInterval = globalThis.clearInterval; ++ const intervalHandle = { unref() {} } as ReturnType; ++ let intervalCallback: (() => void) | null = null; ++ let intervalMs: number | undefined; ++ const cleared: Array> = []; ++ globalThis.setInterval = ((callback: () => void, delay?: number) => { ++ intervalCallback = callback; ++ intervalMs = delay; ++ return intervalHandle; ++ }) as typeof setInterval; ++ globalThis.clearInterval = ((handle: ReturnType) => { ++ cleared.push(handle); ++ }) as typeof clearInterval; ++ ++ try { ++ const connection = createSessionBrokerConnection({ ++ url: "ws://broker.test/session", ++ createSocket: () => socket, ++ registration: createRegistration(), ++ snapshot: createSnapshot(), ++ protocolParsers, ++ heartbeatIntervalMs: 25, ++ }); ++ connection.start(); ++ socket.emitOpen(); ++ ++ expect(intervalMs).toBe(25); ++ expect(socket.sent.map((message) => JSON.parse(message).type)).toEqual(["register"]); ++ expect(intervalCallback).not.toBeNull(); ++ (intervalCallback as unknown as () => void)(); ++ expect(socket.sent.map((message) => JSON.parse(message).type)).toEqual([ ++ "register", ++ "heartbeat", ++ ]); ++ ++ connection.stop(); ++ expect(cleared).toEqual([intervalHandle]); ++ } finally { ++ globalThis.setInterval = originalSetInterval; ++ globalThis.clearInterval = originalClearInterval; ++ } ++ }); ++ ++ test("clears each producer handshake timeout on close and terminal stop", async () => { ++ const pair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const grant: ProducerGrant = { ++ kind: "producer", ++ appId: "dev.example", ++ principalId: "producer-1", ++ keyId: "producer-key-1", ++ grantId: "producer-grant-1", ++ algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, ++ issuedAt: Date.now() - 1_000, ++ expiresAt: Date.now() + 60_000, ++ revocationId: "producer-revocation-1", ++ mayDelegate: false, ++ operations: ["register"], ++ }; ++ const originalSetTimeout = globalThis.setTimeout; ++ const originalClearTimeout = globalThis.clearTimeout; ++ const handles: Array> = []; ++ const cleared: Array> = []; ++ globalThis.setTimeout = ((_callback: () => void, _delay?: number) => { ++ const handle = { unref() {} } as ReturnType; ++ handles.push(handle); ++ return handle; ++ }) as typeof setTimeout; ++ globalThis.clearTimeout = ((handle: ReturnType) => { ++ cleared.push(handle); ++ }) as typeof clearTimeout; ++ ++ try { ++ const createConnection = (socket: TestSocket) => ++ createSessionBrokerConnection({ ++ url: "ws://broker.test/session", ++ createSocket: () => socket, ++ registration: createRegistration(), ++ snapshot: createSnapshot(), ++ protocolParsers, ++ producerAuthentication: { ++ appId: "dev.example", ++ appRevision: 1, ++ credential: { grant, privateKey: pair.privateKey }, ++ daemon: { keyId: "daemon-key-1", publicKey: pair.publicKey }, ++ }, ++ reconnectDelayMs: 60_000, ++ resolveClose: () => ({ reconnect: false }), ++ }); ++ ++ const closedSocket = new TestSocket(); ++ const closedConnection = createConnection(closedSocket); ++ closedConnection.start(); ++ closedSocket.emitClose(); ++ closedConnection.stop(); ++ ++ const stoppedSocket = new TestSocket(); ++ const stoppedConnection = createConnection(stoppedSocket); ++ stoppedConnection.start(); ++ stoppedConnection.stop(); ++ ++ expect(handles).toHaveLength(2); ++ expect(cleared).toEqual(handles); ++ } finally { ++ globalThis.setTimeout = originalSetTimeout; ++ globalThis.clearTimeout = originalClearTimeout; ++ } ++ }); ++ ++ test("clears the producer handshake timeout after successful signed activation", async () => { ++ const producerPair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const daemonPair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const grant: ProducerGrant = { ++ kind: "producer", ++ appId: "dev.example", ++ principalId: "producer-1", ++ keyId: "producer-key-1", ++ grantId: "producer-grant-1", ++ algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, ++ issuedAt: Date.now() - 1_000, ++ expiresAt: Date.now() + 60_000, ++ revocationId: "producer-revocation-1", ++ mayDelegate: false, ++ operations: ["register"], ++ }; ++ const authenticator = new SessionBrokerAuthenticator({ ++ appId: grant.appId, ++ appRevision: 1, ++ generation: "generation-1", ++ daemonIdentity: { keyId: "daemon-key-1", privateKey: daemonPair.privateKey }, ++ credentials: [{ grant, publicKey: producerPair.publicKey }], ++ }); ++ const broker = new SessionBroker({ protocolParsers }); ++ const daemon = createSessionBrokerDaemon({ ++ broker, ++ appId: grant.appId, ++ producerEndpoint: "ws://broker.test/session", ++ helloAuthenticator: authenticator, ++ idleTimeoutMs: 0, ++ }); ++ const socket = new TestSocket(); ++ const peer = { ++ send: (data: string) => queueMicrotask(() => socket.emitMessage(data)), ++ close: (code?: number, reason?: string) => socket.close(code, reason), ++ markAuthenticated() {}, ++ }; ++ socket.onSend = (data) => daemon.handleConnectionMessage(peer, data); ++ ++ const originalSetTimeout = globalThis.setTimeout; ++ const originalClearTimeout = globalThis.clearTimeout; ++ const handle = { unref() {} } as ReturnType; ++ const cleared: Array> = []; ++ globalThis.setTimeout = (() => handle) as unknown as typeof setTimeout; ++ globalThis.clearTimeout = ((value: ReturnType) => { ++ cleared.push(value); ++ }) as typeof clearTimeout; ++ ++ const connection = createSessionBrokerConnection({ ++ url: "ws://broker.test/session", ++ createSocket: () => socket, ++ registration: createRegistration(), ++ snapshot: createSnapshot(), ++ protocolParsers, ++ producerAuthentication: { ++ appId: grant.appId, ++ appRevision: 1, ++ credential: { grant, privateKey: producerPair.privateKey }, ++ daemon: { keyId: "daemon-key-1", publicKey: daemonPair.publicKey }, ++ }, ++ }); ++ try { ++ connection.start(); ++ socket.emitOpen(); ++ for (let index = 0; index < 200 && broker.getSessionIds().length === 0; index += 1) { ++ await Bun.sleep(1); ++ } ++ expect(broker.getSessionIds()).toEqual(["session-1"]); ++ expect(cleared).toEqual([handle]); ++ } finally { ++ connection.stop(); ++ daemon.shutdown(); ++ await daemon.stopped; ++ globalThis.setTimeout = originalSetTimeout; ++ globalThis.clearTimeout = originalClearTimeout; ++ } ++ }); ++ + test("registers on open and sends later snapshot updates", () => { + const sockets: TestSocket[] = []; + const connection = createSessionBrokerConnection< +diff --git a/scripts/session-broker-effect-lifecycle-experiment.ts b/scripts/session-broker-effect-lifecycle-experiment.ts +new file mode 100644 +index 00000000..1da37549 +--- /dev/null ++++ b/scripts/session-broker-effect-lifecycle-experiment.ts +@@ -0,0 +1,1520 @@ ++#!/usr/bin/env bun ++ ++import { createHash } from "node:crypto"; ++import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; ++import { arch, cpus, platform, release } from "node:os"; ++import { basename, join, relative, resolve } from "node:path"; ++import { ++ SESSION_BROKER_REGISTRATION_VERSION, ++ SessionBroker, ++ createSessionBrokerConnection, ++ createSessionBrokerDaemon, ++ createSessionBrokerProtocolParsers, ++ type SessionBrokerSocketLike, ++} from "@hunk/session-broker"; ++import * as sessionBroker from "@hunk/session-broker"; ++import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun"; ++import { ++ allTypeScriptFiles, ++ analyzeTestContinuity, ++ analyzeTreatmentOwnership, ++ astModuleSpecifiers, ++ forbiddenProductionSupportImports, ++ repositoryTypeScriptFiles, ++} from "./session-lifecycle-ownership-analysis"; ++ ++const CONTROL_SHA = "42d2b9dd2f3144e33080f159f4cc9c2824ad1708"; ++const BEHAVIORALLY_FROZEN_TESTS = [ ++ "packages/session-broker/src/connection.test.ts", ++ "src/session/broker/brokerClient.test.ts", ++] as const; ++const FIRST_EXPERIMENT_PATCH_SHA256 = ++ "f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56"; ++const REDACTED_LIFECYCLE_DEFECT = "Session broker lifecycle failed unexpectedly."; ++const WARMUPS = 5; ++const SAMPLES = 30; ++ ++interface CommandEvidence { ++ readonly name: string; ++ readonly command: readonly string[]; ++ readonly cwd: string; ++ readonly startedAt: string; ++ readonly finishedAt: string; ++ readonly durationMs: number; ++ readonly exitCode: number; ++ readonly stdoutTranscript: string; ++ readonly stderrTranscript: string; ++ readonly stdoutSha256: string; ++ readonly stderrSha256: string; ++ readonly parsedCounts?: Record; ++} ++ ++interface SampleSeries { ++ readonly status: "measured"; ++ readonly unit: "ms"; ++ readonly warmups: readonly number[]; ++ readonly samples: readonly number[]; ++ readonly median: number; ++ readonly p95: number; ++} ++ ++interface NotMeasured { ++ readonly status: "not-measured"; ++ readonly reason: string; ++} ++ ++type Measurement = SampleSeries | NotMeasured; ++ ++type Arm = "control" | "treatment"; ++ ++interface ExperimentLifecycle { ++ close(): void; ++ readonly settled: Promise; ++} ++ ++interface LifecycleFactoryOptions { ++ readonly onDefect?: (message: string) => void; ++} ++ ++/** Hash bytes without normalizing text or line endings. */ ++function sha256(bytes: Uint8Array | string) { ++ return createHash("sha256").update(bytes).digest("hex"); ++} ++ ++/** Hash one file exactly as stored. */ ++async function hashFile(path: string) { ++ return sha256(await readFile(path)); ++} ++ ++/** Return one nearest-rank percentile from raw sorted samples. */ ++function percentile(samples: readonly number[], fraction: number) { ++ const sorted = [...samples].sort((left, right) => left - right); ++ return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)] ?? 0; ++} ++ ++/** Measure one stable local operation with frozen warmup and sample counts. */ ++async function measure(operation: () => void | Promise): Promise { ++ return measureReported(async () => { ++ const startedAt = performance.now(); ++ await operation(); ++ return performance.now() - startedAt; ++ }); ++} ++ ++/** Retain measurements reported directly by the operation rather than including fixture setup. */ ++async function measureReported(operation: () => number | Promise): Promise { ++ const warmups: number[] = []; ++ const samples: number[] = []; ++ for (let index = 0; index < WARMUPS + SAMPLES; index += 1) { ++ const duration = await operation(); ++ (index < WARMUPS ? warmups : samples).push(duration); ++ } ++ return { ++ status: "measured", ++ unit: "ms", ++ warmups, ++ samples, ++ median: percentile(samples, 0.5), ++ p95: percentile(samples, 0.95), ++ }; ++} ++ ++/** Recursively list files in stable protocol-path order. */ ++async function listFiles(root: string): Promise { ++ const entries = await readdir(root, { withFileTypes: true }); ++ const files: string[] = []; ++ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { ++ const path = join(root, entry.name); ++ if (entry.isDirectory()) files.push(...(await listFiles(path))); ++ else if (entry.isFile()) files.push(path); ++ } ++ return files; ++} ++ ++/** Parse only stable summary lines emitted by Bun test or Node's TAP runner. */ ++function parseTestCounts(stdout: string, stderr: string) { ++ const text = `${stdout}\n${stderr}`; ++ const counts: Record = {}; ++ const patterns = { ++ passed: [ ++ /(?:^|\n)\s*(\d+) pass(?:ed)?\b/m, ++ /(?:^|\n)# pass (\d+)\b/m, ++ /(?:^|\n)ℹ pass (\d+)\b/m, ++ ], ++ failed: [ ++ /(?:^|\n)\s*(\d+) fail(?:ed)?\b/m, ++ /(?:^|\n)# fail (\d+)\b/m, ++ /(?:^|\n)ℹ fail (\d+)\b/m, ++ ], ++ skipped: [ ++ /(?:^|\n)\s*(\d+) skip(?:ped)?\b/m, ++ /(?:^|\n)# skipped (\d+)\b/m, ++ /(?:^|\n)ℹ skipped (\d+)\b/m, ++ ], ++ }; ++ let ambiguous = false; ++ for (const [name, candidates] of Object.entries(patterns)) { ++ const matches = candidates.flatMap((pattern) => [ ++ ...text.matchAll(new RegExp(pattern.source, "gm")), ++ ]); ++ if (matches.length > 1) ambiguous = true; ++ if (matches.length === 1 && matches[0]?.[1]) counts[name] = Number(matches[0][1]); ++ } ++ return !ambiguous && counts.passed !== undefined ? counts : undefined; ++} ++ ++/** Run one command and persist complete stdout/stderr transcripts and exact identities. */ ++async function runCommand( ++ root: string, ++ transcriptRoot: string, ++ name: string, ++ command: readonly string[], ++ parseCounts = false, ++): Promise { ++ await mkdir(transcriptRoot, { recursive: true }); ++ const stdoutPath = join(transcriptRoot, `${name}.stdout.log`); ++ const stderrPath = join(transcriptRoot, `${name}.stderr.log`); ++ const startedAt = new Date().toISOString(); ++ const started = performance.now(); ++ const child = Bun.spawn({ ++ cmd: [...command], ++ cwd: root, ++ env: { ...process.env, NO_COLOR: "1" }, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const [stdoutBuffer, stderrBuffer, exitCode] = await Promise.all([ ++ new Response(child.stdout).arrayBuffer(), ++ new Response(child.stderr).arrayBuffer(), ++ child.exited, ++ ]); ++ const stdout = new Uint8Array(stdoutBuffer); ++ const stderr = new Uint8Array(stderrBuffer); ++ await writeFile(stdoutPath, stdout); ++ await writeFile(stderrPath, stderr); ++ return { ++ name, ++ command, ++ cwd: root, ++ startedAt, ++ finishedAt: new Date().toISOString(), ++ durationMs: performance.now() - started, ++ exitCode, ++ stdoutTranscript: relative(root, stdoutPath), ++ stderrTranscript: relative(root, stderrPath), ++ stdoutSha256: sha256(stdout), ++ stderrSha256: sha256(stderr), ++ ...(parseCounts ++ ? { ++ parsedCounts: parseTestCounts( ++ new TextDecoder().decode(stdout), ++ new TextDecoder().decode(stderr), ++ ), ++ } ++ : {}), ++ }; ++} ++ ++/** Run a required validation command and fail the arm on any non-zero exit. */ ++async function runRequiredCommand( ++ root: string, ++ transcriptRoot: string, ++ name: string, ++ command: readonly string[], ++ parseCounts = false, ++) { ++ const evidence = await runCommand(root, transcriptRoot, name, command, parseCounts); ++ if (evidence.exitCode !== 0) throw new Error(`Required command failed: ${name}.`); ++ return evidence; ++} ++ ++/** Find every installed version of one package in an npm dependency-tree projection. */ ++function findDependencyVersions(value: unknown, name: string, versions = new Set()) { ++ if (!value || typeof value !== "object") return versions; ++ const dependencies = (value as { dependencies?: unknown }).dependencies; ++ if (!dependencies || typeof dependencies !== "object") return versions; ++ for (const [dependencyName, dependency] of Object.entries(dependencies)) { ++ if (dependencyName === name) { ++ const version = (dependency as { version?: unknown })?.version; ++ if (typeof version === "string") versions.add(version); ++ } ++ findDependencyVersions(dependency, name, versions); ++ } ++ return versions; ++} ++ ++/** Construct exactly one arm lifecycle and fail closed on a missing treatment factory. */ ++function createArmLifecycle( ++ label: Arm, ++ onDefect: (message: string) => void, ++): ExperimentLifecycle | undefined { ++ const factory = ( ++ sessionBroker as typeof sessionBroker & { ++ createSessionBrokerProcessLifecycle?: ( ++ options?: LifecycleFactoryOptions, ++ ) => ExperimentLifecycle | undefined; ++ } ++ ).createSessionBrokerProcessLifecycle; ++ if (label === "control") { ++ if (factory) throw new Error("Control unexpectedly exports the treatment lifecycle factory."); ++ return undefined; ++ } ++ if (!factory) throw new Error("Treatment does not export createSessionBrokerProcessLifecycle."); ++ const lifecycle = factory({ onDefect }); ++ if ( ++ !lifecycle || ++ typeof lifecycle.close !== "function" || ++ !(lifecycle.settled instanceof Promise) ++ ) { ++ throw new Error("Treatment lifecycle factory returned an invalid lifecycle façade."); ++ } ++ return lifecycle; ++} ++ ++class FixtureSocket implements SessionBrokerSocketLike { ++ readyState = 0; ++ sent: string[] = []; ++ onopen: (() => void) | null = null; ++ onmessage: ((event: { data: unknown }) => void) | null = null; ++ onclose: ((event: { code: number; reason: string }) => void) | null = null; ++ onerror: (() => void) | null = null; ++ onSend?: (value: unknown) => void; ++ ++ send(data: string) { ++ this.sent.push(data); ++ this.onSend?.(JSON.parse(data)); ++ } ++ ++ close(code = 1000, reason = "") { ++ if (this.readyState === 3) return; ++ this.readyState = 3; ++ this.onclose?.({ code, reason }); ++ } ++ ++ open() { ++ this.readyState = 1; ++ this.onopen?.(); ++ } ++} ++ ++const protocolParsers = createSessionBrokerProtocolParsers({ ++ appRevision: 1, ++ features: [], ++ parseRegistration: (value) => value as never, ++ parseSnapshot: (value) => value as never, ++ commands: [ ++ { ++ command: "ping", ++ version: 1, ++ parseInput: (value) => (value === "ping" ? "ping" : null), ++ parseResult: (value) => (value === "pong" ? "pong" : null), ++ }, ++ ], ++}); ++ ++/** Localize the planned required lifecycle option across control and treatment declarations. */ ++function createExperimentConnection(options: Record) { ++ const factory = createSessionBrokerConnection as unknown as ( ++ value: Record, ++ ) => ReturnType; ++ return factory(options); ++} ++ ++/** Build common connection options without adding treatment types to the control. */ ++function connectionOptions(createSocket: () => FixtureSocket, lifecycle?: ExperimentLifecycle) { ++ return { ++ url: "ws://127.0.0.1/session", ++ createSocket, ++ registration: { ++ registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, ++ sessionId: "experiment-session", ++ pid: process.pid, ++ cwd: process.cwd(), ++ launchedAt: new Date(0).toISOString(), ++ info: {}, ++ }, ++ snapshot: { updatedAt: new Date(0).toISOString(), state: {} }, ++ protocolParsers, ++ heartbeatIntervalMs: 60_000, ++ reconnectDelayMs: 1, ++ ...(lifecycle ? { lifecycle } : {}), ++ }; ++} ++ ++/** Measure one explicitly unauthenticated in-memory registration projection. */ ++async function measureUnauthenticatedRegistration(lifecycle?: ExperimentLifecycle) { ++ const socket = new FixtureSocket(); ++ const connection = createExperimentConnection(connectionOptions(() => socket, lifecycle)); ++ connection.start(); ++ socket.open(); ++ if ((JSON.parse(socket.sent[0] ?? "null") as { type?: string } | null)?.type !== "register") { ++ throw new Error("Registration fixture did not emit register."); ++ } ++ connection.stop(); ++} ++ ++/** Measure one parsed command/result through the real FIFO bridge path. */ ++async function measureCommand(lifecycle?: ExperimentLifecycle) { ++ const socket = new FixtureSocket(); ++ let resolveCompleted!: () => void; ++ const completed = new Promise((resolve) => (resolveCompleted = resolve)); ++ socket.onSend = (value) => { ++ if ((value as { type?: unknown }).type === "command-result") resolveCompleted(); ++ }; ++ const connection = createExperimentConnection({ ++ ...connectionOptions(() => socket, lifecycle), ++ bridge: { dispatchCommand: async () => "pong" }, ++ }); ++ connection.start(); ++ socket.open(); ++ socket.onmessage?.({ ++ data: JSON.stringify({ ++ type: "command", ++ requestId: "experiment-request", ++ command: "ping", ++ input: "ping", ++ }), ++ }); ++ await completed; ++ connection.stop(); ++} ++ ++/** Measure one close, delay, preparation, and replacement socket creation. */ ++async function measureReconnect(lifecycle?: ExperimentLifecycle) { ++ const sockets: FixtureSocket[] = []; ++ let resolveReconnected!: () => void; ++ const reconnected = new Promise((resolve) => (resolveReconnected = resolve)); ++ const connection = createExperimentConnection({ ++ ...connectionOptions(() => { ++ const socket = new FixtureSocket(); ++ sockets.push(socket); ++ if (sockets.length === 2) resolveReconnected(); ++ return socket; ++ }, lifecycle), ++ prepareReconnect: async () => {}, ++ }); ++ connection.start(); ++ sockets[0]!.open(); ++ sockets[0]!.close(1006, "retry"); ++ await reconnected; ++ connection.stop(); ++} ++ ++/** Measure only the synchronous connection stop call, excluding fixture construction. */ ++async function measureSynchronousConnectionStop(lifecycle?: ExperimentLifecycle) { ++ const socket = new FixtureSocket(); ++ const connection = createExperimentConnection(connectionOptions(() => socket, lifecycle)); ++ connection.start(); ++ socket.open(); ++ const startedAt = performance.now(); ++ connection.stop(); ++ const duration = performance.now() - startedAt; ++ if (socket.readyState !== 3) throw new Error("Synchronous stop did not close the socket."); ++ return duration; ++} ++ ++/** Create the unchanged daemon/adapter context used by transport-only measurements. */ ++function createFixtureDaemon() { ++ return createSessionBrokerDaemon({ ++ broker: new SessionBroker({ protocolParsers }), ++ idleTimeoutMs: 0, ++ }); ++} ++ ++/** Measure native Bun listener acquisition while settling cleanup before returning. */ ++async function measureTransportListenerAcquisition() { ++ const daemon = createFixtureDaemon(); ++ const startedAt = performance.now(); ++ const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port: 0 }); ++ const duration = performance.now() - startedAt; ++ server.stop(true); ++ await server.stopped; ++ return duration; ++} ++ ++/** Measure unchanged daemon-plus-Bun-adapter ordered stop context. */ ++async function measureTransportOrderedStop() { ++ const daemon = createFixtureDaemon(); ++ const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port: 0 }); ++ server.stop(true); ++ await server.stopped; ++} ++ ++/** Bundle the Bun fixture through a ws-rejecting graph and prove the plugin with a direct probe. */ ++async function buildBunRuntimeFixture(root: string, outputRoot: string) { ++ const runtimeRoot = join(outputRoot, "bun-runtime-fixture"); ++ await mkdir(runtimeRoot, { recursive: true }); ++ const rejected: string[] = []; ++ const rejectWsPlugin = (sink: string[]): Bun.BunPlugin => ({ ++ name: "reject-ws-in-bun-session-fixture", ++ setup(builder) { ++ builder.onResolve({ filter: /^ws(?:\/|$)/ }, (args) => { ++ sink.push(args.path); ++ throw new Error(`Forbidden ws resolution in Bun lifecycle fixture: ${args.path}`); ++ }); ++ }, ++ }); ++ const probe = join(runtimeRoot, "ws-probe.ts"); ++ await writeFile(probe, 'import WebSocket from "ws"; console.log(WebSocket);\n'); ++ let probeFailed = false; ++ try { ++ await Bun.build({ ++ entrypoints: [probe], ++ outdir: join(runtimeRoot, "probe-output"), ++ target: "node", ++ plugins: [rejectWsPlugin(rejected)], ++ }); ++ } catch { ++ probeFailed = true; ++ } ++ if (!probeFailed || rejected.length !== 1 || rejected[0] !== "ws") { ++ throw new Error("Bun ws-rejection plugin direct probe did not fail closed."); ++ } ++ await rm(probe, { force: true }); ++ await rm(join(runtimeRoot, "probe-output"), { force: true, recursive: true }); ++ ++ const fixtureRejected: string[] = []; ++ const bundle = join(runtimeRoot, "bun-connection-fixture.js"); ++ const fixtureBuild = await Bun.build({ ++ entrypoints: [join(root, "test/session-broker-runtime/bun-connection-fixture.ts")], ++ outdir: runtimeRoot, ++ naming: "bun-connection-fixture.js", ++ target: "node", ++ format: "esm", ++ sourcemap: "none", ++ plugins: [rejectWsPlugin(fixtureRejected)], ++ }); ++ if (!fixtureBuild.success || fixtureRejected.length !== 0) { ++ throw new Error("Bun lifecycle fixture build graph reached ws."); ++ } ++ return { ++ bundle, ++ evidence: { ++ buildTarget: "node package-bundling graph; produced bundle executed by Bun", ++ pluginFilter: "^ws(?:\\/|$)", ++ directProbeRejected: rejected, ++ fixtureRejected, ++ bundleSha256: await hashFile(bundle), ++ }, ++ }; ++} ++ ++/** Measure cold process-to-lifecycle-ready using the gated Bun bundle in both arms. */ ++async function measureColdLifecycleReady(root: string, label: Arm, bundle: string) { ++ return measureReported(async () => { ++ const startedAt = performance.now(); ++ const child = Bun.spawn({ ++ cmd: [process.execPath, bundle, "lifecycle-ready"], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const [stdout, stderr, exitCode] = await Promise.all([ ++ new Response(child.stdout).text(), ++ new Response(child.stderr).text(), ++ child.exited, ++ ]); ++ const duration = performance.now() - startedAt; ++ const expected = `lifecycle-ready:${label}\n`; ++ if (exitCode !== 0 || stderr !== "" || stdout !== expected) { ++ throw new Error("Cold lifecycle-ready subprocess did not exercise the requested arm."); ++ } ++ return duration; ++ }); ++} ++ ++/** Build one minified standalone broker bundle and return its exact identity. */ ++async function buildBrokerBundle( ++ root: string, ++ buildRoot: string, ++ target: "bun" | "node", ++ index: number, ++) { ++ const name = `session-broker-${target}-${index}.js`; ++ const startedAt = performance.now(); ++ const result = await Bun.build({ ++ entrypoints: [join(root, "packages/session-broker/src/index.ts")], ++ target, ++ format: "esm", ++ minify: true, ++ sourcemap: "none", ++ outdir: buildRoot, ++ naming: name, ++ }); ++ if (!result.success) throw new Error(result.logs.map((entry) => entry.message).join("\n")); ++ return { ++ target, ++ index, ++ retained: true, ++ durationMs: performance.now() - startedAt, ++ bytes: (await stat(join(buildRoot, name))).size, ++ sha256: await hashFile(join(buildRoot, name)), ++ }; ++} ++ ++/** Read one file when present without treating a control-only absence as an error. */ ++async function readOptional(path: string) { ++ return readFile(path).catch((error: NodeJS.ErrnoException) => { ++ if (error.code === "ENOENT") return null; ++ throw error; ++ }); ++} ++ ++/** List the complete dirty tree as tracked differences plus untracked non-ignored files. */ ++function changedFiles(root: string) { ++ const tracked = Bun.spawnSync({ ++ cmd: ["git", "diff", "--name-only", CONTROL_SHA, "--"], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const untracked = Bun.spawnSync({ ++ cmd: ["git", "ls-files", "--others", "--exclude-standard"], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ if (tracked.exitCode !== 0 || untracked.exitCode !== 0) { ++ throw new Error(`${tracked.stderr}${untracked.stderr}`); ++ } ++ return [tracked.stdout, untracked.stdout] ++ .flatMap((bytes) => bytes.toString().split("\n")) ++ .map((path) => path.trim()) ++ .filter(Boolean) ++ .filter((path, index, paths) => paths.indexOf(path) === index) ++ .sort(); ++} ++ ++/** Classify experiment support separately so new production files cannot hide among fixtures. */ ++function isExperimentSupport(path: string) { ++ return ( ++ path === "docs/session-broker-effect-lifecycle-experiment.md" || ++ path === "docs/session-broker-effect-lifecycle-experiment-results.md" || ++ path === "scripts/session-broker-effect-lifecycle-experiment.ts" || ++ path === "scripts/session-lifecycle-ownership-analysis.ts" || ++ path === "scripts/test-session-broker-node.ts" || ++ path.startsWith("test/") || ++ /(?:^|\/)\w[^/]*\.test\.[cm]?[jt]sx?$/.test(path) ++ ); ++} ++ ++/** Produce one complete binary patch using a temporary index so untracked files are included. */ ++async function createReconstructablePatch(root: string, outputRoot: string) { ++ const indexPath = join(outputRoot, ".reconstruct-index"); ++ await rm(indexPath, { force: true }); ++ const env = { ...process.env, GIT_INDEX_FILE: indexPath }; ++ for (const command of [ ++ ["git", "read-tree", CONTROL_SHA], ++ ["git", "add", "-A", "--", "."], ++ ]) { ++ const result = Bun.spawnSync({ cmd: command, cwd: root, env, stdout: "pipe", stderr: "pipe" }); ++ if (result.exitCode !== 0) throw new Error(result.stderr.toString()); ++ } ++ const diff = Bun.spawnSync({ ++ cmd: ["git", "diff", "--cached", "--binary", CONTROL_SHA, "--"], ++ cwd: root, ++ env, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ await rm(indexPath, { force: true }); ++ if (diff.exitCode !== 0) throw new Error(diff.stderr.toString()); ++ const path = join(outputRoot, "complete-experiment.patch"); ++ await writeFile(path, diff.stdout); ++ return { ++ path: relative(root, path), ++ bytes: diff.stdout.byteLength, ++ sha256: sha256(diff.stdout), ++ includesUntracked: true, ++ }; ++} ++ ++/** Hash all changed files and every required production candidate against the frozen base. */ ++async function collectSourceIdentity( ++ root: string, ++ outputRoot: string, ++ label: Arm, ++ optionalFirstPatch?: string, ++) { ++ const changed = changedFiles(root); ++ const identities = await Promise.all( ++ changed.map(async (path) => { ++ const bytes = await readOptional(join(root, path)); ++ return { ++ path, ++ classification: isExperimentSupport(path) ? "experiment-support" : "production", ++ exists: bytes !== null, ++ ...(bytes ? { bytes: bytes.byteLength, sha256: sha256(bytes) } : {}), ++ }; ++ }), ++ ); ++ const requiredProductionPaths = [ ++ "bun.lock", ++ "packages/session-broker/package.json", ++ "packages/session-broker/src/index.ts", ++ "packages/session-broker/src/processLifecycle.ts", ++ "packages/session-broker/src/connection.ts", ++ "packages/session-broker/src/daemon.ts", ++ "packages/session-broker-node/src/serve.ts", ++ "packages/session-broker-bun/src/serve.ts", ++ "src/session/broker/brokerClient.ts", ++ "src/session/broker/brokerLauncher.ts", ++ "src/session/broker/brokerServer.ts", ++ "src/ui/runInteractiveApp.tsx", ++ ]; ++ const dynamicProductionPaths = identities ++ .filter((entry) => entry.classification === "production") ++ .map((entry) => entry.path); ++ const productionPaths = [ ++ ...new Set([...requiredProductionPaths, ...dynamicProductionPaths]), ++ ].sort(); ++ const production = await Promise.all( ++ productionPaths.map(async (path) => { ++ const current = await readOptional(join(root, path)); ++ const base = Bun.spawnSync({ ++ cmd: ["git", "show", `${CONTROL_SHA}:${path}`], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const baseBytes = base.exitCode === 0 ? new Uint8Array(base.stdout) : null; ++ return { ++ path, ++ currentExists: current !== null, ++ baseExists: baseBytes !== null, ++ currentSha256: current ? sha256(current) : null, ++ baseSha256: baseBytes ? sha256(baseBytes) : null, ++ matchesBase: ++ current === null && baseBytes === null ++ ? true ++ : current !== null && baseBytes !== null && sha256(current) === sha256(baseBytes), ++ }; ++ }), ++ ); ++ const productionMatchesBase = production.every((entry) => entry.matchesBase); ++ const changedProduction = identities ++ .filter((entry) => entry.classification === "production") ++ .map((entry) => entry.path) ++ .sort(); ++ const allowedTreatmentProduction = new Set([ ++ "bun.lock", ++ "packages/session-broker/package.json", ++ "packages/session-broker/src/index.ts", ++ "packages/session-broker/src/processLifecycle.ts", ++ "packages/session-broker/src/connection.ts", ++ "src/session/broker/brokerClient.ts", ++ "src/session/broker/brokerLauncher.ts", ++ "src/ui/runInteractiveApp.tsx", ++ ]); ++ const requiredTreatmentProduction = [ ++ "bun.lock", ++ "packages/session-broker/package.json", ++ "packages/session-broker/src/index.ts", ++ "packages/session-broker/src/processLifecycle.ts", ++ "packages/session-broker/src/connection.ts", ++ "src/session/broker/brokerClient.ts", ++ "src/ui/runInteractiveApp.tsx", ++ ]; ++ const productionChangeViolations = ++ label === "treatment" ++ ? [ ++ ...changedProduction ++ .filter((path) => !allowedTreatmentProduction.has(path)) ++ .map((path) => `disallowed:${path}`), ++ ...requiredTreatmentProduction ++ .filter((path) => !changedProduction.includes(path)) ++ .map((path) => `missing:${path}`), ++ ] ++ : []; ++ if (productionChangeViolations.length > 0) { ++ throw new Error( ++ `Treatment production change set rejected: ${productionChangeViolations.join(", ")}`, ++ ); ++ } ++ if (label === "control" && !productionMatchesBase) { ++ throw new Error("Control production files do not match the frozen base."); ++ } ++ if (label === "treatment" && productionMatchesBase) { ++ throw new Error("Treatment did not change the required production lifecycle path."); ++ } ++ const behavioralTestContracts = BEHAVIORALLY_FROZEN_TESTS.map((path) => ++ analyzeTestContinuity(root, join(root, path)), ++ ); ++ const reconstructablePatch = await createReconstructablePatch(root, outputRoot); ++ const observedFirstPatch = optionalFirstPatch ++ ? await readOptional(resolve(optionalFirstPatch)) ++ : null; ++ if (observedFirstPatch && sha256(observedFirstPatch) !== FIRST_EXPERIMENT_PATCH_SHA256) { ++ throw new Error("Optional first-experiment patch does not match its frozen identity."); ++ } ++ return { ++ schemaVersion: 2, ++ baseHead: CONTROL_SHA, ++ currentHead: Bun.spawnSync({ cmd: ["git", "rev-parse", "HEAD"], cwd: root }) ++ .stdout.toString() ++ .trim(), ++ changedFiles: identities, ++ behavioralTestContracts, ++ production, ++ productionMatchesBase, ++ productionChangePolicy: { ++ changedProduction, ++ allowed: [...allowedTreatmentProduction].sort(), ++ required: requiredTreatmentProduction, ++ violations: productionChangeViolations, ++ }, ++ reconstructablePatch, ++ firstExperiment: { ++ expectedPatchSha256: FIRST_EXPERIMENT_PATCH_SHA256, ++ observation: observedFirstPatch ++ ? { suppliedPath: resolve(optionalFirstPatch!), observedSha256: sha256(observedFirstPatch) } ++ : { suppliedPath: null, observedSha256: null, status: "expected-hash-only" }, ++ }, ++ }; ++} ++ ++/** Enforce AST-derived ownership gates and retain every relevant source location. */ ++async function collectOwnership(root: string, label: Arm) { ++ const analysis = analyzeTreatmentOwnership(root); ++ if (label === "control") { ++ if ( ++ analysis.treatmentPresent || ++ analysis.locations.connectionNativeTiming.length === 0 || ++ analysis.locations.clientNativeTiming.length === 0 || ++ analysis.locations.launcherBunSleep.length === 0 ++ ) { ++ throw new Error("Control no longer exposes the frozen native lifecycle mechanisms."); ++ } ++ } else if (!analysis.treatmentPresent || analysis.violations.length > 0) { ++ throw new Error(`Treatment ownership gate failed: ${analysis.violations.join("; ")}`); ++ } ++ return { schemaVersion: 3, label, ...analysis }; ++} ++ ++/** Label semantics from AST-derived mechanism locations without claiming semantic proof. */ ++function collectSemantics(label: Arm, ownership: Awaited>) { ++ const locations = ownership.locations; ++ return { ++ schemaVersion: 3, ++ label, ++ heartbeat: ++ locations.fixedHeartbeatSchedule.length === 1 ++ ? "delayed-Effect-Schedule.fixed-mechanical-match" ++ : "native-timing-control", ++ connectionOwner: ++ locations.connectionSupervisorProperties.length === 1 ++ ? "one-frozen-supervisor-property" ++ : "native-timing-control", ++ clientStartupOwner: ++ locations.clientSupervisorProperties.length === 1 ++ ? "one-frozen-supervisor-property" ++ : "native-timing-control", ++ staticAnalysisLimit: ++ "AST gates prove only named mechanical source shapes; independent review must establish lifecycle semantics and correctness.", ++ cancellationBoundary: ++ "foreign native promises and detached processes are not claimed cancelled", ++ intentionalTreatmentDifferences: ++ label === "treatment" ++ ? [ ++ "Schedule.fixed catch-up may differ from native setInterval.", ++ `Unexpected lifecycle defects use the fixed message: ${REDACTED_LIFECYCLE_DEFECT}`, ++ ] ++ : [], ++ }; ++} ++ ++/** Enforce exact Effect placement and return all package declarations that mention it. */ ++async function collectDependencyPlacement(root: string, label: Arm, effectVersions: string[]) { ++ const packageList = Bun.spawnSync({ ++ cmd: ["git", "ls-files", "package.json", "**/package.json"], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ if (packageList.exitCode !== 0) throw new Error(packageList.stderr.toString()); ++ const declarations: Array<{ path: string; section: string; value: string }> = []; ++ for (const path of packageList.stdout.toString().split("\n").filter(Boolean).sort()) { ++ const value = JSON.parse(await readFile(join(root, path), "utf8")) as Record; ++ for (const section of [ ++ "dependencies", ++ "devDependencies", ++ "peerDependencies", ++ "optionalDependencies", ++ ] as const) { ++ const dependency = (value[section] as Record | undefined)?.effect; ++ if (typeof dependency === "string") declarations.push({ path, section, value: dependency }); ++ } ++ } ++ if (label === "control") { ++ if (effectVersions.length > 0 || declarations.length > 0) { ++ throw new Error("Control unexpectedly contains Effect."); ++ } ++ } else if ( ++ effectVersions.length !== 1 || ++ effectVersions[0] !== "3.22.1" || ++ declarations.length !== 1 || ++ declarations[0]?.path !== "packages/session-broker/package.json" || ++ declarations[0]?.section !== "dependencies" || ++ declarations[0]?.value !== "3.22.1" ++ ) { ++ throw new Error( ++ "Treatment must declare and resolve exact effect@3.22.1 only in session-broker.", ++ ); ++ } ++ return { effectVersions, declarations }; ++} ++ ++/** Enforce actual Effect/ws module specifier boundaries and retain syntax-derived locations. */ ++function collectModuleBoundaries(root: string, label: Arm) { ++ const processLifecycle = "packages/session-broker/src/processLifecycle.ts"; ++ const allFiles = repositoryTypeScriptFiles(root); ++ const effectImports = allFiles.flatMap((path) => ++ astModuleSpecifiers(path) ++ .filter(({ specifier }) => specifier === "effect" || specifier.startsWith("effect/")) ++ .map(({ specifier, location }) => ({ specifier, ...location })), ++ ); ++ const allowedEffectFiles = new Set([ ++ processLifecycle, ++ "packages/session-broker/src/processLifecycle.test.ts", ++ ]); ++ const misplacedEffectImports = effectImports.filter( ++ (entry) => !allowedEffectFiles.has(entry.file), ++ ); ++ const forbiddenSupportImports = forbiddenProductionSupportImports(root); ++ const bunFiles = [ ++ ...allTypeScriptFiles(join(root, "packages/session-broker-bun")), ++ ...allTypeScriptFiles(join(root, "test/session-broker-runtime")), ++ ]; ++ const bunWsImports = bunFiles.flatMap((path) => ++ astModuleSpecifiers(path) ++ .filter(({ specifier }) => specifier === "ws" || specifier.startsWith("ws/")) ++ .map(({ specifier, location }) => ({ specifier, ...location })), ++ ); ++ if ( ++ misplacedEffectImports.length > 0 || ++ forbiddenSupportImports.length > 0 || ++ bunWsImports.length > 0 || ++ (label === "control" && effectImports.length > 0) || ++ (label === "treatment" && ++ (effectImports.length === 0 || ++ effectImports.some((entry) => !allowedEffectFiles.has(entry.file)))) ++ ) { ++ throw new Error("Effect/ws syntax-aware module boundary failed."); ++ } ++ return { ++ effectImports, ++ misplacedEffectImports, ++ forbiddenSupportImports, ++ bunWsImports, ++ bunBuildGate: ++ "The Bun runtime fixture is executed only from a bundle built with a ws/ws/* rejecting onResolve plugin; a direct ws import probe must fail under the same plugin.", ++ }; ++} ++ ++/** Trigger the frozen unexpected-close-policy defect without persisting the random marker. */ ++async function exerciseCanary( ++ label: Arm, ++ lifecycle: ExperimentLifecycle | undefined, ++ marker: string, ++ defectMessages: string[], ++) { ++ const socket = new FixtureSocket(); ++ const outputMessages: Array<{ channel: string; text: string }> = []; ++ const consoleMethodNames = new Set(); ++ for ( ++ let value: object | null = console; ++ value && value !== Object.prototype; ++ value = Object.getPrototypeOf(value) ++ ) { ++ for (const name of Object.getOwnPropertyNames(value)) { ++ if (name !== "constructor" && typeof Reflect.get(console, name) === "function") { ++ consoleMethodNames.add(name); ++ } ++ } ++ } ++ const previousConsoleDescriptors = new Map(); ++ for (const method of consoleMethodNames) { ++ previousConsoleDescriptors.set(method, Object.getOwnPropertyDescriptor(console, method)); ++ Object.defineProperty(console, method, { ++ configurable: true, ++ writable: true, ++ value: (...values: unknown[]) => { ++ outputMessages.push({ ++ channel: `console.${method}`, ++ text: values.map((value) => String(value)).join(" "), ++ }); ++ }, ++ }); ++ } ++ const previousStdoutWrite = process.stdout.write; ++ const previousStderrWrite = process.stderr.write; ++ process.stdout.write = ((chunk: unknown) => { ++ outputMessages.push({ channel: "process.stdout.write", text: String(chunk) }); ++ return true; ++ }) as typeof process.stdout.write; ++ process.stderr.write = ((chunk: unknown) => { ++ outputMessages.push({ channel: "process.stderr.write", text: String(chunk) }); ++ return true; ++ }) as typeof process.stderr.write; ++ let synchronousEscape: string | null = null; ++ if (label === "treatment" && defectMessages.length !== 0) { ++ throw new Error("Treatment emitted a lifecycle defect before the canary."); ++ } ++ defectMessages.length = 0; ++ try { ++ for (const method of ["error", "warn", "log", "info", "debug", "trace", "dir", "table"]) { ++ (console as unknown as Record void>)[method]?.("self-test"); ++ } ++ const selfTestChannels = new Set(outputMessages.map((entry) => entry.channel)); ++ for (const method of ["error", "warn", "log", "info", "debug", "trace", "dir", "table"]) { ++ if (!selfTestChannels.has(`console.${method}`)) { ++ throw new Error(`Console interception self-test missed ${method}.`); ++ } ++ } ++ outputMessages.length = 0; ++ const connection = createExperimentConnection({ ++ ...connectionOptions(() => socket, lifecycle), ++ resolveClose: () => { ++ throw new Error(marker); ++ }, ++ }); ++ connection.start(); ++ socket.open(); ++ try { ++ socket.close(1006, "canary"); ++ } catch (error) { ++ synchronousEscape = String(error); ++ } ++ for ( ++ let index = 0; ++ label === "treatment" && defectMessages.length === 0 && index < 40; ++ index += 1 ++ ) { ++ await Bun.sleep(5); ++ } ++ connection.stop(); ++ } finally { ++ for (const [method, descriptor] of previousConsoleDescriptors) { ++ if (descriptor) Object.defineProperty(console, method, descriptor); ++ else delete (console as unknown as Record)[method]; ++ } ++ process.stdout.write = previousStdoutWrite; ++ process.stderr.write = previousStderrWrite; ++ } ++ const controlExposedRawDefect = synchronousEscape?.includes(marker) ?? false; ++ if (label === "control" && !controlExposedRawDefect) { ++ throw new Error("Control canary no longer exposes the frozen synchronous close-policy defect."); ++ } ++ const canaryDefectMessages = [...defectMessages]; ++ if (label === "treatment") { ++ if (synchronousEscape !== null) { ++ throw new Error("Treatment synchronously exposed a lifecycle defect."); ++ } ++ if ( ++ canaryDefectMessages.length !== 1 || ++ canaryDefectMessages[0] !== REDACTED_LIFECYCLE_DEFECT ++ ) { ++ throw new Error("Treatment must report exactly one fixed redacted lifecycle defect."); ++ } ++ if (outputMessages.length !== 0) { ++ throw new Error( ++ "Treatment wrote process or console output during the lifecycle defect canary.", ++ ); ++ } ++ } ++ return { ++ schemaVersion: 3, ++ label, ++ markerSha256: sha256(marker), ++ exercised: "resolveClose callback defect during reconnect close policy", ++ fixedTreatmentMessage: REDACTED_LIFECYCLE_DEFECT, ++ defectCallbackMessages: canaryDefectMessages, ++ outputMessages, ++ synchronousEscapeCharacterized: synchronousEscape !== null, ++ controlLimitation: ++ label === "control" ++ ? "The raw Error remains synchronously observable to the close caller in memory; the harness catches it and never persists the marker. This does not exercise credentials, payloads, finalizers, or rendered Effect Causes." ++ : null, ++ treatmentAssertionsApplied: label === "treatment", ++ }; ++} ++ ++/** Scan all generated evidence bytes for the random marker. */ ++async function scanCanary(outputRoot: string, marker: string) { ++ const matches: string[] = []; ++ for (const path of await listFiles(outputRoot)) { ++ if (basename(path).startsWith("MANIFEST")) continue; ++ const bytes = await readFile(path); ++ if (bytes.includes(Buffer.from(marker))) matches.push(relative(outputRoot, path)); ++ } ++ return matches; ++} ++ ++/** Write JSON with one stable final newline. */ ++async function writeJson(path: string, value: unknown) { ++ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); ++} ++ ++/** Write a sorted checksum manifest without making it self-referential. */ ++async function writeManifest(outputRoot: string) { ++ const manifestPath = join(outputRoot, "MANIFEST.sha256"); ++ const attestationPath = join(outputRoot, "MANIFEST.attestation.sha256"); ++ const files = (await listFiles(outputRoot)).filter( ++ (path) => path !== manifestPath && path !== attestationPath, ++ ); ++ const lines = await Promise.all( ++ files.map(async (path) => `${await hashFile(path)} ${relative(outputRoot, path)}`), ++ ); ++ await writeFile(manifestPath, `${lines.sort().join("\n")}\n`); ++ await writeFile(attestationPath, `${await hashFile(manifestPath)} ${basename(manifestPath)}\n`); ++} ++ ++/** Verify an externally anchored local control manifest before trusting control identities. */ ++async function verifyControlAttestation(root: string, expectedAttestationSha256: string) { ++ if (!/^[a-f0-9]{64}$/.test(expectedAttestationSha256)) { ++ throw new TypeError("Control attestation SHA-256 must be 64 lowercase hexadecimal characters."); ++ } ++ const controlRoot = join(root, "tmp/session-lifecycle-effect/control"); ++ const attestationPath = join(controlRoot, "MANIFEST.attestation.sha256"); ++ const manifestPath = join(controlRoot, "MANIFEST.sha256"); ++ const attestation = await readFile(attestationPath); ++ if (sha256(attestation) !== expectedAttestationSha256) { ++ throw new Error("External control attestation SHA-256 does not match the attestation file."); ++ } ++ const attestationMatch = attestation.toString().match(/^([a-f0-9]{64}) MANIFEST\.sha256\n$/); ++ if (!attestationMatch?.[1] || (await hashFile(manifestPath)) !== attestationMatch[1]) { ++ throw new Error("Control attestation does not authenticate MANIFEST.sha256."); ++ } ++ const entries = (await readFile(manifestPath, "utf8")).trimEnd().split("\n"); ++ let resultVerified = false; ++ for (const entry of entries) { ++ const match = entry.match(/^([a-f0-9]{64}) (.+)$/); ++ if (!match?.[1] || !match[2]) throw new Error("Control manifest contains a malformed entry."); ++ const relativePath = match[2]; ++ const resolvedPath = resolve(controlRoot, relativePath); ++ if ( ++ relative(controlRoot, resolvedPath).startsWith("..") || ++ (await hashFile(resolvedPath)) !== match[1] ++ ) { ++ throw new Error(`Control manifest verification failed for ${relativePath}.`); ++ } ++ if (relativePath === "result.json") resultVerified = true; ++ } ++ if (!resultVerified) throw new Error("Control manifest does not authenticate result.json."); ++ return { ++ attestationFileSha256: expectedAttestationSha256, ++ manifestSha256: attestationMatch[1], ++ verifiedEntries: entries.length, ++ note: "Local integrity/reproducibility evidence only; this is not a cryptographic signer or timestamp.", ++ }; ++} ++ ++/** Freeze support bytes except lifecycle-injection tests, whose assertions remain monotonic. */ ++async function verifyFrozenControlInputs(root: string, sourceIdentity: unknown) { ++ type TestContract = ReturnType; ++ const controlIdentity = sourceIdentity as { ++ changedFiles?: Array<{ path: string; classification: string; sha256?: string }>; ++ behavioralTestContracts?: TestContract[]; ++ }; ++ const entries = controlIdentity.changedFiles; ++ if (!Array.isArray(entries)) ++ throw new Error("Control source identity lacks changed-file hashes."); ++ const frozen = entries.filter( ++ (entry) => ++ entry.classification === "experiment-support" && ++ entry.path !== "docs/session-broker-effect-lifecycle-experiment-results.md" && ++ !BEHAVIORALLY_FROZEN_TESTS.includes(entry.path as (typeof BEHAVIORALLY_FROZEN_TESTS)[number]), ++ ); ++ for (const entry of frozen) { ++ if (!entry.sha256 || (await hashFile(join(root, entry.path))) !== entry.sha256) { ++ throw new Error(`Frozen control input changed before treatment: ${entry.path}.`); ++ } ++ } ++ const controlContracts = controlIdentity.behavioralTestContracts; ++ if ( ++ !Array.isArray(controlContracts) || ++ controlContracts.length !== BEHAVIORALLY_FROZEN_TESTS.length ++ ) { ++ throw new Error("Control source identity lacks behavioral test contracts."); ++ } ++ const currentContracts = BEHAVIORALLY_FROZEN_TESTS.map((path) => ++ analyzeTestContinuity(root, join(root, path)), ++ ); ++ for (const current of currentContracts) { ++ if (current.forbiddenModes.length > 0) { ++ throw new Error(`Treatment weakened tests with skip/todo/only: ${current.path}.`); ++ } ++ const control = controlContracts.find((entry) => entry.path === current.path); ++ if (!control) throw new Error(`Missing control test contract: ${current.path}.`); ++ const seenNames = new Map(); ++ for (const expected of control.tests) { ++ const occurrence = seenNames.get(expected.name) ?? 0; ++ seenNames.set(expected.name, occurrence + 1); ++ const observed = current.tests.filter((test) => test.name === expected.name)[occurrence]; ++ if (!observed || observed.assertions.length < expected.assertions.length) { ++ throw new Error(`Treatment weakened control test: ${current.path} :: ${expected.name}.`); ++ } ++ } ++ } ++ return { ++ count: frozen.length, ++ files: frozen.map((entry) => ({ path: entry.path, sha256: entry.sha256 })), ++ behavioralTestContinuity: { ++ control: controlContracts, ++ treatment: currentContracts, ++ note: "Mechanical anti-weakening evidence only; independent review must establish behavioral preservation.", ++ }, ++ excluded: [ ++ "docs/session-broker-effect-lifecycle-experiment-results.md", ++ ...BEHAVIORALLY_FROZEN_TESTS, ++ "raw arm evidence", ++ ], ++ }; ++} ++ ++/** Collect one fresh, enforced control or treatment arm. */ ++async function main() { ++ const labelIndex = process.argv.indexOf("--label"); ++ const label = labelIndex >= 0 ? process.argv[labelIndex + 1] : undefined; ++ if (label !== "control" && label !== "treatment") { ++ throw new TypeError("Expected --label control or --label treatment."); ++ } ++ const firstPatchIndex = process.argv.indexOf("--first-experiment-patch"); ++ const optionalFirstPatch = firstPatchIndex >= 0 ? process.argv[firstPatchIndex + 1] : undefined; ++ const controlAttestationIndex = process.argv.indexOf("--control-attestation-sha256"); ++ const externalControlAttestation = ++ controlAttestationIndex >= 0 ? process.argv[controlAttestationIndex + 1] : undefined; ++ const root = process.cwd(); ++ const outputRoot = resolve(root, "tmp/session-lifecycle-effect", label); ++ const buildRoot = join(outputRoot, "builds"); ++ const commandRoot = join(outputRoot, "commands"); ++ const validationRoot = join(outputRoot, "validation"); ++ const declarationRoot = join(outputRoot, "declarations"); ++ const harnessPath = join(root, "scripts/session-broker-effect-lifecycle-experiment.ts"); ++ const planPath = join(root, "docs/session-broker-effect-lifecycle-experiment.md"); ++ ++ let verifiedControlAttestation: Awaited> | null = ++ null; ++ let frozenControlInputs: Awaited> | null = null; ++ if (label === "treatment") { ++ if (!externalControlAttestation) { ++ throw new TypeError("Treatment requires --control-attestation-sha256 ."); ++ } ++ verifiedControlAttestation = await verifyControlAttestation(root, externalControlAttestation); ++ const controlResult = JSON.parse( ++ await readFile(join(root, "tmp/session-lifecycle-effect/control/result.json"), "utf8"), ++ ) as { identities?: { harnessSha256?: string; planSha256?: string } }; ++ const controlSourceIdentity = JSON.parse( ++ await readFile( ++ join(root, "tmp/session-lifecycle-effect/control/source-identity.json"), ++ "utf8", ++ ), ++ ); ++ frozenControlInputs = await verifyFrozenControlInputs(root, controlSourceIdentity); ++ if ( ++ controlResult.identities?.harnessSha256 !== (await hashFile(harnessPath)) || ++ controlResult.identities?.planSha256 !== (await hashFile(planPath)) ++ ) { ++ throw new Error("Treatment must use the unchanged frozen control harness and plan."); ++ } ++ } ++ ++ // No prior arm file may satisfy a current run or enter its manifest. ++ await rm(outputRoot, { force: true, recursive: true }); ++ await mkdir(buildRoot, { recursive: true }); ++ ++ const validationCommands = [ ++ await runRequiredCommand( ++ root, ++ validationRoot, ++ "focused", ++ [ ++ "bun", ++ "test", ++ "packages/session-broker-core", ++ "packages/session-broker", ++ "packages/session-broker-bun", ++ "src/session/broker", ++ "scripts/source-boundaries.test.ts", ++ ], ++ true, ++ ), ++ await runRequiredCommand( ++ root, ++ validationRoot, ++ "real-node", ++ ["bun", "run", "test:session-broker-node"], ++ true, ++ ), ++ await runRequiredCommand(root, validationRoot, "typecheck", ["bun", "run", "typecheck"]), ++ await runRequiredCommand(root, validationRoot, "lint", ["bun", "run", "lint"]), ++ await runRequiredCommand(root, validationRoot, "dependencies", ["bun", "run", "deps:check"]), ++ await runRequiredCommand(root, validationRoot, "full-unit", ["bun", "run", "test"], true), ++ await runRequiredCommand( ++ root, ++ validationRoot, ++ "pty-integration", ++ ["bun", "run", "test:integration"], ++ true, ++ ), ++ ]; ++ ++ const commandEvidence: CommandEvidence[] = []; ++ const systemNode = await runRequiredCommand(root, commandRoot, "system-node-version", [ ++ "node", ++ "--version", ++ ]); ++ commandEvidence.push(systemNode); ++ const dependencyTree = await runCommand(root, commandRoot, "dependency-tree", [ ++ "npm", ++ "ls", ++ "effect", ++ "--all", ++ "--json", ++ ]); ++ commandEvidence.push(dependencyTree); ++ const dependencyTreeValue = JSON.parse( ++ await readFile(join(root, dependencyTree.stdoutTranscript), "utf8"), ++ ); ++ const effectVersions = [...findDependencyVersions(dependencyTreeValue, "effect")].sort(); ++ const dependencyPlacement = await collectDependencyPlacement(root, label, effectVersions); ++ if (label === "treatment" && dependencyTree.exitCode !== 0) { ++ throw new Error("Treatment dependency-tree command must exit successfully."); ++ } ++ const moduleBoundaries = collectModuleBoundaries(root, label); ++ ++ const builds = []; ++ for (let index = 1; index <= 3; index += 1) { ++ builds.push(await buildBrokerBundle(root, buildRoot, "bun", index)); ++ builds.push(await buildBrokerBundle(root, buildRoot, "node", index)); ++ } ++ for (let index = 1; index <= 3; index += 1) { ++ const evidence = await runRequiredCommand(root, commandRoot, `compiled-hunk-${index}`, [ ++ "bun", ++ "run", ++ "build:bin", ++ ]); ++ commandEvidence.push(evidence); ++ const binary = join(root, "dist", platform() === "win32" ? "hunk.exe" : "hunk"); ++ builds.push({ ++ target: "compiled-hunk" as const, ++ index, ++ retained: false, ++ retentionNote: ++ "Identity measured in dist and recorded; binary is not copied into arm evidence.", ++ durationMs: evidence.durationMs, ++ bytes: (await stat(binary)).size, ++ sha256: await hashFile(binary), ++ }); ++ } ++ ++ const bunRuntimeFixture = await buildBunRuntimeFixture(root, outputRoot); ++ const defectMessages: string[] = []; ++ const lifecycle = createArmLifecycle(label, (message) => defectMessages.push(message)); ++ const measurements: Record = { ++ processToLifecycleReadyCold: await measureColdLifecycleReady( ++ root, ++ label, ++ bunRuntimeFixture.bundle, ++ ), ++ synchronousConnectionStop: await measureReported(() => ++ measureSynchronousConnectionStop(lifecycle), ++ ), ++ unauthenticatedInMemoryRegistration: await measure(() => ++ measureUnauthenticatedRegistration(lifecycle), ++ ), ++ authenticatedRegistration: { ++ status: "not-measured", ++ reason: ++ "The signed Bun/Node fixtures prove hello and broker-observed register, but do not expose a stable isolated registration timestamp in both arms.", ++ }, ++ commandRoundTrip: await measure(() => measureCommand(lifecycle)), ++ reconnectGeneration: await measure(() => measureReconnect(lifecycle)), ++ transportListenerAcquisitionContext: await measureReported(measureTransportListenerAcquisition), ++ transportDaemonOrderedStopContext: await measure(measureTransportOrderedStop), ++ hunkStartupPolling: { ++ status: "not-measured", ++ reason: ++ "Hunk startup polling is a Bun product path with no honest portable real-Node equivalent; characterization tests freeze behavior instead of timing a synthetic timer.", ++ }, ++ }; ++ ++ const cpuBefore = process.cpuUsage(); ++ const cpuStartedAt = performance.now(); ++ for (let index = 0; index < 100; index += 1) await measureCommand(lifecycle); ++ const cpu = { ++ classification: "coarse-non-isolated-observation", ++ conclusionPolicy: ++ "Do not infer lifecycle performance or security conclusions from this sample.", ++ operations: 100, ++ wallMs: performance.now() - cpuStartedAt, ++ ...process.cpuUsage(cpuBefore), ++ }; ++ const rssSamples: number[] = []; ++ for (let index = 0; index < 5; index += 1) { ++ await Bun.sleep(10); ++ rssSamples.push(process.memoryUsage.rss()); ++ } ++ ++ const declaration = await runRequiredCommand(root, commandRoot, "public-declarations", [ ++ "./node_modules/.bin/tsc", ++ "packages/session-broker/src/index.ts", ++ "--declaration", ++ "--emitDeclarationOnly", ++ "--outDir", ++ relative(root, declarationRoot), ++ "--module", ++ "ESNext", ++ "--moduleResolution", ++ "Bundler", ++ "--target", ++ "ES2022", ++ "--lib", ++ "ES2022,DOM", ++ "--skipLibCheck", ++ "--strict", ++ "--types", ++ "bun", ++ ]); ++ commandEvidence.push(declaration); ++ const declarationFiles = (await listFiles(declarationRoot)).filter((path) => ++ path.endsWith(".d.ts"), ++ ); ++ const forbiddenDeclarationPattern = ++ /(?:from\s+["']effect["']|\b(?:Effect|ManagedRuntime|Runtime|Layer|Scope|Fiber|Cause|Exit|Duration|Schedule|Clock)\b)/; ++ const declarationMatches: Array<{ file: string; line: number; text: string }> = []; ++ for (const path of declarationFiles) { ++ const lines = (await readFile(path, "utf8")).split("\n"); ++ lines.forEach((text, index) => { ++ const declarationText = /^\s*(?:\/\*|\*|\/\/)/.test(text) ? "" : text; ++ if (forbiddenDeclarationPattern.test(declarationText)) { ++ declarationMatches.push({ file: relative(declarationRoot, path), line: index + 1, text }); ++ } ++ }); ++ } ++ if (declarationMatches.length > 0) ++ throw new Error("Public declarations expose Effect vocabulary."); ++ ++ const canaryMarker = `session-lifecycle-canary-${crypto.randomUUID()}`; ++ const canary = await exerciseCanary(label, lifecycle, canaryMarker, defectMessages); ++ lifecycle?.close(); ++ await lifecycle?.settled; ++ if ( ++ label === "treatment" && ++ (defectMessages.length !== 1 || defectMessages[0] !== REDACTED_LIFECYCLE_DEFECT) ++ ) { ++ throw new Error( ++ "Treatment lifecycle emitted defect-hook output outside the single canary event.", ++ ); ++ } ++ ++ const sourceIdentity = await collectSourceIdentity(root, outputRoot, label, optionalFirstPatch); ++ const ownership = await collectOwnership(root, label); ++ const semantics = collectSemantics(label, ownership); ++ await writeJson(join(outputRoot, "source-identity.json"), sourceIdentity); ++ await writeJson(join(outputRoot, "ownership-inventory.json"), ownership); ++ await writeJson(join(outputRoot, "semantics.json"), semantics); ++ await writeJson(join(outputRoot, "canary.json"), canary); ++ await writeJson(join(validationRoot, "manifest.json"), { ++ schemaVersion: 2, ++ recordedAt: new Date().toISOString(), ++ freshness: "all required commands executed by this arm invocation after clearing output", ++ commands: validationCommands, ++ }); ++ ++ const result = { ++ schemaVersion: 3, ++ label, ++ recordedAt: new Date().toISOString(), ++ environment: { ++ head: sourceIdentity.currentHead, ++ bun: Bun.version, ++ embeddedNode: process.versions.node, ++ systemNodeTranscript: systemNode.stdoutTranscript, ++ os: platform(), ++ release: release(), ++ arch: arch(), ++ cpu: cpus()[0]?.model ?? "unknown", ++ }, ++ identities: { ++ harnessSha256: await hashFile(harnessPath), ++ planSha256: await hashFile(planPath), ++ lockfileSha256: await hashFile(join(root, "bun.lock")), ++ firstExperimentPatchSha256: FIRST_EXPERIMENT_PATCH_SHA256, ++ }, ++ builds, ++ measurements, ++ coarseObservations: { ++ cpu, ++ rss: { ++ classification: "coarse-non-isolated-observation", ++ conclusionPolicy: ++ "Do not infer lifecycle performance or security conclusions from this sample.", ++ unit: "bytes", ++ samples: rssSamples, ++ median: percentile(rssSamples, 0.5), ++ p95: percentile(rssSamples, 0.95), ++ }, ++ }, ++ processExit: { ++ bun: { ++ status: "passed", ++ evidence: validationCommands.find((entry) => entry.name === "focused"), ++ modes: [ ++ "signed-register", ++ "lifecycle-ready", ++ "pending-handshake", ++ "heartbeat", ++ "reconnect", ++ ], ++ }, ++ node: { ++ status: "passed", ++ evidence: validationCommands.find((entry) => entry.name === "real-node"), ++ modes: [ ++ "signed-register", ++ "lifecycle-ready", ++ "pending-handshake", ++ "heartbeat", ++ "reconnect", ++ ], ++ }, ++ }, ++ declarations: { ++ files: declarationFiles.map((path) => relative(declarationRoot, path)), ++ forbiddenPattern: forbiddenDeclarationPattern.source, ++ forbiddenMatches: declarationMatches, ++ }, ++ dependencyPlacement: { ++ ...dependencyPlacement, ++ commandExitCode: dependencyTree.exitCode, ++ commandExpectation: ++ label === "control" ++ ? "Effect is absent; npm ls may exit nonzero for the missing requested package." ++ : "Treatment requires exit 0 with exact effect@3.22.1.", ++ }, ++ moduleBoundaries: { ++ ...moduleBoundaries, ++ bunBuildGraph: bunRuntimeFixture.evidence, ++ }, ++ behavioralTestContinuity: { ++ contracts: sourceIdentity.behavioralTestContracts, ++ treatmentComparison: frozenControlInputs?.behavioralTestContinuity ?? null, ++ note: "Mechanical anti-weakening evidence only; independent review must establish behavioral preservation.", ++ }, ++ verifiedControlAttestation, ++ frozenControlInputs, ++ validation: validationCommands, ++ commands: commandEvidence, ++ }; ++ await writeJson(join(outputRoot, "result.json"), result); ++ ++ const canaryMatches = await scanCanary(outputRoot, canaryMarker); ++ if (canaryMatches.length > 0) { ++ throw new Error(`Canary marker escaped into evidence: ${canaryMatches.join(", ")}`); ++ } ++ await writeManifest(outputRoot); ++ console.log(join(outputRoot, "result.json")); ++} ++ ++await main(); +diff --git a/scripts/session-lifecycle-ownership-analysis.ts b/scripts/session-lifecycle-ownership-analysis.ts +new file mode 100644 +index 00000000..645bd84b +--- /dev/null ++++ b/scripts/session-lifecycle-ownership-analysis.ts +@@ -0,0 +1,676 @@ ++import { execFileSync } from "node:child_process"; ++import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; ++import { extname, join, relative, resolve, sep } from "node:path"; ++import ts from "typescript"; ++ ++export interface SourceLocationFact { ++ readonly file: string; ++ readonly line: number; ++ readonly column: number; ++ readonly kind: string; ++ readonly text: string; ++} ++ ++export interface TreatmentOwnershipAnalysis { ++ readonly treatmentPresent: boolean; ++ readonly locations: { ++ readonly connectionNativeTiming: readonly SourceLocationFact[]; ++ readonly clientNativeTiming: readonly SourceLocationFact[]; ++ readonly launcherBunSleep: readonly SourceLocationFact[]; ++ readonly connectionProperties: readonly SourceLocationFact[]; ++ readonly clientProperties: readonly SourceLocationFact[]; ++ readonly connectionSupervisorProperties: readonly SourceLocationFact[]; ++ readonly clientSupervisorProperties: readonly SourceLocationFact[]; ++ readonly connectionDelayOwnerProperties: readonly SourceLocationFact[]; ++ readonly clientDelayOwnerProperties: readonly SourceLocationFact[]; ++ readonly clientPromiseOwnerProperties: readonly SourceLocationFact[]; ++ readonly processTaskRegistries: readonly SourceLocationFact[]; ++ readonly processImplementationClasses: readonly SourceLocationFact[]; ++ readonly processDelayClasses: readonly SourceLocationFact[]; ++ readonly processSetInterval: readonly SourceLocationFact[]; ++ readonly processTimersOutsideClock: readonly SourceLocationFact[]; ++ readonly managedRuntimeConstruction: readonly SourceLocationFact[]; ++ readonly customClockConstruction: readonly SourceLocationFact[]; ++ readonly fixedHeartbeatSchedule: readonly SourceLocationFact[]; ++ readonly delayedHeartbeatSleep: readonly SourceLocationFact[]; ++ readonly runtimeFactoryCalls: readonly SourceLocationFact[]; ++ }; ++ readonly violations: readonly string[]; ++} ++ ++/** Return stable repo-relative paths in evidence and failures. */ ++function repoPath(root: string, path: string) { ++ return relative(root, path).split(sep).join("/"); ++} ++ ++/** Identify TypeScript source extensions while excluding declaration files. */ ++function isTypeScriptSource(path: string) { ++ return /\.(?:ts|tsx|mts|cts)$/.test(path) && !/\.d\.(?:ts|tsx|mts|cts)$/.test(path); ++} ++ ++/** Return every TypeScript source file, including tests, while excluding declarations. */ ++export function allTypeScriptFiles(directory: string): string[] { ++ if (!existsSync(directory)) return []; ++ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { ++ const path = join(directory, entry.name); ++ if (entry.isDirectory()) return allTypeScriptFiles(path); ++ return isTypeScriptSource(entry.name) ? [path] : []; ++ }); ++} ++ ++/** Return every tracked or untracked TypeScript source in a repository. */ ++export function repositoryTypeScriptFiles(repoRoot: string): string[] { ++ const root = resolve(repoRoot); ++ const paths = execFileSync( ++ "git", ++ ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], ++ { encoding: "utf8" }, ++ ).split("\0"); ++ return paths.filter(isTypeScriptSource).map((path) => join(root, path)); ++} ++ ++/** Return production TypeScript files while excluding tests and declarations. */ ++export function productionTypeScriptFiles(directory: string): string[] { ++ return allTypeScriptFiles(directory).filter((path) => !/\.test\.(?:ts|tsx|mts|cts)$/.test(path)); ++} ++ ++/** Resolve a relative ESM specifier to its TypeScript source when one exists. */ ++function resolveTypeScriptSpecifier(importer: string, specifier: string) { ++ const base = resolve(join(importer, ".."), specifier); ++ const extension = extname(base); ++ const mapped = ++ extension === ".js" ++ ? [`${base.slice(0, -3)}.ts`, `${base.slice(0, -3)}.tsx`] ++ : extension === ".mjs" ++ ? [`${base.slice(0, -4)}.mts`] ++ : extension === ".cjs" ++ ? [`${base.slice(0, -4)}.cts`] ++ : []; ++ const extensionless = extension ++ ? [] ++ : [".ts", ".tsx", ".mts", ".cts"].map((suffix) => `${base}${suffix}`); ++ const indexes = ["index.ts", "index.tsx", "index.mts", "index.cts"].map((name) => ++ join(base, name), ++ ); ++ return ( ++ [base, ...mapped, ...extensionless, ...indexes].find( ++ (candidate) => existsSync(candidate) && statSync(candidate).isFile(), ++ ) ?? base ++ ); ++} ++ ++/** Reject production edges into tests or other experiment-support trees. */ ++export function forbiddenProductionSupportImports(repoRoot: string) { ++ const root = resolve(repoRoot); ++ const files = [ ++ ...productionTypeScriptFiles(join(root, "packages")), ++ ...productionTypeScriptFiles(join(root, "src")), ++ ]; ++ return files.flatMap((path) => ++ astModuleSpecifiers(path).flatMap(({ specifier, location }) => { ++ if (!specifier.startsWith(".")) return []; ++ const target = resolveTypeScriptSpecifier(path, specifier); ++ const relativeTarget = repoPath(root, target); ++ return /\.test\.(?:ts|tsx|mts|cts)$/.test(relativeTarget) || ++ /^(?:test|docs|scripts)\//.test(relativeTarget) ++ ? [{ ...location, file: repoPath(root, path), specifier, resolved: relativeTarget }] ++ : []; ++ }), ++ ); ++} ++ ++/** Record test names and assertion-call locations for cross-arm anti-weakening checks. */ ++export function analyzeTestContinuity(repoRoot: string, path: string) { ++ const source = parseTypeScript(path); ++ const forbiddenModes: SourceLocationFact[] = []; ++ const tests: Array<{ ++ name: string; ++ line: number; ++ column: number; ++ mode: string; ++ assertions: SourceLocationFact[]; ++ }> = []; ++ const testCall = (node: ts.CallExpression) => { ++ if (ts.isIdentifier(node.expression) && ["test", "it"].includes(node.expression.text)) { ++ return { mode: "normal", nameArgument: node.arguments[0], body: node.arguments[1] }; ++ } ++ if ( ++ ts.isPropertyAccessExpression(node.expression) && ++ ts.isIdentifier(node.expression.expression) && ++ ["test", "it"].includes(node.expression.expression.text) ++ ) { ++ return { ++ mode: node.expression.name.text, ++ nameArgument: node.arguments[0], ++ body: node.arguments[1], ++ }; ++ } ++ return null; ++ }; ++ const assertionName = (node: ts.CallExpression) => { ++ if (ts.isIdentifier(node.expression)) { ++ return /^(?:expect|assert|assertEquals|assertStrictEquals|deepStrictEqual|strictEqual|ok|fail)$/.test( ++ node.expression.text, ++ ) ++ ? node.expression.text ++ : null; ++ } ++ if ( ++ ts.isPropertyAccessExpression(node.expression) && ++ ts.isIdentifier(node.expression.expression) && ++ node.expression.expression.text === "assert" ++ ) { ++ return `assert.${node.expression.name.text}`; ++ } ++ return null; ++ }; ++ const visit = (node: ts.Node) => { ++ if (ts.isCallExpression(node)) { ++ if ( ++ ts.isPropertyAccessExpression(node.expression) && ++ ts.isIdentifier(node.expression.expression) && ++ ["test", "it", "describe"].includes(node.expression.expression.text) && ++ /^(?:skip|todo|only)/.test(node.expression.name.text) ++ ) { ++ forbiddenModes.push(locationFact(repoRoot, path, source, node, node.expression.name.text)); ++ } ++ const declaration = testCall(node); ++ const nameArgument = declaration?.nameArgument; ++ if (declaration && nameArgument && ts.isStringLiteralLike(nameArgument)) { ++ const assertions: SourceLocationFact[] = []; ++ if (declaration.body) { ++ const findAssertions = (child: ts.Node) => { ++ if (ts.isCallExpression(child)) { ++ const name = assertionName(child); ++ if (name) assertions.push(locationFact(repoRoot, path, source, child, name)); ++ } ++ ts.forEachChild(child, findAssertions); ++ }; ++ findAssertions(declaration.body); ++ } ++ const position = source.getLineAndCharacterOfPosition(node.getStart(source)); ++ tests.push({ ++ name: nameArgument.text, ++ line: position.line + 1, ++ column: position.character + 1, ++ mode: declaration.mode, ++ assertions, ++ }); ++ } ++ } ++ ts.forEachChild(node, visit); ++ }; ++ visit(source); ++ return { path: repoPath(repoRoot, path), tests, forbiddenModes }; ++} ++ ++/** Parse one TypeScript module for syntax-aware import and mechanism checks. */ ++export function parseTypeScript(path: string) { ++ return ts.createSourceFile( ++ path, ++ readFileSync(path, "utf8"), ++ ts.ScriptTarget.Latest, ++ true, ++ path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ++ ); ++} ++ ++/** Read real static/dynamic import, export, require, and import-meta resolution specifiers. */ ++export function astModuleSpecifiers(path: string) { ++ const source = parseTypeScript(path); ++ const specifiers: Array<{ specifier: string; location: SourceLocationFact }> = []; ++ const add = (node: ts.Node, value: ts.Expression | undefined, kind: string) => { ++ if (!value || !ts.isStringLiteralLike(value)) return; ++ specifiers.push({ ++ specifier: value.text, ++ location: locationFact(process.cwd(), path, source, node, kind), ++ }); ++ }; ++ const visit = (node: ts.Node) => { ++ if (ts.isImportDeclaration(node)) add(node, node.moduleSpecifier, "import"); ++ if (ts.isExportDeclaration(node)) add(node, node.moduleSpecifier, "export"); ++ if (ts.isCallExpression(node) && node.arguments.length > 0) { ++ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) { ++ add(node, node.arguments[0], "dynamic-import"); ++ } else if (ts.isIdentifier(node.expression) && node.expression.text === "require") { ++ add(node, node.arguments[0], "require"); ++ } else if (ts.isPropertyAccessExpression(node.expression)) { ++ const owner = node.expression.expression; ++ const method = node.expression.name.text; ++ if (ts.isMetaProperty(owner) && method === "resolve") { ++ add(node, node.arguments[0], "import-meta-resolve"); ++ } else if (ts.isIdentifier(owner) && owner.text === "require" && method === "resolve") { ++ add(node, node.arguments[0], "require-resolve"); ++ } else if (ts.isIdentifier(owner) && owner.text === "Bun" && method === "resolveSync") { ++ add(node, node.arguments[0], "bun-resolve-sync"); ++ } ++ } ++ } ++ ts.forEachChild(node, visit); ++ }; ++ visit(source); ++ return specifiers; ++} ++ ++/** Describe one AST node without reading comments or unrelated string literals. */ ++function locationFact( ++ root: string, ++ path: string, ++ source: ts.SourceFile, ++ node: ts.Node, ++ kind: string, ++): SourceLocationFact { ++ const start = node.getStart(source); ++ const position = source.getLineAndCharacterOfPosition(start); ++ return { ++ file: repoPath(root, path), ++ line: position.line + 1, ++ column: position.character + 1, ++ kind, ++ text: node.getText(source).slice(0, 240), ++ }; ++} ++ ++/** Return a class property name without treating computed expressions as ownership names. */ ++function propertyName(member: ts.PropertyDeclaration) { ++ return ts.isIdentifier(member.name) || ts.isStringLiteralLike(member.name) ++ ? member.name.text ++ : member.name.getText(); ++} ++ ++/** Return whether a node is lexically contained by one frozen named function. */ ++function isInsideFunction(node: ts.Node, expectedName: string) { ++ let current: ts.Node | undefined = node.parent; ++ while (current) { ++ if ( ++ (ts.isFunctionDeclaration(current) || ++ ts.isMethodDeclaration(current) || ++ ts.isFunctionExpression(current)) && ++ current.name && ++ ts.isIdentifier(current.name) && ++ current.name.text === expectedName ++ ) { ++ return true; ++ } ++ if ( ++ ts.isArrowFunction(current) && ++ ts.isVariableDeclaration(current.parent) && ++ ts.isIdentifier(current.parent.name) && ++ current.parent.name.text === expectedName ++ ) { ++ return true; ++ } ++ current = current.parent; ++ } ++ return false; ++} ++ ++/** Collect calls with one exact identifier or property-access shape. */ ++function callFacts( ++ root: string, ++ path: string, ++ source: ts.SourceFile, ++ predicate: (call: ts.CallExpression) => boolean, ++ kind: string, ++) { ++ const facts: SourceLocationFact[] = []; ++ const visit = (node: ts.Node) => { ++ if (ts.isCallExpression(node) && predicate(node)) { ++ facts.push(locationFact(root, path, source, node, kind)); ++ } ++ ts.forEachChild(node, visit); ++ }; ++ visit(source); ++ return facts; ++} ++ ++/** Match direct or global timing APIs while ignoring comments, strings, and similarly named methods. */ ++function nativeTimingCall(call: ts.CallExpression) { ++ const expression = call.expression; ++ if (ts.isIdentifier(expression)) { ++ return new Set(["setTimeout", "setInterval", "clearTimeout", "clearInterval"]).has( ++ expression.text, ++ ); ++ } ++ if (!ts.isPropertyAccessExpression(expression)) return false; ++ if ( ++ ts.isIdentifier(expression.expression) && ++ expression.expression.text === "Bun" && ++ expression.name.text === "sleep" ++ ) { ++ return true; ++ } ++ return ( ++ ts.isIdentifier(expression.expression) && ++ new Set(["globalThis", "window"]).has(expression.expression.text) && ++ new Set(["setTimeout", "setInterval", "clearTimeout", "clearInterval"]).has( ++ expression.name.text, ++ ) ++ ); ++} ++ ++/** Analyze frozen Phase 1 ownership mechanics and report source locations for independent review. */ ++export function analyzeTreatmentOwnership(repoRoot: string): TreatmentOwnershipAnalysis { ++ const root = resolve(repoRoot); ++ const paths = { ++ connection: join(root, "packages/session-broker/src/connection.ts"), ++ client: join(root, "src/session/broker/brokerClient.ts"), ++ launcher: join(root, "src/session/broker/brokerLauncher.ts"), ++ lifecycle: join(root, "packages/session-broker/src/processLifecycle.ts"), ++ }; ++ const treatmentPresent = existsSync(paths.lifecycle); ++ const connection = parseTypeScript(paths.connection); ++ const client = parseTypeScript(paths.client); ++ const launcher = parseTypeScript(paths.launcher); ++ const lifecycle = treatmentPresent ? parseTypeScript(paths.lifecycle) : undefined; ++ ++ const connectionNativeTiming = callFacts( ++ root, ++ paths.connection, ++ connection, ++ nativeTimingCall, ++ "native-timing-call", ++ ); ++ const clientNativeTiming = callFacts( ++ root, ++ paths.client, ++ client, ++ nativeTimingCall, ++ "native-timing-call", ++ ); ++ const launcherBunSleep = callFacts( ++ root, ++ paths.launcher, ++ launcher, ++ (call) => ++ ts.isPropertyAccessExpression(call.expression) && ++ ts.isIdentifier(call.expression.expression) && ++ call.expression.expression.text === "Bun" && ++ call.expression.name.text === "sleep", ++ "Bun.sleep", ++ ); ++ ++ const classProperties = ( ++ path: string, ++ source: ts.SourceFile, ++ className: string, ++ ): SourceLocationFact[] => { ++ const facts: SourceLocationFact[] = []; ++ source.forEachChild((node) => { ++ if (!ts.isClassDeclaration(node) || node.name?.text !== className) return; ++ for (const member of node.members) { ++ if (ts.isPropertyDeclaration(member)) { ++ facts.push(locationFact(root, path, source, member, `property:${propertyName(member)}`)); ++ } ++ if (ts.isConstructorDeclaration(member)) { ++ for (const parameter of member.parameters) { ++ const ownsProperty = parameter.modifiers?.some((modifier) => ++ new Set([ ++ ts.SyntaxKind.PrivateKeyword, ++ ts.SyntaxKind.ProtectedKeyword, ++ ts.SyntaxKind.PublicKeyword, ++ ts.SyntaxKind.ReadonlyKeyword, ++ ]).has(modifier.kind), ++ ); ++ if (ownsProperty && ts.isIdentifier(parameter.name)) { ++ facts.push( ++ locationFact(root, path, source, parameter, `property:${parameter.name.text}`), ++ ); ++ } ++ } ++ } ++ } ++ }); ++ return facts; ++ }; ++ const connectionProperties = classProperties( ++ paths.connection, ++ connection, ++ "SessionBrokerConnection", ++ ); ++ const clientProperties = classProperties(paths.client, client, "SessionBrokerClient"); ++ const supervisorProperty = (fact: SourceLocationFact, expected: string, typeName: string) => ++ fact.kind === `property:${expected}` && new RegExp(`\\b${typeName}\\b`).test(fact.text); ++ const delayOwnerProperty = (fact: SourceLocationFact) => ++ /(?:timer|task|fiber|handle|delay)/i.test(fact.kind.replace("property:", "")) || ++ /\b(?:Timeout|Interval|Task|Fiber|Handle)\b/.test(fact.text); ++ const connectionSupervisorProperties = connectionProperties.filter((fact) => ++ supervisorProperty(fact, "connectionSupervisor", "SessionBrokerConnectionSupervisor"), ++ ); ++ const clientSupervisorProperties = clientProperties.filter((fact) => ++ supervisorProperty(fact, "startupSupervisor", "SessionBrokerStartupSupervisor"), ++ ); ++ const connectionDelayOwnerProperties = connectionProperties.filter(delayOwnerProperty); ++ const clientDelayOwnerProperties = clientProperties.filter(delayOwnerProperty); ++ const clientPromiseOwnerProperties = clientProperties.filter( ++ (fact) => /Promise/i.test(fact.kind.replace("property:", "")) || /\bPromise\b/.test(fact.text), ++ ); ++ ++ const processTaskRegistries: SourceLocationFact[] = []; ++ const processImplementationClasses: SourceLocationFact[] = []; ++ const processDelayClasses: SourceLocationFact[] = []; ++ const processSetInterval: SourceLocationFact[] = []; ++ const processTimersOutsideClock: SourceLocationFact[] = []; ++ const managedRuntimeConstruction: SourceLocationFact[] = []; ++ const customClockConstruction: SourceLocationFact[] = []; ++ const fixedHeartbeatSchedule: SourceLocationFact[] = []; ++ const delayedHeartbeatSleep: SourceLocationFact[] = []; ++ if (lifecycle) { ++ const visit = (node: ts.Node) => { ++ if ( ++ ts.isNewExpression(node) && ++ ts.isIdentifier(node.expression) && ++ new Set(["Set", "Map", "WeakMap"]).has(node.expression.text) ++ ) { ++ processTaskRegistries.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "task-registry-construction"), ++ ); ++ } ++ if (ts.isClassDeclaration(node) && node.name) { ++ processImplementationClasses.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, `class:${node.name.text}`), ++ ); ++ if (/(?:Delay|Timer|Task|Fiber|Handle)/.test(node.name.text)) { ++ processDelayClasses.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "per-delay-class"), ++ ); ++ } ++ } ++ if (ts.isCallExpression(node)) { ++ const expression = node.expression; ++ if (ts.isIdentifier(expression) && expression.text === "setInterval") { ++ processSetInterval.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "setInterval"), ++ ); ++ } ++ if ( ++ (ts.isIdentifier(expression) && ++ new Set(["setTimeout", "clearTimeout"]).has(expression.text)) || ++ (ts.isPropertyAccessExpression(expression) && ++ ts.isIdentifier(expression.expression) && ++ expression.expression.text === "globalThis" && ++ new Set(["setTimeout", "clearTimeout"]).has(expression.name.text)) ++ ) { ++ if (!isInsideFunction(node, "createUnrefClock")) { ++ processTimersOutsideClock.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "timer-outside-custom-clock"), ++ ); ++ } ++ } ++ if ( ++ ts.isPropertyAccessExpression(expression) && ++ expression.expression.getText(lifecycle) === "ManagedRuntime" && ++ expression.name.text === "make" ++ ) { ++ managedRuntimeConstruction.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "ManagedRuntime.make"), ++ ); ++ } ++ if (ts.isIdentifier(expression) && expression.text === "createUnrefClock") { ++ customClockConstruction.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "createUnrefClock"), ++ ); ++ } ++ if ( ++ ts.isPropertyAccessExpression(expression) && ++ expression.expression.getText(lifecycle) === "Schedule" && ++ expression.name.text === "fixed" && ++ isInsideFunction(node, "runDelayedFixedHeartbeat") ++ ) { ++ fixedHeartbeatSchedule.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "Schedule.fixed"), ++ ); ++ } ++ if ( ++ ts.isPropertyAccessExpression(expression) && ++ expression.expression.getText(lifecycle) === "Effect" && ++ expression.name.text === "sleep" && ++ isInsideFunction(node, "runDelayedFixedHeartbeat") ++ ) { ++ delayedHeartbeatSleep.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "Effect.sleep"), ++ ); ++ } ++ } ++ ts.forEachChild(node, visit); ++ }; ++ visit(lifecycle); ++ } ++ ++ const runtimeFactoryCalls: SourceLocationFact[] = []; ++ for (const path of [ ++ ...productionTypeScriptFiles(join(root, "packages")), ++ ...productionTypeScriptFiles(join(root, "src")), ++ ]) { ++ const source = parseTypeScript(path); ++ runtimeFactoryCalls.push( ++ ...callFacts( ++ root, ++ path, ++ source, ++ (call) => ++ ts.isIdentifier(call.expression) && ++ call.expression.text === "createSessionBrokerProcessLifecycle", ++ "process-lifecycle-factory-call", ++ ), ++ ); ++ } ++ ++ const locations = { ++ connectionNativeTiming, ++ clientNativeTiming, ++ launcherBunSleep, ++ connectionProperties, ++ clientProperties, ++ connectionSupervisorProperties, ++ clientSupervisorProperties, ++ connectionDelayOwnerProperties, ++ clientDelayOwnerProperties, ++ clientPromiseOwnerProperties, ++ processTaskRegistries, ++ processImplementationClasses, ++ processDelayClasses, ++ processSetInterval, ++ processTimersOutsideClock, ++ managedRuntimeConstruction, ++ customClockConstruction, ++ fixedHeartbeatSchedule, ++ delayedHeartbeatSleep, ++ runtimeFactoryCalls, ++ }; ++ const violations: string[] = []; ++ if (treatmentPresent) { ++ const empty = (name: keyof typeof locations) => { ++ if (locations[name].length > 0) violations.push(`${name} must be empty`); ++ }; ++ for (const name of [ ++ "connectionNativeTiming", ++ "clientNativeTiming", ++ "launcherBunSleep", ++ "connectionDelayOwnerProperties", ++ "clientDelayOwnerProperties", ++ "clientPromiseOwnerProperties", ++ "processTaskRegistries", ++ "processDelayClasses", ++ "processSetInterval", ++ "processTimersOutsideClock", ++ ] as const) { ++ empty(name); ++ } ++ const exactlyOne = (name: keyof typeof locations) => { ++ if (locations[name].length !== 1) ++ violations.push(`${name} must contain exactly one location`); ++ }; ++ for (const name of [ ++ "connectionSupervisorProperties", ++ "clientSupervisorProperties", ++ "managedRuntimeConstruction", ++ "customClockConstruction", ++ "fixedHeartbeatSchedule", ++ "delayedHeartbeatSleep", ++ "runtimeFactoryCalls", ++ ] as const) { ++ exactlyOne(name); ++ } ++ const propertyNames = (facts: readonly SourceLocationFact[]) => ++ facts.map((fact) => fact.kind.replace("property:", "")).sort(); ++ const expectedConnectionProperties = [ ++ "activeSocket", ++ "bridge", ++ "connectionSupervisor", ++ "draining", ++ "executingMessages", ++ "limits", ++ "options", ++ "producerHellos", ++ "queuedCommandByteBudget", ++ "queuedCommandCountBudget", ++ "queuedMessages", ++ "registration", ++ "snapshot", ++ "socket", ++ "stopped", ++ ].sort(); ++ const expectedClientProperties = [ ++ "bridge", ++ "connection", ++ "credentials", ++ "incumbentLaunchFingerprint", ++ "lastConnectionWarning", ++ "registration", ++ "snapshot", ++ "startupSupervisor", ++ "stopped", ++ "timing", ++ "waitingForIncumbentExit", ++ ].sort(); ++ if ( ++ JSON.stringify(propertyNames(connectionProperties)) !== ++ JSON.stringify(expectedConnectionProperties) ++ ) { ++ violations.push("SessionBrokerConnection properties must equal the frozen treatment set"); ++ } ++ if ( ++ JSON.stringify(propertyNames(clientProperties)) !== JSON.stringify(expectedClientProperties) ++ ) { ++ violations.push("SessionBrokerClient properties must equal the frozen treatment set"); ++ } ++ const implementationClasses = processImplementationClasses.map((fact) => fact.kind).sort(); ++ const expectedClasses = [ ++ "class:EffectSessionBrokerConnectionSupervisor", ++ "class:EffectSessionBrokerProcessLifecycle", ++ "class:EffectSessionBrokerStartupSupervisor", ++ ]; ++ if (JSON.stringify(implementationClasses) !== JSON.stringify(expectedClasses)) { ++ violations.push( ++ "processLifecycle.ts must contain exactly the three frozen implementation classes", ++ ); ++ } ++ if (runtimeFactoryCalls[0]?.file !== "src/ui/runInteractiveApp.tsx") { ++ violations.push( ++ "the only process lifecycle factory call must be in src/ui/runInteractiveApp.tsx", ++ ); ++ } ++ } ++ return { treatmentPresent, locations, violations }; ++} +diff --git a/scripts/source-boundaries.test.ts b/scripts/source-boundaries.test.ts +index a0835172..cf73a128 100644 +--- a/scripts/source-boundaries.test.ts ++++ b/scripts/source-boundaries.test.ts +@@ -1,6 +1,22 @@ + import { describe, expect, test } from "bun:test"; +-import { existsSync, readdirSync, readFileSync } from "node:fs"; ++import { ++ existsSync, ++ mkdirSync, ++ mkdtempSync, ++ readdirSync, ++ readFileSync, ++ rmSync, ++ writeFileSync, ++} from "node:fs"; ++import { tmpdir } from "node:os"; + import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; ++import { ++ allTypeScriptFiles, ++ analyzeTreatmentOwnership, ++ astModuleSpecifiers, ++ forbiddenProductionSupportImports, ++ repositoryTypeScriptFiles, ++} from "./session-lifecycle-ownership-analysis"; + + const REPO_ROOT = resolve(import.meta.dir, ".."); + const SRC_ROOT = join(REPO_ROOT, "src"); +@@ -16,6 +32,19 @@ const REVIEW_MODEL_ROOT = join(CORE_ROOT, "review"); + const EXTENSION_API_TYPES_PATH = join(SRC_ROOT, "extension-api", "types.ts"); + const REVIEW_PROTOCOL_PATH = join(SRC_ROOT, "session", "reviewProtocol.ts"); + const WEB_CLIENT_ROOT = join(SRC_ROOT, "web"); ++const SESSION_BROKER_PACKAGE_ROOT = join(REPO_ROOT, "packages", "session-broker"); ++const SESSION_BROKER_PROCESS_LIFECYCLE = join( ++ SESSION_BROKER_PACKAGE_ROOT, ++ "src", ++ "processLifecycle.ts", ++); ++const SESSION_BROKER_BUN_ROOT = join(REPO_ROOT, "packages", "session-broker-bun"); ++const BUN_CONNECTION_FIXTURE = join( ++ REPO_ROOT, ++ "test", ++ "session-broker-runtime", ++ "bun-connection-fixture.ts", ++); + + // Session modules a browser bundle imports verbatim: the wire schema, the HTTP surface's + // addressing and authorization grammar, the SSE event contract, and the user-facing error +@@ -243,6 +272,107 @@ describe("source architecture boundaries", () => { + }); + }); + ++describe("session broker lifecycle experiment boundaries", () => { ++ test("keeps Effect imports isolated to the private process lifecycle implementation", () => { ++ const files = repositoryTypeScriptFiles(REPO_ROOT); ++ const violations = files.flatMap((path) => ++ astModuleSpecifiers(path) ++ .filter(({ specifier }) => specifier === "effect" || specifier.startsWith("effect/")) ++ .filter( ++ () => ++ path !== SESSION_BROKER_PROCESS_LIFECYCLE && ++ path !== join(SESSION_BROKER_PACKAGE_ROOT, "src", "processLifecycle.test.ts"), ++ ) ++ .map(({ specifier, location }) => `${location.file}:${location.line} -> ${specifier}`), ++ ); ++ expect(violations).toEqual([]); ++ }); ++ ++ test("declares exact Effect only in the session-broker package when treatment exists", () => { ++ const packagePaths = [ ++ join(REPO_ROOT, "package.json"), ++ ...readdirSync(join(REPO_ROOT, "packages"), { withFileTypes: true }) ++ .filter((entry) => entry.isDirectory()) ++ .map((entry) => join(REPO_ROOT, "packages", entry.name, "package.json")) ++ .filter(existsSync), ++ ]; ++ const declarations = packagePaths.flatMap((path) => { ++ const value = JSON.parse(readFileSync(path, "utf8")) as Record; ++ return [ ++ "dependencies", ++ "devDependencies", ++ "peerDependencies", ++ "optionalDependencies", ++ ].flatMap((section) => { ++ const effect = (value[section] as Record | undefined)?.effect; ++ return typeof effect === "string" ? [{ path: repoPath(path), section, effect }] : []; ++ }); ++ }); ++ expect(declarations).toEqual( ++ existsSync(SESSION_BROKER_PROCESS_LIFECYCLE) ++ ? [ ++ { ++ path: "packages/session-broker/package.json", ++ section: "dependencies", ++ effect: "3.22.1", ++ }, ++ ] ++ : [], ++ ); ++ }); ++ ++ test("keeps ws resolution out of the Bun adapter and Bun runtime fixture", () => { ++ const files = [ ++ ...allTypeScriptFiles(SESSION_BROKER_BUN_ROOT), ++ ...(existsSync(BUN_CONNECTION_FIXTURE) ? [BUN_CONNECTION_FIXTURE] : []), ++ ]; ++ const violations = files.flatMap((path) => ++ astModuleSpecifiers(path) ++ .filter(({ specifier }) => specifier === "ws" || specifier.startsWith("ws/")) ++ .map(({ specifier, location }) => `${location.file}:${location.line} -> ${specifier}`), ++ ); ++ expect(violations).toEqual([]); ++ }); ++ ++ test("keeps production imports out of tests and experiment-support trees", () => { ++ expect(forbiddenProductionSupportImports(REPO_ROOT)).toEqual([]); ++ }); ++ ++ test("rejects a production import of a test-named Effect implementation", () => { ++ const root = mkdtempSync(join(tmpdir(), "hunk-support-import-boundary-")); ++ try { ++ const sourceRoot = join(root, "src"); ++ mkdirSync(sourceRoot, { recursive: true }); ++ writeFileSync( ++ join(sourceRoot, "runtime.ts"), ++ 'export { lifecycle } from "./runtime.test.js";\n', ++ ); ++ writeFileSync( ++ join(sourceRoot, "runtime.test.ts"), ++ 'import "effect"; export const lifecycle = 1;\n', ++ ); ++ expect(forbiddenProductionSupportImports(root)).toEqual([ ++ expect.objectContaining({ ++ file: "src/runtime.ts", ++ specifier: "./runtime.test.js", ++ resolved: "src/runtime.test.ts", ++ }), ++ ]); ++ } finally { ++ rmSync(root, { force: true, recursive: true }); ++ } ++ }); ++ ++ test("enforces the frozen treatment ownership shape when the lifecycle exists", () => { ++ const analysis = analyzeTreatmentOwnership(REPO_ROOT); ++ if (!analysis.treatmentPresent) { ++ expect(analysis.violations).toEqual([]); ++ return; ++ } ++ expect(analysis.violations).toEqual([]); ++ }); ++}); ++ + // The seam contract for sharing the review experience with a browser surface: the semantic + // review model and its wire protocol are the primitives every consumer (terminal UI, session + // runtime, browser client) builds on, so they must stay renderer-free and platform-neutral. +diff --git a/scripts/test-session-broker-node.ts b/scripts/test-session-broker-node.ts +index b772d3e5..247d5ec1 100644 +--- a/scripts/test-session-broker-node.ts ++++ b/scripts/test-session-broker-node.ts +@@ -6,25 +6,47 @@ import { join } from "node:path"; + async function main() { + const directory = await mkdtemp(join(tmpdir(), "hunk-session-broker-node-")); + const outfile = join(directory, "adapter.mjs"); ++ const connectionFixture = join(directory, "connection-fixture.mjs"); + try { +- const build = await Bun.build({ +- entrypoints: [join(process.cwd(), "packages/session-broker-node/src/index.ts")], +- outdir: directory, +- naming: "adapter.mjs", +- target: "node", +- format: "esm", +- minify: false, +- sourcemap: "none", +- }); +- if (!build.success) { +- for (const log of build.logs) console.error(log); ++ const builds = await Promise.all([ ++ Bun.build({ ++ entrypoints: [join(process.cwd(), "packages/session-broker-node/src/index.ts")], ++ outdir: directory, ++ naming: "adapter.mjs", ++ target: "node", ++ format: "esm", ++ minify: false, ++ sourcemap: "none", ++ }), ++ Bun.build({ ++ entrypoints: [join(process.cwd(), "test/session-broker-node/connection-fixture.ts")], ++ outdir: directory, ++ naming: "connection-fixture.mjs", ++ target: "node", ++ format: "esm", ++ minify: false, ++ sourcemap: "none", ++ }), ++ ]); ++ if (builds.some((build) => !build.success)) { ++ for (const build of builds) { ++ for (const log of build.logs) console.error(log); ++ } + process.exitCode = 1; + return; + } + const child = Bun.spawn({ + cmd: ["node", "--test", "test/session-broker-node/adapter.test.mjs"], + cwd: process.cwd(), +- env: { ...process.env, HUNK_NODE_ADAPTER_BUNDLE: outfile }, ++ env: { ++ ...process.env, ++ HUNK_NODE_ADAPTER_BUNDLE: outfile, ++ HUNK_NODE_CONNECTION_FIXTURE: connectionFixture, ++ HUNK_SESSION_BROKER_NODE_PACKAGE_JSON: join( ++ process.cwd(), ++ "packages/session-broker-node/package.json", ++ ), ++ }, + stdout: "inherit", + stderr: "inherit", + }); +diff --git a/src/session/broker/brokerClient.test.ts b/src/session/broker/brokerClient.test.ts +index ee213dc3..615991f2 100644 +--- a/src/session/broker/brokerClient.test.ts ++++ b/src/session/broker/brokerClient.test.ts +@@ -137,6 +137,78 @@ describe("Hunk session daemon client", () => { + expect(client.getRegistration()).toBe(registration); + }); + ++ test("shares one active first-attempt promise across concurrent starts", async () => { ++ let release!: () => void; ++ const gate = new Promise((resolve) => { ++ release = resolve; ++ }); ++ const client = new SessionBrokerClient(createRegistration(), createSnapshot()); ++ let attempts = 0; ++ (client as any).ensureDaemonAndConnect = async () => { ++ attempts += 1; ++ await gate; ++ }; ++ ++ const first = client.start(); ++ const second = client.start(); ++ expect(first).toBe(second); ++ expect(attempts).toBe(1); ++ ++ client.stop(); ++ release(); ++ await first; ++ }); ++ ++ test("an explicit start bypasses but does not displace the scheduled automatic retry", async () => { ++ const messages: string[] = []; ++ console.error = (...args: unknown[]) => { ++ messages.push(args.map((value) => String(value)).join(" ")); ++ }; ++ const originalSetTimeout = globalThis.setTimeout; ++ const originalClearTimeout = globalThis.clearTimeout; ++ let retryCallback: (() => void) | null = null; ++ const retryHandle = { unref() {} } as ReturnType; ++ const cleared: Array> = []; ++ globalThis.setTimeout = ((callback: () => void) => { ++ retryCallback = callback; ++ return retryHandle; ++ }) as typeof setTimeout; ++ globalThis.clearTimeout = ((handle: ReturnType) => { ++ cleared.push(handle); ++ }) as typeof clearTimeout; ++ ++ const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { ++ reconnectDelayMs: 60_000, ++ }); ++ let attempts = 0; ++ (client as any).ensureDaemonAndConnect = async () => { ++ attempts += 1; ++ if (attempts === 1) throw new Error("first startup failed"); ++ }; ++ ++ try { ++ await client.start(); ++ expect(attempts).toBe(1); ++ expect(messages).toEqual(["[session:broker] first startup failed"]); ++ expect(retryCallback).not.toBeNull(); ++ expect((client as any).reconnectTimer).toBe(retryHandle); ++ ++ await client.start(); ++ expect(attempts).toBe(2); ++ expect((client as any).reconnectTimer).toBe(retryHandle); ++ expect(cleared).toEqual([]); ++ ++ (retryCallback as unknown as () => void)(); ++ await Bun.sleep(0); ++ expect(attempts).toBe(3); ++ expect((client as any).reconnectTimer).toBeNull(); ++ } finally { ++ client.stop(); ++ globalThis.setTimeout = originalSetTimeout; ++ globalThis.clearTimeout = originalClearTimeout; ++ } ++ }); ++ + test("logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", async () => { + process.env.HUNK_MCP_HOST = "0.0.0.0"; + process.env.HUNK_MCP_PORT = "47657"; +@@ -550,12 +622,16 @@ describe("Hunk session daemon client", () => { + expect(attempts).toBe(0); + }); + +- test("does not schedule recovery after stop wins a startup race", async () => { ++ test("does not warn or schedule recovery after stop wins a foreign startup race", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let reconnectScheduled = false; ++ const messages: string[] = []; ++ console.error = (...args: unknown[]) => { ++ messages.push(args.map((value) => String(value)).join(" ")); ++ }; + const client = new SessionBrokerClient(createRegistration(), createSnapshot()); + (client as any).ensureDaemonAndConnect = async () => { + await gate; +@@ -570,6 +646,7 @@ describe("Hunk session daemon client", () => { + release(); + await startup; + expect(reconnectScheduled).toBe(false); ++ expect(messages).toEqual([]); + }); + + test("logs one actionable warning when a non-Hunk listener owns the session daemon port", async () => { +diff --git a/src/session/broker/brokerLauncher.test.ts b/src/session/broker/brokerLauncher.test.ts +index c544d268..9919a3f2 100644 +--- a/src/session/broker/brokerLauncher.test.ts ++++ b/src/session/broker/brokerLauncher.test.ts +@@ -205,6 +205,66 @@ describe("session daemon launcher", () => { + }); + }); + ++ test("releases an acquired launch lock when the protected health check rejects", async () => { ++ const runtimeDir = createRuntimeDir(); ++ const env = { ...process.env, XDG_RUNTIME_DIR: runtimeDir }; ++ const failure = new Error("health fixture failed"); ++ let healthCalls = 0; ++ ++ await expect( ++ ensureSessionBrokerAvailable({ ++ config: testConfig, ++ env, ++ timeoutMs: 100, ++ intervalMs: 5, ++ isHealthy: async () => { ++ healthCalls += 1; ++ if (healthCalls === 1) return false; ++ throw failure; ++ }, ++ isPortReachable: async () => false, ++ launchDaemon: () => { ++ throw new Error("launch must not run"); ++ }, ++ }), ++ ).rejects.toBe(failure); ++ ++ const paths = resolveSessionBrokerRuntimePaths(testConfig, env); ++ expect(healthCalls).toBe(2); ++ expect(existsSync(paths.lockPath)).toBe(false); ++ }); ++ ++ test("gives a lock owner one fresh full health-poll timeout after the outer deadline", async () => { ++ const runtimeDir = createRuntimeDir(); ++ const env = { ...process.env, XDG_RUNTIME_DIR: runtimeDir }; ++ let healthCalls = 0; ++ const startedAt = performance.now(); ++ ++ await expect( ++ ensureSessionBrokerAvailable({ ++ config: testConfig, ++ env, ++ timeoutMs: 10, ++ intervalMs: 1, ++ isHealthy: async () => { ++ healthCalls += 1; ++ await Bun.sleep(12); ++ return false; ++ }, ++ isPortReachable: async () => false, ++ launchDaemon: () => ({ pid: process.pid }) as ChildProcess, ++ }), ++ ).rejects.toThrow("Timed out waiting for the session broker daemon"); ++ ++ const elapsedMs = performance.now() - startedAt; ++ const paths = resolveSessionBrokerRuntimePaths(testConfig, env); ++ // Initial health plus the protected recheck already exhaust the outer 10ms deadline. A third ++ // call proves the lock owner still receives its separate full polling window. ++ expect(healthCalls).toBeGreaterThanOrEqual(3); ++ expect(elapsedMs).toBeGreaterThanOrEqual(30); ++ expect(existsSync(paths.lockPath)).toBe(false); ++ }); ++ + test("recovers a stale launch lock from a dead launcher and overwrites stale metadata", async () => { + const runtimeDir = createRuntimeDir(); + const env = { ...process.env, XDG_RUNTIME_DIR: runtimeDir }; +diff --git a/test/session-broker-node/adapter.test.mjs b/test/session-broker-node/adapter.test.mjs +index 90f8d943..024d5502 100644 +--- a/test/session-broker-node/adapter.test.mjs ++++ b/test/session-broker-node/adapter.test.mjs +@@ -1,4 +1,5 @@ + import assert from "node:assert/strict"; ++import { spawn } from "node:child_process"; + import { readFile } from "node:fs/promises"; + import { connect, createServer } from "node:net"; + import { createRequire } from "node:module"; +@@ -19,6 +20,32 @@ const corpus = JSON.parse( + const bundlePath = process.env.HUNK_NODE_ADAPTER_BUNDLE; + if (!bundlePath) throw new Error("HUNK_NODE_ADAPTER_BUNDLE must name the built Node adapter."); + const { serveSessionBrokerDaemon } = await import(pathToFileURL(bundlePath).href); ++const connectionFixture = process.env.HUNK_NODE_CONNECTION_FIXTURE; ++if (!connectionFixture) { ++ throw new Error("HUNK_NODE_CONNECTION_FIXTURE must name the built producer fixture."); ++} ++ ++async function runConnectionFixture(args) { ++ return new Promise((resolve, reject) => { ++ const child = spawn(process.execPath, [connectionFixture, ...args], { ++ env: process.env, ++ stdio: ["ignore", "pipe", "pipe"], ++ }); ++ let stdout = ""; ++ let stderr = ""; ++ child.stdout.on("data", (chunk) => (stdout += chunk)); ++ child.stderr.on("data", (chunk) => (stderr += chunk)); ++ const timeout = setTimeout(() => { ++ child.kill(); ++ reject(new Error(`Timed out waiting for connection fixture: ${args.join(" ")}`)); ++ }, 2_000); ++ child.once("error", reject); ++ child.once("exit", (exitCode) => { ++ clearTimeout(timeout); ++ resolve({ exitCode, stdout, stderr }); ++ }); ++ }); ++} + + async function reservePort() { + const server = createServer(); +@@ -117,6 +144,25 @@ function fakeDaemon(overrides = {}, behavior = {}) { + }; + } + ++test("Node producer authenticates, registers, and leaves maintenance time unref'ed", async () => { ++ const real = await runConnectionFixture(["real"]); ++ assert.deepEqual(real, { ++ exitCode: 0, ++ stdout: "signed-producer-register-observed\n", ++ stderr: "", ++ }); ++ ++ const ready = await runConnectionFixture(["lifecycle-ready"]); ++ assert.equal(ready.exitCode, 0); ++ assert.equal(ready.stderr, ""); ++ assert.match(ready.stdout, /^lifecycle-ready:(?:control|treatment)\n$/); ++ ++ for (const mode of ["unref-handshake", "unref-heartbeat", "unref-reconnect"]) { ++ const result = await runConnectionFixture([mode]); ++ assert.deepEqual(result, { exitCode: 0, stdout: `${mode}\n`, stderr: "" }); ++ } ++}); ++ + test("Node WebCrypto Ed25519 and base64url work without Bun globals", async () => { + assert.equal(typeof globalThis.Bun, "undefined"); + const keys = await crypto.subtle.generateKey("Ed25519", false, ["sign", "verify"]); +diff --git a/test/session-broker-node/connection-fixture.ts b/test/session-broker-node/connection-fixture.ts +new file mode 100644 +index 00000000..86911f2d +--- /dev/null ++++ b/test/session-broker-node/connection-fixture.ts +@@ -0,0 +1,22 @@ ++import { createRequire } from "node:module"; ++import type { SessionBrokerDaemon, SessionBrokerSocketLike } from "@hunk/session-broker"; ++import { serveSessionBrokerDaemon } from "@hunk/session-broker-node"; ++import { ++ runConnectionFixture, ++ type ConnectionFixtureMode, ++} from "../session-broker-runtime/connectionFixture"; ++ ++const mode = process.argv[2] as ConnectionFixtureMode; ++const packageJson = process.env.HUNK_SESSION_BROKER_NODE_PACKAGE_JSON; ++if (!packageJson) throw new TypeError("HUNK_SESSION_BROKER_NODE_PACKAGE_JSON is required."); ++const require = createRequire(packageJson); ++const WebSocket = require("ws") as new (url: string) => SessionBrokerSocketLike; ++ ++await runConnectionFixture({ ++ mode, ++ createSocket: (target) => new WebSocket(target), ++ startDaemon: async (daemon: SessionBrokerDaemon, port: number) => { ++ const running = await serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); ++ return running; ++ }, ++}); +diff --git a/test/session-broker-runtime/bun-connection-fixture.ts b/test/session-broker-runtime/bun-connection-fixture.ts +new file mode 100644 +index 00000000..404819ed +--- /dev/null ++++ b/test/session-broker-runtime/bun-connection-fixture.ts +@@ -0,0 +1,17 @@ ++import type { SessionBrokerDaemon, SessionBrokerSocketLike } from "@hunk/session-broker"; ++import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun"; ++import { runConnectionFixture, type ConnectionFixtureMode } from "./connectionFixture"; ++ ++const mode = process.argv[2] as ConnectionFixtureMode; ++ ++await runConnectionFixture({ ++ mode, ++ createSocket: (target) => new WebSocket(target) as unknown as SessionBrokerSocketLike, ++ startDaemon: async (daemon: SessionBrokerDaemon, port: number) => { ++ const running = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); ++ return { ++ stop: () => running.stop(true), ++ stopped: running.stopped, ++ }; ++ }, ++}); +diff --git a/test/session-broker-runtime/connectionFixture.ts b/test/session-broker-runtime/connectionFixture.ts +new file mode 100644 +index 00000000..f1862e4c +--- /dev/null ++++ b/test/session-broker-runtime/connectionFixture.ts +@@ -0,0 +1,295 @@ ++import { createServer } from "node:net"; ++import * as sessionBroker from "@hunk/session-broker"; ++import { ++ SESSION_BROKER_REGISTRATION_VERSION, ++ SESSION_BROKER_SIGNATURE_ALGORITHM, ++ SessionBroker, ++ SessionBrokerAuthenticator, ++ createSessionBrokerConnection, ++ createSessionBrokerDaemon, ++ createSessionBrokerProtocolParsers, ++ type ProducerGrant, ++ type SessionBrokerDaemon, ++ type SessionBrokerSocketLike, ++ type SessionRegistration, ++ type SessionSnapshot, ++} from "@hunk/session-broker"; ++ ++interface ProcessLifecycle { ++ close(): void; ++ readonly settled?: Promise; ++} ++ ++interface RunningDaemon { ++ stop(): void | Promise; ++ readonly stopped: Promise; ++} ++ ++interface LifecycleFactoryOptions { ++ readonly onDefect?: (message: string) => void; ++} ++ ++class FixtureSocket implements SessionBrokerSocketLike { ++ readyState = 0; ++ onopen: (() => void) | null = null; ++ onmessage: ((event: { data: unknown }) => void) | null = null; ++ onclose: ((event: { code: number; reason: string }) => void) | null = null; ++ onerror: (() => void) | null = null; ++ ++ send(_data: string) {} ++ ++ close(code = 1000, reason = "") { ++ this.readyState = 3; ++ this.onclose?.({ code, reason }); ++ } ++ ++ open() { ++ this.readyState = 1; ++ this.onopen?.(); ++ } ++} ++ ++const protocolParsers = createSessionBrokerProtocolParsers({ ++ appRevision: 1, ++ features: [], ++ parseRegistration: (value) => value as SessionRegistration, ++ parseSnapshot: (value) => value as SessionSnapshot, ++ commands: [], ++}); ++ ++/** Call the treatment factory when exported and reject a present-but-invalid treatment façade. */ ++function createDetectedProcessLifecycle(): ProcessLifecycle | undefined { ++ const factory = ( ++ sessionBroker as typeof sessionBroker & { ++ createSessionBrokerProcessLifecycle?: ( ++ options?: LifecycleFactoryOptions, ++ ) => ProcessLifecycle | undefined; ++ } ++ ).createSessionBrokerProcessLifecycle; ++ if (!factory) return undefined; ++ const lifecycle = factory({ ++ onDefect: (message) => console.error(message), ++ }); ++ if (!lifecycle || typeof lifecycle.close !== "function") { ++ throw new TypeError("The treatment lifecycle factory did not return a lifecycle."); ++ } ++ return lifecycle; ++} ++ ++/** Localize the control/treatment option difference without weakening production declarations. */ ++function createFixtureConnection(options: Record) { ++ const factory = createSessionBrokerConnection as unknown as ( ++ value: Record, ++ ) => ReturnType; ++ return factory(options); ++} ++ ++function createOptions( ++ createSocket: (url: string) => SessionBrokerSocketLike, ++ lifecycle?: ProcessLifecycle, ++) { ++ return { ++ url: "ws://127.0.0.1/session", ++ createSocket, ++ registration: { ++ registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, ++ sessionId: "runtime-fixture", ++ pid: process.pid, ++ cwd: process.cwd(), ++ launchedAt: new Date(0).toISOString(), ++ info: {}, ++ }, ++ snapshot: { ++ updatedAt: new Date(0).toISOString(), ++ state: {}, ++ }, ++ protocolParsers, ++ heartbeatIntervalMs: 60_000, ++ reconnectDelayMs: 60_000, ++ ...(lifecycle ? { lifecycle } : {}), ++ }; ++} ++ ++/** Reserve one loopback port without passing credentials across a process boundary. */ ++async function reservePort() { ++ const server = createServer(); ++ await new Promise((resolve, reject) => { ++ server.once("error", reject); ++ server.listen(0, "127.0.0.1", resolve); ++ }); ++ const address = server.address(); ++ const port = typeof address === "object" && address ? address.port : 0; ++ await new Promise((resolve) => server.close(() => resolve())); ++ return port; ++} ++ ++/** Generate one daemon identity and one independently signed producer grant in-process. */ ++async function createSignedProducerAuthentication(endpoint: string) { ++ const producerPair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const daemonPair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const grant: ProducerGrant = { ++ kind: "producer", ++ appId: "dev.example", ++ principalId: "runtime-fixture", ++ keyId: "runtime-fixture-key", ++ grantId: "runtime-fixture-grant", ++ algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, ++ issuedAt: Date.now() - 1_000, ++ expiresAt: Date.now() + 60_000, ++ revocationId: "runtime-fixture-revocation", ++ mayDelegate: false, ++ operations: ["register", "reconnect"], ++ }; ++ const authenticator = new SessionBrokerAuthenticator({ ++ appId: grant.appId, ++ appRevision: 1, ++ generation: "runtime-fixture-generation", ++ daemonIdentity: { ++ keyId: "runtime-fixture-daemon", ++ privateKey: daemonPair.privateKey, ++ }, ++ credentials: [{ grant, publicKey: producerPair.publicKey }], ++ }); ++ return { ++ authenticator, ++ connection: { ++ appId: grant.appId, ++ appRevision: 1, ++ credential: { grant, privateKey: producerPair.privateKey }, ++ daemon: { ++ keyId: "runtime-fixture-daemon", ++ publicKey: daemonPair.publicKey, ++ }, ++ }, ++ endpoint, ++ }; ++} ++ ++/** Wait until the actual broker has accepted the register message after signed hello. */ ++async function waitForRegistration(broker: { getSessionIds(): readonly string[] }) { ++ const deadline = Date.now() + 2_000; ++ while (Date.now() < deadline) { ++ if (broker.getSessionIds().includes("runtime-fixture")) return; ++ await new Promise((resolve) => setTimeout(resolve, 5)); ++ } ++ throw new Error("Timed out waiting for the signed producer register message."); ++} ++ ++/** Exercise signed hello and registration through one runtime's native daemon and socket adapters. */ ++async function runSignedConnection({ ++ createSocket, ++ startDaemon, ++}: { ++ createSocket: (url: string) => SessionBrokerSocketLike; ++ startDaemon: (daemon: SessionBrokerDaemon, port: number) => Promise; ++}) { ++ const lifecycle = createDetectedProcessLifecycle(); ++ const port = await reservePort(); ++ const endpoint = `ws://127.0.0.1:${port}/session`; ++ const authentication = await createSignedProducerAuthentication(endpoint); ++ const broker = new SessionBroker({ protocolParsers }); ++ const daemon = createSessionBrokerDaemon({ ++ broker, ++ appId: "dev.example", ++ producerEndpoint: endpoint, ++ helloAuthenticator: authentication.authenticator, ++ idleTimeoutMs: 0, ++ }); ++ const running = await startDaemon(daemon, port); ++ const connection = createFixtureConnection({ ++ ...createOptions(createSocket, lifecycle), ++ url: endpoint, ++ producerAuthentication: authentication.connection, ++ }); ++ try { ++ connection.start(); ++ await waitForRegistration(broker); ++ console.log("signed-producer-register-observed"); ++ } finally { ++ connection.stop(); ++ lifecycle?.close(); ++ await lifecycle?.settled; ++ await running.stop(); ++ await running.stopped; ++ } ++} ++ ++/** Create a valid producer credential that intentionally leaves its handshake incomplete. */ ++async function createPendingProducerAuthentication() { ++ const pair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const grant: ProducerGrant = { ++ kind: "producer", ++ appId: "dev.example", ++ principalId: "runtime-fixture", ++ keyId: "runtime-fixture-key", ++ grantId: "runtime-fixture-grant", ++ algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, ++ issuedAt: Date.now() - 1_000, ++ expiresAt: Date.now() + 60_000, ++ revocationId: "runtime-fixture-revocation", ++ mayDelegate: false, ++ operations: ["register"], ++ }; ++ return { ++ appId: grant.appId, ++ appRevision: 1, ++ credential: { grant, privateKey: pair.privateKey }, ++ daemon: { keyId: "runtime-fixture-daemon", publicKey: pair.publicKey }, ++ }; ++} ++ ++export type ConnectionFixtureMode = ++ | "real" ++ | "lifecycle-ready" ++ | "unref-handshake" ++ | "unref-heartbeat" ++ | "unref-reconnect"; ++ ++/** Exercise producer lifecycle behavior in a standalone runtime process. */ ++export async function runConnectionFixture({ ++ mode, ++ createSocket, ++ startDaemon, ++}: { ++ mode: ConnectionFixtureMode; ++ createSocket: (url: string) => SessionBrokerSocketLike; ++ startDaemon: (daemon: SessionBrokerDaemon, port: number) => Promise; ++}) { ++ if (mode === "real") { ++ await runSignedConnection({ createSocket, startDaemon }); ++ return; ++ } ++ ++ if (mode === "lifecycle-ready") { ++ const lifecycle = createDetectedProcessLifecycle(); ++ console.log(lifecycle ? "lifecycle-ready:treatment" : "lifecycle-ready:control"); ++ lifecycle?.close(); ++ await lifecycle?.settled; ++ return; ++ } ++ ++ const lifecycle = createDetectedProcessLifecycle(); ++ const socket = new FixtureSocket(); ++ const options = createOptions(() => socket, lifecycle); ++ const connection = ++ mode === "unref-handshake" ++ ? createFixtureConnection({ ++ ...options, ++ producerAuthentication: await createPendingProducerAuthentication(), ++ }) ++ : createFixtureConnection(options); ++ connection.start(); ++ socket.open(); ++ if (mode === "unref-reconnect") socket.close(1006, "retry"); ++ console.log(mode); ++ // Do not stop: the process proves that maintenance sleeps and the treatment root are unref-aware. ++} diff --git a/research/session-broker-effect-lifecycle/artifacts/control/original-MANIFEST.attestation.sha256 b/research/session-broker-effect-lifecycle/artifacts/control/original-MANIFEST.attestation.sha256 new file mode 100644 index 000000000..dec1aedb1 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/original-MANIFEST.attestation.sha256 @@ -0,0 +1 @@ +7b0ecea5b2d133424c0fbf680a58914af73de09c5ac602a148f66520b2ff52b2 MANIFEST.sha256 diff --git a/research/session-broker-effect-lifecycle/artifacts/control/original-MANIFEST.sha256 b/research/session-broker-effect-lifecycle/artifacts/control/original-MANIFEST.sha256 new file mode 100644 index 000000000..78ef00263 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/original-MANIFEST.sha256 @@ -0,0 +1,49 @@ +09ecfba2f4587c0290e0a7a2f94eb478d5199d4872a15c8acd963dabb6be34cf declarations/authentication.d.ts +0a32cd16e7af75d0664249183f2afe51f95b062e23527b614c0a3ddd36dc5751 declarations/clientAuthentication.d.ts +1295cbf7ec22d91b728ac5408a20fddc8a52ad7d680729e67e807352e530fc58 canary.json +193fa56a8d511371d42d63faee8c2c554862a1d91c5ec8c4f831948310ff290b declarations/connection.d.ts +2394e1487599a89120a0f36e73fd68f5e73c230647d2484f985679c65fa850a5 builds/session-broker-node-1.js +2394e1487599a89120a0f36e73fd68f5e73c230647d2484f985679c65fa850a5 builds/session-broker-node-2.js +2394e1487599a89120a0f36e73fd68f5e73c230647d2484f985679c65fa850a5 builds/session-broker-node-3.js +28ee15b4b02724041782170d73c84205ab5c406ca873f3b093184b5a284fe7cd result.json +43ce517587b7e67ec1c26e07e00e75d17058ea52eddbad5865314e5dfe36740e bun-runtime-fixture/bun-connection-fixture.js +453e03a8b20ca775ec55803bcfacb05909174e9c3bd39ad5f5b9fcab99479524 commands/compiled-hunk-3.stdout.log +50956a2c27b1a442408131f86103ff1df8a7f4a29502df81d216ba19e2c2f4a6 commands/system-node-version.stdout.log +55d4b062345736d3d10cb6fcde722a19a1bfbcccd3b1594bf8f08164fc9a9c9d declarations/broker.d.ts +56a6137a358617a9dcc83455078dc519ffa2f8233d047b03e008f5232ac4ae44 commands/compiled-hunk-1.stdout.log +5a2c7eff57808daef35c98eb9ad7e8c25b9d763877148fa9671fcfa26fb9f2c0 declarations/crypto.d.ts +5a3b339b2a27ff346013ad224650a01f69fd75be4b9a4f6067a95cdbb5c22336 validation/lint.stdout.log +669dc6aa0f02359c38e4aacd4653dab93bfe7b10cafa086477b8f2e6974c2da0 validation/focused.stderr.log +6b0c84346bfabf45490860f39089f4d427f9366e439fc2129edcd9fef9ac0d0c declarations/daemon.d.ts +70a9db88f4d0962ba72ae7ef8d5999b0ea4355ccda2e816a84ecd4ba4d6e454e validation/real-node.stderr.log +7533f724866b3715b0f2727a9db077409d51dd75866255a69aa007c8a4c1c85e commands/compiled-hunk-2.stdout.log +780a54aae35df7d39ed1bb75e824e92aaa3540184b161029e23a89686acd374a validation/manifest.json +78c9ba4b5461c552fe43e52904d0eaf6f5ddef47588cf477ea2a697c44182351 validation/dependencies.stderr.log +78d6558d0470a3117c9e690c3807d1f2dc4ee374de11b7f481af7377b2391bb9 ownership-inventory.json +8366207267355d3e3d5bf3bf6e8c94c5f93f6078c34f08973fa2b38cdda6cc92 validation/typecheck.stderr.log +8463b7aa889245684ee1f08d67bb034aea0d77df40a8c3390410cd437a424475 semantics.json +8d4128ca3fda4bc33627695431abc1f23ed737253e315c6c4dbb016f8935e61a validation/focused.stdout.log +915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc commands/compiled-hunk-1.stderr.log +915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc commands/compiled-hunk-2.stderr.log +915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc commands/compiled-hunk-3.stderr.log +91991838cfca6d9b09120c41dbfb47bd2edacb6d89b4812dde3f6d4627174336 validation/dependencies.stdout.log +ae4d22dad031138c8d581a94ee80023563d83af4a1ed4cd85749f81c0dacdfb3 validation/full-unit.stdout.log +b22fb8bc56d89884bb55ead90195bd981eb73111e72da5a9082dd367827f309e validation/pty-integration.stderr.log +b2dff80d5fca1f1286fa77107660bc4380492aa10e2be93e185856f21c3993fd validation/lint.stderr.log +b32cf5d0af4ac94f56296a7f85e4a97dac94e2720ab1f8a31fe5b0616a5b0de9 validation/full-unit.stderr.log +b89de9f5fdce27371ecd9d8e92cc7be194a3daa49ecea45e4a235dbebd680264 declarations/index.d.ts +c412adae08de072313bd2dc70bbd65661becc5b7b15b7b20da875bb2757fe7f6 declarations/protocolParsers.d.ts +d6684989b8dd63b37d2f1954270826fbe1bd89a8debd12bcacf03ed1c6140ef6 validation/pty-integration.stdout.log +d78b3e4580194ea5aaca1d894117efe0688990d2ee28dbb4e6fa799ed13072a5 source-identity.json +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 commands/dependency-tree.stderr.log +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 commands/public-declarations.stderr.log +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 commands/public-declarations.stdout.log +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 commands/system-node-version.stderr.log +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 validation/typecheck.stdout.log +e7786e2376a7b7b1622c4a6654deac082ddb8723a55f3edb2c7b59c8720738ca commands/dependency-tree.stdout.log +e7868ae3e9436d1a4ded6853297632fc1f15ea275c17d06553054d354e2c300e builds/session-broker-bun-1.js +e7868ae3e9436d1a4ded6853297632fc1f15ea275c17d06553054d354e2c300e builds/session-broker-bun-2.js +e7868ae3e9436d1a4ded6853297632fc1f15ea275c17d06553054d354e2c300e builds/session-broker-bun-3.js +e7b9a0c6e639841fe65532ebf1cd3c9ce5a14e688b9c3fe28cf1c53288674757 complete-experiment.patch +f2566a0e01bee8c67c96fcf73a76fcbf671b5120be081249b121a055ec105e4f validation/real-node.stdout.log +fd559cb4cafe71a7ffc3766a551f71ddc075674ae0c7073b6cad4db7ada9987f declarations/types.d.ts diff --git a/research/session-broker-effect-lifecycle/artifacts/control/ownership-inventory.json b/research/session-broker-effect-lifecycle/artifacts/control/ownership-inventory.json new file mode 100644 index 000000000..06b886279 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/ownership-inventory.json @@ -0,0 +1,351 @@ +{ + "schemaVersion": 3, + "label": "control", + "treatmentPresent": false, + "locations": { + "connectionNativeTiming": [ + { + "file": "packages/session-broker/src/connection.ts", + "line": 179, + "column": 7, + "kind": "native-timing-call", + "text": "clearTimeout(this.reconnectTimer)" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 186, + "column": 27, + "kind": "native-timing-call", + "text": "clearTimeout(handshakeTimer)" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 239, + "column": 21, + "kind": "native-timing-call", + "text": "setTimeout(() => {\n socket.close(1008, \"Session broker authentication timed out.\");\n }, this.limits.maxHandshakeDurationMs)" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 297, + "column": 27, + "kind": "native-timing-call", + "text": "clearTimeout(handshakeTimer)" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 341, + "column": 25, + "kind": "native-timing-call", + "text": "clearTimeout(handshakeTimer)" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 394, + "column": 27, + "kind": "native-timing-call", + "text": "setTimeout(() => {\n this.reconnectTimer = null;\n void this.prepareAndReconnect();\n }, delayMs)" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 421, + "column": 27, + "kind": "native-timing-call", + "text": "setInterval(() => {\n this.send({\n type: \"heartbeat\",\n sessionId: this.registration.sessionId,\n });\n }, this.options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS)" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 436, + "column": 5, + "kind": "native-timing-call", + "text": "clearInterval(this.heartbeatTimer)" + } + ], + "clientNativeTiming": [ + { + "file": "src/session/broker/brokerClient.ts", + "line": 119, + "column": 7, + "kind": "native-timing-call", + "text": "clearTimeout(this.reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 247, + "column": 27, + "kind": "native-timing-call", + "text": "setTimeout(() => {\n this.reconnectTimer = null;\n this.start();\n }, delayMs)" + } + ], + "launcherBunSleep": [ + { + "file": "src/session/broker/brokerLauncher.ts", + "line": 293, + "column": 11, + "kind": "Bun.sleep", + "text": "Bun.sleep(intervalMs)" + } + ], + "connectionProperties": [ + { + "file": "packages/session-broker/src/connection.ts", + "line": 113, + "column": 3, + "kind": "property:socket", + "text": "private socket: Socket | null = null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 114, + "column": 3, + "kind": "property:activeSocket", + "text": "private activeSocket: Socket | null = null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 115, + "column": 3, + "kind": "property:bridge", + "text": "private bridge: SessionBrokerConnectionBridge | null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 116, + "column": 3, + "kind": "property:limits", + "text": "readonly limits: Readonly;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 118, + "column": 3, + "kind": "property:queuedMessages", + "text": "private queuedMessages: Array> = [];" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 119, + "column": 3, + "kind": "property:executingMessages", + "text": "private executingMessages = new Set>();" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 120, + "column": 3, + "kind": "property:queuedCommandCountBudget", + "text": "private readonly queuedCommandCountBudget: ResourceBudget;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 121, + "column": 3, + "kind": "property:queuedCommandByteBudget", + "text": "private readonly queuedCommandByteBudget: ResourceBudget;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 122, + "column": 3, + "kind": "property:draining", + "text": "private draining = false;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 123, + "column": 3, + "kind": "property:reconnectTimer", + "text": "private reconnectTimer: ReturnType | null = null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 124, + "column": 3, + "kind": "property:heartbeatTimer", + "text": "private heartbeatTimer: ReturnType | null = null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 125, + "column": 3, + "kind": "property:stopped", + "text": "private stopped = false;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 126, + "column": 3, + "kind": "property:registration", + "text": "private registration: SessionRegistration;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 127, + "column": 3, + "kind": "property:snapshot", + "text": "private snapshot: SessionSnapshot;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 128, + "column": 3, + "kind": "property:handshakeTimers", + "text": "private readonly handshakeTimers = new WeakMap>();" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 129, + "column": 3, + "kind": "property:producerHellos", + "text": "private readonly producerHellos = new WeakMap<\n Socket,\n {\n request: SessionBrokerHelloChallengeRequest;\n pending?: PendingSessionBrokerHello | null;\n }\n >();" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 138, + "column": 5, + "kind": "property:options", + "text": "private readonly options: SessionBrokerConnectionOptions<\n Info,\n State,\n Socket,\n ServerMessage,\n Result\n >" + } + ], + "clientProperties": [ + { + "file": "src/session/broker/brokerClient.ts", + "line": 69, + "column": 3, + "kind": "property:connection", + "text": "private connection: GenericSessionBrokerConnection<\n HunkSessionInfo,\n HunkSessionState,\n SessionBrokerSocketLike,\n HunkSessionServerMessage,\n HunkSessionCommandResult\n > | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 76, + "column": 3, + "kind": "property:bridge", + "text": "private bridge: SessionAppBridge | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 77, + "column": 3, + "kind": "property:reconnectTimer", + "text": "private reconnectTimer: ReturnType | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 78, + "column": 3, + "kind": "property:stopped", + "text": "private stopped = false;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 79, + "column": 3, + "kind": "property:startupPromise", + "text": "private startupPromise: Promise | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 80, + "column": 3, + "kind": "property:lastConnectionWarning", + "text": "private lastConnectionWarning: string | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 81, + "column": 3, + "kind": "property:credentials", + "text": "private credentials: HunkSessionBrokerCredentials | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 82, + "column": 3, + "kind": "property:waitingForIncumbentExit", + "text": "private waitingForIncumbentExit = false;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 83, + "column": 3, + "kind": "property:incumbentLaunchFingerprint", + "text": "private incumbentLaunchFingerprint: string | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 86, + "column": 5, + "kind": "property:registration", + "text": "private registration: SessionRegistration" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 87, + "column": 5, + "kind": "property:snapshot", + "text": "private snapshot: SessionSnapshot" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 88, + "column": 5, + "kind": "property:timing", + "text": "private timing: SessionBrokerClientTiming = {}" + } + ], + "connectionSupervisorProperties": [], + "clientSupervisorProperties": [], + "connectionDelayOwnerProperties": [ + { + "file": "packages/session-broker/src/connection.ts", + "line": 123, + "column": 3, + "kind": "property:reconnectTimer", + "text": "private reconnectTimer: ReturnType | null = null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 124, + "column": 3, + "kind": "property:heartbeatTimer", + "text": "private heartbeatTimer: ReturnType | null = null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 128, + "column": 3, + "kind": "property:handshakeTimers", + "text": "private readonly handshakeTimers = new WeakMap>();" + } + ], + "clientDelayOwnerProperties": [ + { + "file": "src/session/broker/brokerClient.ts", + "line": 77, + "column": 3, + "kind": "property:reconnectTimer", + "text": "private reconnectTimer: ReturnType | null = null;" + } + ], + "clientPromiseOwnerProperties": [ + { + "file": "src/session/broker/brokerClient.ts", + "line": 79, + "column": 3, + "kind": "property:startupPromise", + "text": "private startupPromise: Promise | null = null;" + } + ], + "processTaskRegistries": [], + "processImplementationClasses": [], + "processDelayClasses": [], + "processSetInterval": [], + "processTimersOutsideClock": [], + "managedRuntimeConstruction": [], + "customClockConstruction": [], + "fixedHeartbeatSchedule": [], + "delayedHeartbeatSleep": [], + "runtimeFactoryCalls": [] + }, + "violations": [] +} diff --git a/research/session-broker-effect-lifecycle/artifacts/control/result.json b/research/session-broker-effect-lifecycle/artifacts/control/result.json new file mode 100644 index 000000000..4c46c7ec7 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/result.json @@ -0,0 +1,1721 @@ +{ + "schemaVersion": 3, + "label": "control", + "recordedAt": "2026-08-31T05:15:39.344Z", + "environment": { + "head": "42d2b9dd2f3144e33080f159f4cc9c2824ad1708", + "bun": "1.3.14", + "embeddedNode": "24.3.0", + "systemNodeTranscript": "tmp/session-lifecycle-effect/control/commands/system-node-version.stdout.log", + "os": "linux", + "release": "7.1.8-arch1-3", + "arch": "x64", + "cpu": "AMD RYZEN AI MAX+ 395 w/ Radeon 8060S" + }, + "identities": { + "harnessSha256": "ab809ae95145a41fa9e3d4f4de1173582b50f425531c88d33be097c44c6f9d85", + "planSha256": "9f9ffdd2068e2a02f6f4380d8928105e1cd592b2b43f955855b149ed42633cf5", + "lockfileSha256": "b2b43bcbac57d09213916d9bec04f883f06e482074dd754fde5b8f8bbd379bd9", + "firstExperimentPatchSha256": "f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56" + }, + "builds": [ + { + "target": "bun", + "index": 1, + "retained": true, + "durationMs": 3.7104660000186414, + "bytes": 90116, + "sha256": "e7868ae3e9436d1a4ded6853297632fc1f15ea275c17d06553054d354e2c300e" + }, + { + "target": "node", + "index": 1, + "retained": true, + "durationMs": 2.110392000002321, + "bytes": 90118, + "sha256": "2394e1487599a89120a0f36e73fd68f5e73c230647d2484f985679c65fa850a5" + }, + { + "target": "bun", + "index": 2, + "retained": true, + "durationMs": 2.0152240000024904, + "bytes": 90116, + "sha256": "e7868ae3e9436d1a4ded6853297632fc1f15ea275c17d06553054d354e2c300e" + }, + { + "target": "node", + "index": 2, + "retained": true, + "durationMs": 1.6830300000146963, + "bytes": 90118, + "sha256": "2394e1487599a89120a0f36e73fd68f5e73c230647d2484f985679c65fa850a5" + }, + { + "target": "bun", + "index": 3, + "retained": true, + "durationMs": 1.992781000008108, + "bytes": 90116, + "sha256": "e7868ae3e9436d1a4ded6853297632fc1f15ea275c17d06553054d354e2c300e" + }, + { + "target": "node", + "index": 3, + "retained": true, + "durationMs": 1.898114000010537, + "bytes": 90118, + "sha256": "2394e1487599a89120a0f36e73fd68f5e73c230647d2484f985679c65fa850a5" + }, + { + "target": "compiled-hunk", + "index": 1, + "retained": false, + "retentionNote": "Identity measured in dist and recorded; binary is not copied into arm evidence.", + "durationMs": 333.1057729999884, + "bytes": 177268864, + "sha256": "8bf3f5fccb9ebd3eece988b5f8fc8b9280cad07c392df397b505f78382c55ca9" + }, + { + "target": "compiled-hunk", + "index": 2, + "retained": false, + "retentionNote": "Identity measured in dist and recorded; binary is not copied into arm evidence.", + "durationMs": 320.01372300001094, + "bytes": 177268864, + "sha256": "8bf3f5fccb9ebd3eece988b5f8fc8b9280cad07c392df397b505f78382c55ca9" + }, + { + "target": "compiled-hunk", + "index": 3, + "retained": false, + "retentionNote": "Identity measured in dist and recorded; binary is not copied into arm evidence.", + "durationMs": 324.0482979999797, + "bytes": 177268864, + "sha256": "8bf3f5fccb9ebd3eece988b5f8fc8b9280cad07c392df397b505f78382c55ca9" + } + ], + "measurements": { + "processToLifecycleReadyCold": { + "status": "measured", + "unit": "ms", + "warmups": [ + 15.131388999987394, + 13.411820000008447, + 12.751992000004975, + 12.125855999998748, + 11.909811000019545 + ], + "samples": [ + 13.041104000003543, + 12.25819500000216, + 11.733940000005532, + 12.964761999988696, + 11.820883000007598, + 11.905392999993637, + 12.372790000023087, + 11.568731000006665, + 12.57862600000226, + 11.365067999984603, + 12.68894399999408, + 13.109643000003416, + 12.329469000018435, + 12.092894000001252, + 11.985321999993175, + 11.770239000004949, + 11.574712000001455, + 12.930667000007816, + 11.908296999987215, + 12.793229000002611, + 11.918447000003653, + 12.01079000000027, + 12.442490999994334, + 11.983168000006117, + 12.012293000007048, + 12.548258999973768, + 12.761208999989321, + 12.354183999996167, + 11.829310000000987, + 12.394891000003554 + ], + "median": 12.092894000001252, + "p95": 13.041104000003543 + }, + "synchronousConnectionStop": { + "status": "measured", + "unit": "ms", + "warmups": [ + 0.11730000001261942, + 0.006631999975070357, + 0.002263999980641529, + 0.0013429999817162752, + 0.0011719999893102795 + ], + "samples": [ + 0.0010009999969042838, + 0.001041999988956377, + 0.0010420000180602074, + 0.004579000000376254, + 0.0008009999874047935, + 0.0005909999890718609, + 0.0007209999894257635, + 0.0005409999866969883, + 0.0005810000002384186, + 0.0010020000045187771, + 0.0006210000137798488, + 0.0010320000001229346, + 0.0027150000096298754, + 0.002123999991454184, + 0.0009110000100918114, + 0.00046099998871795833, + 0.00046099998871795833, + 0.0005909999890718609, + 0.0005610000225715339, + 0.0004610000178217888, + 0.0005810000002384186, + 0.0005110000201966614, + 0.0004409999819472432, + 0.0011519999825395644, + 0.0025550000136718154, + 0.0016929999983403832, + 0.0016629999736323953, + 0.0013719999988097697, + 0.009066999977221712, + 0.021299999993061647 + ], + "median": 0.0009110000100918114, + "p95": 0.009066999977221712 + }, + "unauthenticatedInMemoryRegistration": { + "status": "measured", + "unit": "ms", + "warmups": [ + 0.09314500002074055, + 0.05979299999307841, + 0.010309999983292073, + 0.010129000002052635, + 0.007012999994913116 + ], + "samples": [ + 0.006502000003820285, + 0.007023000012850389, + 0.006231000006664544, + 0.004859000007854775, + 0.010230000014416873, + 0.014517000003252178, + 0.009288000001106411, + 0.005670999991707504, + 0.007603999983984977, + 0.010689999995520338, + 0.012944999994942918, + 0.006531999999424443, + 0.006292000005487353, + 0.007092999992892146, + 0.006502000003820285, + 0.006062000000383705, + 0.006682999985059723, + 0.006502000003820285, + 0.00530999997863546, + 0.006261000002268702, + 0.011602000013226643, + 0.008285999996587634, + 0.009046999999554828, + 0.008456000010482967, + 0.0056099999928846955, + 0.005099000001791865, + 0.004268000018782914, + 0.004948999994667247, + 0.008254999993368983, + 0.00897700001951307 + ], + "median": 0.006531999999424443, + "p95": 0.012944999994942918 + }, + "authenticatedRegistration": { + "status": "not-measured", + "reason": "The signed Bun/Node fixtures prove hello and broker-observed register, but do not expose a stable isolated registration timestamp in both arms." + }, + "commandRoundTrip": { + "status": "measured", + "unit": "ms", + "warmups": [ + 0.7771389999834355, + 0.07846699998481199, + 0.03189000001293607, + 0.01947699999436736, + 0.015278999984730035 + ], + "samples": [ + 0.023473999986890703, + 0.024386000004597008, + 0.018183999985922128, + 0.02315299998736009, + 0.021340000006603077, + 0.023434000002453104, + 0.03190000000176951, + 0.015958999982103705, + 0.020168000017292798, + 0.014476999989710748, + 0.02071900002192706, + 0.027452000009361655, + 0.030697999987751245, + 0.027350999996997416, + 0.03024699998786673, + 0.04091699997661635, + 0.03340300000854768, + 0.012994999997317791, + 0.011892000009538606, + 0.022964000003412366, + 0.021020000014686957, + 0.02450600001611747, + 0.012863999989349395, + 0.013906000007409602, + 0.017182999989017844, + 0.03227999998489395, + 0.02408500001183711, + 0.015940000012051314, + 0.03614800001378171, + 0.020147999981418252 + ], + "median": 0.022964000003412366, + "p95": 0.03614800001378171 + }, + "reconnectGeneration": { + "status": "measured", + "unit": "ms", + "warmups": [ + 1.261047999985749, + 1.0256759999901988, + 1.0211169999965932, + 1.0083330000052229, + 1.006519999995362 + ], + "samples": [ + 1.005738999985624, + 1.0061189999978524, + 1.0099060000211466, + 1.0121199999994133, + 1.0172400000155903, + 1.0265270000090823, + 1.030544999986887, + 1.0406739999889396, + 1.0199349999893457, + 1.010396999976365, + 1.0078619999985676, + 1.0154960000072606, + 1.013172000006307, + 1.0136129999882542, + 1.0093660000129603, + 1.0059889999974985, + 1.010917999985395, + 1.0055479999864474, + 1.0114490000123624, + 1.0085330000147223, + 1.0064089999941643, + 1.017280000000028, + 1.0151560000085738, + 1.030373999994481, + 1.0081030000001192, + 1.0072109999891836, + 1.0076919999846723, + 1.027990000002319, + 1.027319000015268, + 1.020235000003595 + ], + "median": 1.0114490000123624, + "p95": 1.030544999986887 + }, + "transportListenerAcquisitionContext": { + "status": "measured", + "unit": "ms", + "warmups": [ + 2.3423880000191275, + 0.8345170000102371, + 0.7238190000061877, + 0.6922400000039488, + 0.6704679999966174 + ], + "samples": [ + 0.6488979999849107, + 0.7239389999886043, + 0.6751780000049621, + 0.7165760000061709, + 0.02892399998381734, + 0.7360120000084862, + 0.029225000005681068, + 0.6643069999990985, + 0.029726000007940456, + 0.7289379999856465, + 0.027581000002101064, + 0.7353909999947064, + 0.024406000011367723, + 0.6948339999944437, + 0.03202000001328997, + 0.7492360000032932, + 0.0271610000054352, + 0.7008759999880567, + 0.024025000020628795, + 0.649810000002617, + 0.025156999996397644, + 0.7049929999921005, + 0.024535999982617795, + 0.6931210000184365, + 0.026139000023249537, + 0.6999039999791421, + 0.028061999997589737, + 0.6719020000018645, + 0.02989599999273196, + 0.712708000006387 + ], + "median": 0.649810000002617, + "p95": 0.7360120000084862 + }, + "transportDaemonOrderedStopContext": { + "status": "measured", + "unit": "ms", + "warmups": [ + 0.817204999999376, + 0.8468299999949522, + 0.5172610000008717, + 0.7608989999862388, + 0.4901700000045821 + ], + "samples": [ + 0.7827089999918826, + 0.5073629999824334, + 0.7252719999814872, + 0.5016319999995176, + 0.7396090000111144, + 0.5457649999880232, + 0.8871059999801219, + 0.6179399999964517, + 0.7788819999841508, + 0.5875229999946896, + 0.7787620000017341, + 0.47988999998779036, + 0.6760290000238456, + 0.4288150000211317, + 0.6951950000075158, + 0.456927999999607, + 0.6996339999896009, + 0.4676879999751691, + 0.6529659999941941, + 0.41326599998865277, + 0.6401419999892823, + 0.49152300000423566, + 0.6339100000041071, + 0.4127450000087265, + 0.6151250000111759, + 0.41109100001631305, + 0.6207659999781754, + 0.41807499999413267, + 0.635232000000542, + 0.6431680000096094 + ], + "median": 0.6179399999964517, + "p95": 0.7827089999918826 + }, + "hunkStartupPolling": { + "status": "not-measured", + "reason": "Hunk startup polling is a Bun product path with no honest portable real-Node equivalent; characterization tests freeze behavior instead of timing a synthetic timer." + } + }, + "coarseObservations": { + "cpu": { + "classification": "coarse-non-isolated-observation", + "conclusionPolicy": "Do not infer lifecycle performance or security conclusions from this sample.", + "operations": 100, + "wallMs": 1.4867109999759123, + "user": 3743, + "system": 0 + }, + "rss": { + "classification": "coarse-non-isolated-observation", + "conclusionPolicy": "Do not infer lifecycle performance or security conclusions from this sample.", + "unit": "bytes", + "samples": [ + 378056704, + 378056704, + 377786368, + 377786368, + 377786368 + ], + "median": 377786368, + "p95": 378056704 + } + }, + "processExit": { + "bun": { + "status": "passed", + "evidence": { + "name": "focused", + "command": [ + "bun", + "test", + "packages/session-broker-core", + "packages/session-broker", + "packages/session-broker-bun", + "src/session/broker", + "scripts/source-boundaries.test.ts" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:43.106Z", + "finishedAt": "2026-08-31T05:11:48.250Z", + "durationMs": 5143.88365, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/focused.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/focused.stderr.log", + "stdoutSha256": "8d4128ca3fda4bc33627695431abc1f23ed737253e315c6c4dbb016f8935e61a", + "stderrSha256": "669dc6aa0f02359c38e4aacd4653dab93bfe7b10cafa086477b8f2e6974c2da0", + "parsedCounts": { + "passed": 379, + "failed": 0 + } + }, + "modes": [ + "signed-register", + "lifecycle-ready", + "pending-handshake", + "heartbeat", + "reconnect" + ] + }, + "node": { + "status": "passed", + "evidence": { + "name": "real-node", + "command": [ + "bun", + "run", + "test:session-broker-node" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:48.251Z", + "finishedAt": "2026-08-31T05:11:48.638Z", + "durationMs": 387.01115800000025, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/real-node.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/real-node.stderr.log", + "stdoutSha256": "f2566a0e01bee8c67c96fcf73a76fcbf671b5120be081249b121a055ec105e4f", + "stderrSha256": "70a9db88f4d0962ba72ae7ef8d5999b0ea4355ccda2e816a84ecd4ba4d6e454e", + "parsedCounts": { + "passed": 4, + "failed": 0, + "skipped": 0 + } + }, + "modes": [ + "signed-register", + "lifecycle-ready", + "pending-handshake", + "heartbeat", + "reconnect" + ] + } + }, + "declarations": { + "files": [ + "authentication.d.ts", + "broker.d.ts", + "clientAuthentication.d.ts", + "connection.d.ts", + "crypto.d.ts", + "daemon.d.ts", + "index.d.ts", + "protocolParsers.d.ts", + "types.d.ts" + ], + "forbiddenPattern": "(?:from\\s+[\"']effect[\"']|\\b(?:Effect|ManagedRuntime|Runtime|Layer|Scope|Fiber|Cause|Exit|Duration|Schedule|Clock)\\b)", + "forbiddenMatches": [] + }, + "dependencyPlacement": { + "effectVersions": [], + "declarations": [], + "commandExitCode": 1, + "commandExpectation": "Effect is absent; npm ls may exit nonzero for the missing requested package." + }, + "moduleBoundaries": { + "effectImports": [], + "misplacedEffectImports": [], + "forbiddenSupportImports": [], + "bunWsImports": [], + "bunBuildGate": "The Bun runtime fixture is executed only from a bundle built with a ws/ws/* rejecting onResolve plugin; a direct ws import probe must fail under the same plugin.", + "bunBuildGraph": { + "buildTarget": "node package-bundling graph; produced bundle executed by Bun", + "pluginFilter": "^ws(?:\\/|$)", + "directProbeRejected": [ + "ws" + ], + "fixtureRejected": [], + "bundleSha256": "43ce517587b7e67ec1c26e07e00e75d17058ea52eddbad5865314e5dfe36740e" + } + }, + "behavioralTestContinuity": { + "contracts": [ + { + "path": "packages/session-broker/src/connection.test.ts", + "tests": [ + { + "name": "throws an initial socket construction failure synchronously from start", + "line": 117, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 129, + "column": 5, + "kind": "expect", + "text": "expect(() => connection.start())" + } + ] + }, + { + "name": "arms delayed fixed-rate heartbeats through one cleared native interval", + "line": 133, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 162, + "column": 7, + "kind": "expect", + "text": "expect(intervalMs)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 163, + "column": 7, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 164, + "column": 7, + "kind": "expect", + "text": "expect(intervalCallback)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 166, + "column": 7, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 172, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears each producer handshake timeout on close and terminal stop", + "line": 179, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 239, + "column": 7, + "kind": "expect", + "text": "expect(handles)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 240, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears the producer handshake timeout after successful signed activation", + "line": 247, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 320, + "column": 7, + "kind": "expect", + "text": "expect(broker.getSessionIds())" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 321, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "registers on open and sends later snapshot updates", + "line": 331, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 357, + "column": 5, + "kind": "expect", + "text": "expect(registerMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 368, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 369, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.snapshot)" + } + ] + }, + { + "name": "withholds registration and replacement updates until producer authentication completes", + "line": 375, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 422, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 423, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent[0]!))" + } + ] + }, + { + "name": "keeps the previous registration when replacement send throws", + "line": 427, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 447, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n connection.replaceSession(\n { ...registration, sessionId: \"session-2\" },\n { ...createSnapshot(), state: { selectedIndex: 2 } },\n ),\n )" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 453, + "column": 5, + "kind": "expect", + "text": "expect(connection.getRegistration())" + } + ] + }, + { + "name": "queues broker commands until the app bridge is ready", + "line": 457, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 493, + "column": 5, + "kind": "expect", + "text": "expect(resultMessage)" + } + ] + }, + { + "name": "parses and transforms each server command once before bridge dispatch", + "line": 496, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 559, + "column": 5, + "kind": "expect", + "text": "expect(bridgedInput)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 560, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, resultCalls })" + } + ] + }, + { + "name": "closes malformed and mismatched commands without invoking the bridge", + "line": 567, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 611, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 617, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "enforces text framing and the exact websocket message ceiling before app parsing", + "line": 621, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 685, + "column": 5, + "kind": "expect", + "text": "expect(new TextEncoder().encode(exact).byteLength)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 688, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 723, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(`${exact} `))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 724, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(\"{\".repeat(maxBytes + 1)))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 725, + "column": 5, + "kind": "expect", + "text": "expect(\n emitRejected(\n JSON.stringify({\n type: \"command\",\n requestId: \"request-extra\",\n command: \"annotate\",\n input: { summary: \"not parsed\" },\n extra: true,\n }),\n ),\n " + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 736, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(new Uint8Array([123, 125])))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 737, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "ignores a late socket callback after stop before parsing or reserving", + "line": 740, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 799, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "does not migrate a late command result onto a replacement socket", + "line": 802, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 845, + "column": 5, + "kind": "expect", + "text": "expect(sockets[0]!.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 846, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "discards queued commands when their source socket disconnects", + "line": 850, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 894, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 895, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "stops replaying a queued batch when its source socket disconnects", + "line": 899, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 947, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps 32 missing-bridge commands FIFO and explicitly rejects the 33rd", + "line": 951, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 980, + "column": 5, + "kind": "expect", + "text": "expect(overflow)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 989, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps a hung bridge plus queued commands within the same 32-command budget", + "line": 993, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1023, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent.at(-1)!))" + } + ] + }, + { + "name": "retains a hung command reservation across disconnect and reconnect", + "line": 1030, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1077, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(sockets[1]!.sent.at(-1)!))" + } + ] + }, + { + "name": "serializes bridge execution while preserving arrival order", + "line": 1084, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1124, + "column": 5, + "kind": "expect", + "text": "expect(started)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1127, + "column": 5, + "kind": "expect", + "text": "expect(started)" + } + ] + }, + { + "name": "rejects producer hello wrappers with unknown or dangerous keys", + "line": 1131, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1173, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + } + ] + }, + { + "name": "prepares reconnect once per attempt and stops after awaited preparation", + "line": 1181, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1211, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1212, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1213, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1229, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + }, + { + "name": "reconnects after socket close unless a close directive disables it", + "line": 1232, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1263, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1267, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1268, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + } + ], + "forbiddenModes": [] + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "tests": [ + { + "name": "only treats exact pre-authentication compatibility closes as quiescent refusals", + "line": 101, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 103, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 104, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Malformed session broker protocol.\",\n authenticated: false,\n }),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 111, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 112, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 113, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Session broker authentication failed.\",\n authenticated: false,\n }),\n )" + } + ] + }, + { + "name": "keeps its previous registration when the live connection rejects replacement", + "line": 122, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 131, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n client.replaceSession(\n { ...registration, sessionId: \"replacement-session\" },\n createSnapshot(),\n ),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 137, + "column": 5, + "kind": "expect", + "text": "expect(client.getRegistration())" + } + ] + }, + { + "name": "shares one active first-attempt promise across concurrent starts", + "line": 140, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 154, + "column": 5, + "kind": "expect", + "text": "expect(first)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 155, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "an explicit start bypasses but does not displace the scheduled automatic retry", + "line": 162, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 191, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 192, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 193, + "column": 7, + "kind": "expect", + "text": "expect(retryCallback)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 194, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 197, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 198, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 199, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 203, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 204, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + } + ] + }, + { + "name": "logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", + "line": 212, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 229, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 232, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + }, + { + "name": "does not retain the legacy PID-based incompatible-daemon replacement path", + "line": 238, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 240, + "column": 5, + "kind": "expect", + "text": "expect((client as any).restartIncompatibleDaemon)" + } + ] + }, + { + "name": "logs one actionable warning when a refreshed daemon rejects an older Hunk window", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 350, + "column": 7, + "kind": "expect", + "text": "expect(messages.every((message) => message.includes(\"Close older Hunk windows\")))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 352, + "column": 7, + "kind": "expect", + "text": "expect(websocketOpens)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 353, + "column": 7, + "kind": "expect", + "text": "expect((client as any).waitingForIncumbentExit)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 354, + "column": 7, + "kind": "expect", + "text": "expect((skewedClient as any).waitingForIncumbentExit)" + } + ] + }, + { + "name": "authenticates after a successor becomes healthy before the waiter observes absence", + "line": 362, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 459, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 460, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "waits out an incompatible incumbent and registers on its successor with one connection", + "line": 471, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 531, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 551, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 577, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "retries the complete startup cycle and recovers without restarting the client", + "line": 590, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 607, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "does no daemon work when start follows terminal stop", + "line": 613, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 622, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "does not warn or schedule recovery after stop wins a foreign startup race", + "line": 625, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 648, + "column": 5, + "kind": "expect", + "text": "expect(reconnectScheduled)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 649, + "column": 5, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "logs one actionable warning when a non-Hunk listener owns the session daemon port", + "line": 652, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 680, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 684, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 685, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 688, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + } + ], + "forbiddenModes": [] + } + ], + "treatmentComparison": null, + "note": "Mechanical anti-weakening evidence only; independent review must establish behavioral preservation." + }, + "verifiedControlAttestation": null, + "frozenControlInputs": null, + "validation": [ + { + "name": "focused", + "command": [ + "bun", + "test", + "packages/session-broker-core", + "packages/session-broker", + "packages/session-broker-bun", + "src/session/broker", + "scripts/source-boundaries.test.ts" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:43.106Z", + "finishedAt": "2026-08-31T05:11:48.250Z", + "durationMs": 5143.88365, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/focused.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/focused.stderr.log", + "stdoutSha256": "8d4128ca3fda4bc33627695431abc1f23ed737253e315c6c4dbb016f8935e61a", + "stderrSha256": "669dc6aa0f02359c38e4aacd4653dab93bfe7b10cafa086477b8f2e6974c2da0", + "parsedCounts": { + "passed": 379, + "failed": 0 + } + }, + { + "name": "real-node", + "command": [ + "bun", + "run", + "test:session-broker-node" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:48.251Z", + "finishedAt": "2026-08-31T05:11:48.638Z", + "durationMs": 387.01115800000025, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/real-node.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/real-node.stderr.log", + "stdoutSha256": "f2566a0e01bee8c67c96fcf73a76fcbf671b5120be081249b121a055ec105e4f", + "stderrSha256": "70a9db88f4d0962ba72ae7ef8d5999b0ea4355ccda2e816a84ecd4ba4d6e454e", + "parsedCounts": { + "passed": 4, + "failed": 0, + "skipped": 0 + } + }, + { + "name": "typecheck", + "command": [ + "bun", + "run", + "typecheck" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:48.638Z", + "finishedAt": "2026-08-31T05:11:53.815Z", + "durationMs": 5176.940555, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/typecheck.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/typecheck.stderr.log", + "stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stderrSha256": "8366207267355d3e3d5bf3bf6e8c94c5f93f6078c34f08973fa2b38cdda6cc92" + }, + { + "name": "lint", + "command": [ + "bun", + "run", + "lint" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:53.816Z", + "finishedAt": "2026-08-31T05:11:53.885Z", + "durationMs": 69.44209500000034, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/lint.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/lint.stderr.log", + "stdoutSha256": "5a3b339b2a27ff346013ad224650a01f69fd75be4b9a4f6067a95cdbb5c22336", + "stderrSha256": "b2dff80d5fca1f1286fa77107660bc4380492aa10e2be93e185856f21c3993fd" + }, + { + "name": "dependencies", + "command": [ + "bun", + "run", + "deps:check" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:53.885Z", + "finishedAt": "2026-08-31T05:11:54.540Z", + "durationMs": 654.5118440000006, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/dependencies.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/dependencies.stderr.log", + "stdoutSha256": "91991838cfca6d9b09120c41dbfb47bd2edacb6d89b4812dde3f6d4627174336", + "stderrSha256": "78c9ba4b5461c552fe43e52904d0eaf6f5ddef47588cf477ea2a697c44182351" + }, + { + "name": "full-unit", + "command": [ + "bun", + "run", + "test" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:54.540Z", + "finishedAt": "2026-08-31T05:12:28.027Z", + "durationMs": 33487.721417, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/full-unit.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/full-unit.stderr.log", + "stdoutSha256": "ae4d22dad031138c8d581a94ee80023563d83af4a1ed4cd85749f81c0dacdfb3", + "stderrSha256": "b32cf5d0af4ac94f56296a7f85e4a97dac94e2720ab1f8a31fe5b0616a5b0de9" + }, + { + "name": "pty-integration", + "command": [ + "bun", + "run", + "test:integration" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:12:28.031Z", + "finishedAt": "2026-08-31T05:15:35.582Z", + "durationMs": 187551.110628, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/pty-integration.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/pty-integration.stderr.log", + "stdoutSha256": "d6684989b8dd63b37d2f1954270826fbe1bd89a8debd12bcacf03ed1c6140ef6", + "stderrSha256": "b22fb8bc56d89884bb55ead90195bd981eb73111e72da5a9082dd367827f309e", + "parsedCounts": { + "passed": 139, + "failed": 0, + "skipped": 1 + } + } + ], + "commands": [ + { + "name": "system-node-version", + "command": [ + "node", + "--version" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:15:35.582Z", + "finishedAt": "2026-08-31T05:15:35.586Z", + "durationMs": 3.5060219999868423, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/commands/system-node-version.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/commands/system-node-version.stderr.log", + "stdoutSha256": "50956a2c27b1a442408131f86103ff1df8a7f4a29502df81d216ba19e2c2f4a6", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "dependency-tree", + "command": [ + "npm", + "ls", + "effect", + "--all", + "--json" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:15:35.586Z", + "finishedAt": "2026-08-31T05:15:35.848Z", + "durationMs": 262.3103340000089, + "exitCode": 1, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/commands/dependency-tree.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/commands/dependency-tree.stderr.log", + "stdoutSha256": "e7786e2376a7b7b1622c4a6654deac082ddb8723a55f3edb2c7b59c8720738ca", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "compiled-hunk-1", + "command": [ + "bun", + "run", + "build:bin" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:15:36.554Z", + "finishedAt": "2026-08-31T05:15:36.887Z", + "durationMs": 333.1057729999884, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/commands/compiled-hunk-1.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/commands/compiled-hunk-1.stderr.log", + "stdoutSha256": "56a6137a358617a9dcc83455078dc519ffa2f8233d047b03e008f5232ac4ae44", + "stderrSha256": "915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc" + }, + { + "name": "compiled-hunk-2", + "command": [ + "bun", + "run", + "build:bin" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:15:37.008Z", + "finishedAt": "2026-08-31T05:15:37.328Z", + "durationMs": 320.01372300001094, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/commands/compiled-hunk-2.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/commands/compiled-hunk-2.stderr.log", + "stdoutSha256": "7533f724866b3715b0f2727a9db077409d51dd75866255a69aa007c8a4c1c85e", + "stderrSha256": "915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc" + }, + { + "name": "compiled-hunk-3", + "command": [ + "bun", + "run", + "build:bin" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:15:37.421Z", + "finishedAt": "2026-08-31T05:15:37.745Z", + "durationMs": 324.0482979999797, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/commands/compiled-hunk-3.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/commands/compiled-hunk-3.stderr.log", + "stdoutSha256": "453e03a8b20ca775ec55803bcfacb05909174e9c3bd39ad5f5b9fcab99479524", + "stderrSha256": "915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc" + }, + { + "name": "public-declarations", + "command": [ + "./node_modules/.bin/tsc", + "packages/session-broker/src/index.ts", + "--declaration", + "--emitDeclarationOnly", + "--outDir", + "tmp/session-lifecycle-effect/control/declarations", + "--module", + "ESNext", + "--moduleResolution", + "Bundler", + "--target", + "ES2022", + "--lib", + "ES2022,DOM", + "--skipLibCheck", + "--strict", + "--types", + "bun" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:15:38.419Z", + "finishedAt": "2026-08-31T05:15:39.073Z", + "durationMs": 654.0611670000071, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/commands/public-declarations.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/commands/public-declarations.stderr.log", + "stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] +} diff --git a/research/session-broker-effect-lifecycle/artifacts/control/semantics.json b/research/session-broker-effect-lifecycle/artifacts/control/semantics.json new file mode 100644 index 000000000..863bc5465 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/semantics.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 3, + "label": "control", + "heartbeat": "native-timing-control", + "connectionOwner": "native-timing-control", + "clientStartupOwner": "native-timing-control", + "staticAnalysisLimit": "AST gates prove only named mechanical source shapes; independent review must establish lifecycle semantics and correctness.", + "cancellationBoundary": "foreign native promises and detached processes are not claimed cancelled", + "intentionalTreatmentDifferences": [] +} diff --git a/research/session-broker-effect-lifecycle/artifacts/control/source-identity.json b/research/session-broker-effect-lifecycle/artifacts/control/source-identity.json new file mode 100644 index 000000000..5a4c7cc04 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/source-identity.json @@ -0,0 +1,1144 @@ +{ + "schemaVersion": 2, + "baseHead": "42d2b9dd2f3144e33080f159f4cc9c2824ad1708", + "currentHead": "42d2b9dd2f3144e33080f159f4cc9c2824ad1708", + "changedFiles": [ + { + "path": "docs/session-broker-effect-lifecycle-experiment-results.md", + "classification": "experiment-support", + "exists": true, + "bytes": 5418, + "sha256": "7b0a270659e22f56904333700d7495400d1944a96a695febb8145fd845f84250" + }, + { + "path": "docs/session-broker-effect-lifecycle-experiment.md", + "classification": "experiment-support", + "exists": true, + "bytes": 15732, + "sha256": "9f9ffdd2068e2a02f6f4380d8928105e1cd592b2b43f955855b149ed42633cf5" + }, + { + "path": "packages/session-broker-bun/src/connectionRuntime.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 3305, + "sha256": "eabb3ff9d2e06c27a600ce6bd6d13f6ea0ff2574eb247cfc5b07e7901c7a371c" + }, + { + "path": "packages/session-broker/src/connection.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 38776, + "sha256": "a9b1b8a16929e8896eabd39093e2815ef204215ff36ccb41de9b480fcd469830" + }, + { + "path": "scripts/session-broker-effect-lifecycle-experiment.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 56839, + "sha256": "ab809ae95145a41fa9e3d4f4de1173582b50f425531c88d33be097c44c6f9d85" + }, + { + "path": "scripts/session-lifecycle-ownership-analysis.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 25164, + "sha256": "78bce16eaa7c757ffd79f7f0118b93089543b9df77b5ad1506e0eb7e29b060cd" + }, + { + "path": "scripts/source-boundaries.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 20485, + "sha256": "7b61f489bf87ac8d9d4960b65852f3c8829b605f6989865f8f3aa2ffc994378e" + }, + { + "path": "scripts/test-session-broker-node.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 1921, + "sha256": "e5a966843b6970538deebcb746eab0e7c1efc70f74398cb8bd473611adf107bf" + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 23604, + "sha256": "ebed0b4807d5e21a0053c42b07a45ee8a2a77780003d8599f2aec74a7cef5f31" + }, + { + "path": "src/session/broker/brokerLauncher.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 11327, + "sha256": "79b4f1cb2fb43f6caa9199c5700db07aa652fddf891384b48bfc44c3e71ce41c" + }, + { + "path": "test/session-broker-node/adapter.test.mjs", + "classification": "experiment-support", + "exists": true, + "bytes": 12165, + "sha256": "21dca2de748bf1ddd8a9ba0c6578006d85974e111fa4a7daa9b0f58e745fdfeb" + }, + { + "path": "test/session-broker-node/connection-fixture.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 948, + "sha256": "91c4cea63bdcf8188bb4688d3cf6fceeb6e18cbbbd12233593bf77abc7e007b2" + }, + { + "path": "test/session-broker-runtime/bun-connection-fixture.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 686, + "sha256": "05e0710382840a5a4b171e42157ed4211b1f993d46e5b0e3b4c0ad0dbd6a6a95" + }, + { + "path": "test/session-broker-runtime/connectionFixture.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 9263, + "sha256": "d4162f1bc31791b7d285ed5d39457eff421832fd18225b7fd77b20f9055765f3" + } + ], + "behavioralTestContracts": [ + { + "path": "packages/session-broker/src/connection.test.ts", + "tests": [ + { + "name": "throws an initial socket construction failure synchronously from start", + "line": 117, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 129, + "column": 5, + "kind": "expect", + "text": "expect(() => connection.start())" + } + ] + }, + { + "name": "arms delayed fixed-rate heartbeats through one cleared native interval", + "line": 133, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 162, + "column": 7, + "kind": "expect", + "text": "expect(intervalMs)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 163, + "column": 7, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 164, + "column": 7, + "kind": "expect", + "text": "expect(intervalCallback)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 166, + "column": 7, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 172, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears each producer handshake timeout on close and terminal stop", + "line": 179, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 239, + "column": 7, + "kind": "expect", + "text": "expect(handles)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 240, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears the producer handshake timeout after successful signed activation", + "line": 247, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 320, + "column": 7, + "kind": "expect", + "text": "expect(broker.getSessionIds())" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 321, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "registers on open and sends later snapshot updates", + "line": 331, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 357, + "column": 5, + "kind": "expect", + "text": "expect(registerMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 368, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 369, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.snapshot)" + } + ] + }, + { + "name": "withholds registration and replacement updates until producer authentication completes", + "line": 375, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 422, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 423, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent[0]!))" + } + ] + }, + { + "name": "keeps the previous registration when replacement send throws", + "line": 427, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 447, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n connection.replaceSession(\n { ...registration, sessionId: \"session-2\" },\n { ...createSnapshot(), state: { selectedIndex: 2 } },\n ),\n )" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 453, + "column": 5, + "kind": "expect", + "text": "expect(connection.getRegistration())" + } + ] + }, + { + "name": "queues broker commands until the app bridge is ready", + "line": 457, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 493, + "column": 5, + "kind": "expect", + "text": "expect(resultMessage)" + } + ] + }, + { + "name": "parses and transforms each server command once before bridge dispatch", + "line": 496, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 559, + "column": 5, + "kind": "expect", + "text": "expect(bridgedInput)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 560, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, resultCalls })" + } + ] + }, + { + "name": "closes malformed and mismatched commands without invoking the bridge", + "line": 567, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 611, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 617, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "enforces text framing and the exact websocket message ceiling before app parsing", + "line": 621, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 685, + "column": 5, + "kind": "expect", + "text": "expect(new TextEncoder().encode(exact).byteLength)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 688, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 723, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(`${exact} `))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 724, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(\"{\".repeat(maxBytes + 1)))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 725, + "column": 5, + "kind": "expect", + "text": "expect(\n emitRejected(\n JSON.stringify({\n type: \"command\",\n requestId: \"request-extra\",\n command: \"annotate\",\n input: { summary: \"not parsed\" },\n extra: true,\n }),\n ),\n " + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 736, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(new Uint8Array([123, 125])))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 737, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "ignores a late socket callback after stop before parsing or reserving", + "line": 740, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 799, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "does not migrate a late command result onto a replacement socket", + "line": 802, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 845, + "column": 5, + "kind": "expect", + "text": "expect(sockets[0]!.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 846, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "discards queued commands when their source socket disconnects", + "line": 850, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 894, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 895, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "stops replaying a queued batch when its source socket disconnects", + "line": 899, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 947, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps 32 missing-bridge commands FIFO and explicitly rejects the 33rd", + "line": 951, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 980, + "column": 5, + "kind": "expect", + "text": "expect(overflow)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 989, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps a hung bridge plus queued commands within the same 32-command budget", + "line": 993, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1023, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent.at(-1)!))" + } + ] + }, + { + "name": "retains a hung command reservation across disconnect and reconnect", + "line": 1030, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1077, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(sockets[1]!.sent.at(-1)!))" + } + ] + }, + { + "name": "serializes bridge execution while preserving arrival order", + "line": 1084, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1124, + "column": 5, + "kind": "expect", + "text": "expect(started)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1127, + "column": 5, + "kind": "expect", + "text": "expect(started)" + } + ] + }, + { + "name": "rejects producer hello wrappers with unknown or dangerous keys", + "line": 1131, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1173, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + } + ] + }, + { + "name": "prepares reconnect once per attempt and stops after awaited preparation", + "line": 1181, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1211, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1212, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1213, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1229, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + }, + { + "name": "reconnects after socket close unless a close directive disables it", + "line": 1232, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1263, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1267, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1268, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + } + ], + "forbiddenModes": [] + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "tests": [ + { + "name": "only treats exact pre-authentication compatibility closes as quiescent refusals", + "line": 101, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 103, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 104, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Malformed session broker protocol.\",\n authenticated: false,\n }),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 111, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 112, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 113, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Session broker authentication failed.\",\n authenticated: false,\n }),\n )" + } + ] + }, + { + "name": "keeps its previous registration when the live connection rejects replacement", + "line": 122, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 131, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n client.replaceSession(\n { ...registration, sessionId: \"replacement-session\" },\n createSnapshot(),\n ),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 137, + "column": 5, + "kind": "expect", + "text": "expect(client.getRegistration())" + } + ] + }, + { + "name": "shares one active first-attempt promise across concurrent starts", + "line": 140, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 154, + "column": 5, + "kind": "expect", + "text": "expect(first)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 155, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "an explicit start bypasses but does not displace the scheduled automatic retry", + "line": 162, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 191, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 192, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 193, + "column": 7, + "kind": "expect", + "text": "expect(retryCallback)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 194, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 197, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 198, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 199, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 203, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 204, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + } + ] + }, + { + "name": "logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", + "line": 212, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 229, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 232, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + }, + { + "name": "does not retain the legacy PID-based incompatible-daemon replacement path", + "line": 238, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 240, + "column": 5, + "kind": "expect", + "text": "expect((client as any).restartIncompatibleDaemon)" + } + ] + }, + { + "name": "logs one actionable warning when a refreshed daemon rejects an older Hunk window", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 350, + "column": 7, + "kind": "expect", + "text": "expect(messages.every((message) => message.includes(\"Close older Hunk windows\")))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 352, + "column": 7, + "kind": "expect", + "text": "expect(websocketOpens)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 353, + "column": 7, + "kind": "expect", + "text": "expect((client as any).waitingForIncumbentExit)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 354, + "column": 7, + "kind": "expect", + "text": "expect((skewedClient as any).waitingForIncumbentExit)" + } + ] + }, + { + "name": "authenticates after a successor becomes healthy before the waiter observes absence", + "line": 362, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 459, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 460, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "waits out an incompatible incumbent and registers on its successor with one connection", + "line": 471, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 531, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 551, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 577, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "retries the complete startup cycle and recovers without restarting the client", + "line": 590, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 607, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "does no daemon work when start follows terminal stop", + "line": 613, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 622, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "does not warn or schedule recovery after stop wins a foreign startup race", + "line": 625, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 648, + "column": 5, + "kind": "expect", + "text": "expect(reconnectScheduled)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 649, + "column": 5, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "logs one actionable warning when a non-Hunk listener owns the session daemon port", + "line": 652, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 680, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 684, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 685, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 688, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + } + ], + "forbiddenModes": [] + } + ], + "production": [ + { + "path": "bun.lock", + "currentExists": true, + "baseExists": true, + "currentSha256": "b2b43bcbac57d09213916d9bec04f883f06e482074dd754fde5b8f8bbd379bd9", + "baseSha256": "b2b43bcbac57d09213916d9bec04f883f06e482074dd754fde5b8f8bbd379bd9", + "matchesBase": true + }, + { + "path": "packages/session-broker-bun/src/serve.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "336ae69a5d1ea336002a2bac9775c9233f5569a8e10a2e051fdd286999cb62f9", + "baseSha256": "336ae69a5d1ea336002a2bac9775c9233f5569a8e10a2e051fdd286999cb62f9", + "matchesBase": true + }, + { + "path": "packages/session-broker-node/src/serve.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "3f982a1df9727f7f9a03e6271f80bbea91138a94c7ab8dcd1c676cda11f8a46a", + "baseSha256": "3f982a1df9727f7f9a03e6271f80bbea91138a94c7ab8dcd1c676cda11f8a46a", + "matchesBase": true + }, + { + "path": "packages/session-broker/package.json", + "currentExists": true, + "baseExists": true, + "currentSha256": "5c9f0ee3a0da36098e0ab8160bb66ab13f3cf1be238d6d69123fa6d5040628ee", + "baseSha256": "5c9f0ee3a0da36098e0ab8160bb66ab13f3cf1be238d6d69123fa6d5040628ee", + "matchesBase": true + }, + { + "path": "packages/session-broker/src/connection.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "d3fbeed7502a31d9d8be82da0f282e523227ab67cd495aa85a8b9208802d6c76", + "baseSha256": "d3fbeed7502a31d9d8be82da0f282e523227ab67cd495aa85a8b9208802d6c76", + "matchesBase": true + }, + { + "path": "packages/session-broker/src/daemon.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "f0fbd88158f2295c0ea21fc4b682b8af5306f76dbe7192da5dc3ea7a7642d82f", + "baseSha256": "f0fbd88158f2295c0ea21fc4b682b8af5306f76dbe7192da5dc3ea7a7642d82f", + "matchesBase": true + }, + { + "path": "packages/session-broker/src/index.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "b89de9f5fdce27371ecd9d8e92cc7be194a3daa49ecea45e4a235dbebd680264", + "baseSha256": "b89de9f5fdce27371ecd9d8e92cc7be194a3daa49ecea45e4a235dbebd680264", + "matchesBase": true + }, + { + "path": "packages/session-broker/src/processLifecycle.ts", + "currentExists": false, + "baseExists": false, + "currentSha256": null, + "baseSha256": null, + "matchesBase": true + }, + { + "path": "src/session/broker/brokerClient.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "996abced18f1e921753b566fa45577dd39650ac7a679d6fbdc447ee91ebb66c3", + "baseSha256": "996abced18f1e921753b566fa45577dd39650ac7a679d6fbdc447ee91ebb66c3", + "matchesBase": true + }, + { + "path": "src/session/broker/brokerLauncher.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "05d9ad750fc9e0f737cd70eb1e444791a9c15e41a31680b3c7803bbba0a86300", + "baseSha256": "05d9ad750fc9e0f737cd70eb1e444791a9c15e41a31680b3c7803bbba0a86300", + "matchesBase": true + }, + { + "path": "src/session/broker/brokerServer.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "b04e38987932851dbe5aedb98110be55bf86087163e6ce620f2bfcf0141f3b0a", + "baseSha256": "b04e38987932851dbe5aedb98110be55bf86087163e6ce620f2bfcf0141f3b0a", + "matchesBase": true + }, + { + "path": "src/ui/runInteractiveApp.tsx", + "currentExists": true, + "baseExists": true, + "currentSha256": "b57be57b9ee97a50267262090dea660c90cba8f84019d2fe9b03e05fa8e6c5a1", + "baseSha256": "b57be57b9ee97a50267262090dea660c90cba8f84019d2fe9b03e05fa8e6c5a1", + "matchesBase": true + } + ], + "productionMatchesBase": true, + "productionChangePolicy": { + "changedProduction": [], + "allowed": [ + "bun.lock", + "packages/session-broker/package.json", + "packages/session-broker/src/connection.ts", + "packages/session-broker/src/index.ts", + "packages/session-broker/src/processLifecycle.ts", + "src/session/broker/brokerClient.ts", + "src/session/broker/brokerLauncher.ts", + "src/ui/runInteractiveApp.tsx" + ], + "required": [ + "bun.lock", + "packages/session-broker/package.json", + "packages/session-broker/src/index.ts", + "packages/session-broker/src/processLifecycle.ts", + "packages/session-broker/src/connection.ts", + "src/session/broker/brokerClient.ts", + "src/ui/runInteractiveApp.tsx" + ], + "violations": [] + }, + "reconstructablePatch": { + "path": "tmp/session-lifecycle-effect/control/complete-experiment.patch", + "bytes": 150070, + "sha256": "e7b9a0c6e639841fe65532ebf1cd3c9ce5a14e688b9c3fe28cf1c53288674757", + "includesUntracked": true + }, + "firstExperiment": { + "expectedPatchSha256": "f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56", + "observation": { + "suppliedPath": null, + "observedSha256": null, + "status": "expected-hash-only" + } + } +} diff --git a/research/session-broker-effect-lifecycle/artifacts/control/validation-manifest.json b/research/session-broker-effect-lifecycle/artifacts/control/validation-manifest.json new file mode 100644 index 000000000..2963a4064 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/control/validation-manifest.json @@ -0,0 +1,144 @@ +{ + "schemaVersion": 2, + "recordedAt": "2026-08-31T05:15:39.344Z", + "freshness": "all required commands executed by this arm invocation after clearing output", + "commands": [ + { + "name": "focused", + "command": [ + "bun", + "test", + "packages/session-broker-core", + "packages/session-broker", + "packages/session-broker-bun", + "src/session/broker", + "scripts/source-boundaries.test.ts" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:43.106Z", + "finishedAt": "2026-08-31T05:11:48.250Z", + "durationMs": 5143.88365, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/focused.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/focused.stderr.log", + "stdoutSha256": "8d4128ca3fda4bc33627695431abc1f23ed737253e315c6c4dbb016f8935e61a", + "stderrSha256": "669dc6aa0f02359c38e4aacd4653dab93bfe7b10cafa086477b8f2e6974c2da0", + "parsedCounts": { + "passed": 379, + "failed": 0 + } + }, + { + "name": "real-node", + "command": [ + "bun", + "run", + "test:session-broker-node" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:48.251Z", + "finishedAt": "2026-08-31T05:11:48.638Z", + "durationMs": 387.01115800000025, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/real-node.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/real-node.stderr.log", + "stdoutSha256": "f2566a0e01bee8c67c96fcf73a76fcbf671b5120be081249b121a055ec105e4f", + "stderrSha256": "70a9db88f4d0962ba72ae7ef8d5999b0ea4355ccda2e816a84ecd4ba4d6e454e", + "parsedCounts": { + "passed": 4, + "failed": 0, + "skipped": 0 + } + }, + { + "name": "typecheck", + "command": [ + "bun", + "run", + "typecheck" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:48.638Z", + "finishedAt": "2026-08-31T05:11:53.815Z", + "durationMs": 5176.940555, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/typecheck.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/typecheck.stderr.log", + "stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stderrSha256": "8366207267355d3e3d5bf3bf6e8c94c5f93f6078c34f08973fa2b38cdda6cc92" + }, + { + "name": "lint", + "command": [ + "bun", + "run", + "lint" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:53.816Z", + "finishedAt": "2026-08-31T05:11:53.885Z", + "durationMs": 69.44209500000034, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/lint.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/lint.stderr.log", + "stdoutSha256": "5a3b339b2a27ff346013ad224650a01f69fd75be4b9a4f6067a95cdbb5c22336", + "stderrSha256": "b2dff80d5fca1f1286fa77107660bc4380492aa10e2be93e185856f21c3993fd" + }, + { + "name": "dependencies", + "command": [ + "bun", + "run", + "deps:check" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:53.885Z", + "finishedAt": "2026-08-31T05:11:54.540Z", + "durationMs": 654.5118440000006, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/dependencies.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/dependencies.stderr.log", + "stdoutSha256": "91991838cfca6d9b09120c41dbfb47bd2edacb6d89b4812dde3f6d4627174336", + "stderrSha256": "78c9ba4b5461c552fe43e52904d0eaf6f5ddef47588cf477ea2a697c44182351" + }, + { + "name": "full-unit", + "command": [ + "bun", + "run", + "test" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:11:54.540Z", + "finishedAt": "2026-08-31T05:12:28.027Z", + "durationMs": 33487.721417, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/full-unit.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/full-unit.stderr.log", + "stdoutSha256": "ae4d22dad031138c8d581a94ee80023563d83af4a1ed4cd85749f81c0dacdfb3", + "stderrSha256": "b32cf5d0af4ac94f56296a7f85e4a97dac94e2720ab1f8a31fe5b0616a5b0de9" + }, + { + "name": "pty-integration", + "command": [ + "bun", + "run", + "test:integration" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:12:28.031Z", + "finishedAt": "2026-08-31T05:15:35.582Z", + "durationMs": 187551.110628, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/control/validation/pty-integration.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/control/validation/pty-integration.stderr.log", + "stdoutSha256": "d6684989b8dd63b37d2f1954270826fbe1bd89a8debd12bcacf03ed1c6140ef6", + "stderrSha256": "b22fb8bc56d89884bb55ead90195bd981eb73111e72da5a9082dd367827f309e", + "parsedCounts": { + "passed": 139, + "failed": 0, + "skipped": 1 + } + } + ] +} diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/canary.json b/research/session-broker-effect-lifecycle/artifacts/treatment/canary.json new file mode 100644 index 000000000..66e9c277e --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/canary.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 3, + "label": "treatment", + "markerSha256": "1658598e333ed7a41438c02ada9266b86396b930252b0adf91de68d32b3efdbe", + "exercised": "resolveClose callback defect during reconnect close policy", + "fixedTreatmentMessage": "Session broker lifecycle failed unexpectedly.", + "defectCallbackMessages": [ + "Session broker lifecycle failed unexpectedly." + ], + "outputMessages": [], + "synchronousEscapeCharacterized": false, + "controlLimitation": null, + "treatmentAssertionsApplied": true +} diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/complete-experiment.patch b/research/session-broker-effect-lifecycle/artifacts/treatment/complete-experiment.patch new file mode 100644 index 000000000..a2dd7e263 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/complete-experiment.patch @@ -0,0 +1,5502 @@ +diff --git a/bun.lock b/bun.lock +index 333ce24c..b0c82776 100644 +--- a/bun.lock ++++ b/bun.lock +@@ -53,6 +53,7 @@ + "version": "0.0.0", + "dependencies": { + "@hunk/session-broker-core": "workspace:*", ++ "effect": "3.22.1", + }, + }, + "packages/session-broker-bun": { +@@ -351,6 +352,8 @@ + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + ++ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], ++ + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], +@@ -435,6 +438,8 @@ + + "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], + ++ "effect": ["effect@3.22.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ=="], ++ + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], +@@ -449,6 +454,8 @@ + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + ++ "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], ++ + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], +@@ -589,6 +596,8 @@ + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + ++ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], ++ + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], +diff --git a/docs/session-broker-effect-lifecycle-experiment-results.md b/docs/session-broker-effect-lifecycle-experiment-results.md +new file mode 100644 +index 00000000..614a4803 +--- /dev/null ++++ b/docs/session-broker-effect-lifecycle-experiment-results.md +@@ -0,0 +1,79 @@ ++# Session broker Effect lifecycle experiment results ++ ++Status: Phase 0 control recollected after independent review; Phase 1 treatment not started. ++ ++## Control ++ ++The control is fresh `origin/main` at ++`42d2b9dd2f3144e33080f159f4cc9c2824ad1708`. The experiment plan, characterization tests, ++fixtures, and harness form a separate experiment-support patch. Every candidate production file, ++package manifest, and lockfile remains byte-identical to the control commit. ++ ++Control evidence is stored locally under `tmp/session-lifecycle-effect/control/`. Each harness ++invocation removes that complete directory before running validation. The current invocation itself ++ran focused tests, the real Node suite, typecheck, lint, dependency boundaries, the full unit suite, ++and PTY integration; it accepts no inherited transcript or hard-coded count. ++ ++The recollected control establishes: ++ ++- the Bun-native and Node-native child fixtures each create a real `SessionBrokerAuthenticator`, ++ real `SessionBrokerDaemon`, and native transport, complete the signed producer hello, and wait ++ until the actual broker observes `register` before stopping; ++- focused lifecycle/broker/source-boundary validation: 379 passed and 0 failed; ++- real Node adapter/producer validation: 4 passed and 0 failed; ++- the full unit command passed; its multi-suite runner emits multiple summaries, so the harness ++ records exact exit/transcript hashes but deliberately does not present the first summary as an ++ aggregate count; ++- PTY integration: 139 passed, 1 skipped, and 0 failed; ++- typecheck, lint, dependency boundaries, declaration emission, Bun/Node process-exit modes, and ++ the bounded canary scan passed; ++- minified standalone broker builds are deterministic at 90,116 bytes for Bun and 90,118 bytes for ++ Node across three retained builds; ++- compiled Hunk is deterministic at 177,268,864 bytes across three measurements. Its identity is ++ recorded, but the binaries remain in `dist/` and are not retained as arm artifacts; ++- cold Bun process-to-lifecycle-ready is measured by fresh subprocesses. Control dynamically proves ++ the factory is absent; treatment must create, close, and settle the factory; ++- direct synchronous connection-stop latency is measured separately from fixture construction; ++- listener acquisition and daemon ordered stop are labeled unchanged transport context, not ++ producer lifecycle startup or stop; ++- CPU and RSS are coarse, non-isolated observations and support no strong performance or security ++ conclusion; ++- source identity includes all dirty files, dynamically classifies production changes, and stores a ++ reconstructable binary patch including untracked experiment support. ++ ++Authenticated-registration latency remains `not-measured`: the signed runtime fixtures prove the ++complete hello/register behavior but do not expose one stable isolated timestamp in both arms. Hunk ++startup polling also remains `not-measured` because it has no honest portable real-Node equivalent. ++The canary exercises only a reconnect close-policy defect. Control exposes its raw Error to the ++synchronous caller in memory; the harness catches it without persisting the random marker. It does ++not claim credential, payload, finalizer, diagnostic, snapshot, or rendered-Cause coverage. ++ ++## Treatment ++ ++Not started. No Effect dependency or production lifecycle change is present in the Phase 0 tree. ++The frozen treatment harness requires an externally supplied control attestation-file hash before ++it trusts control identities. It freezes every existing control support input except this results ++report and raw evidence, and enforces the exact allowed production change set. It then fails closed ++on named AST-derived mechanical patterns: lifecycle factory/façade shape, exact class property sets, ++Promise/native timing owners, Effect declaration/import placement, registries/classes, ++runtime/Clock/fixed-heartbeat construction sites, and factory call location. Bun executes only a ++fixture bundle produced through a `ws`-rejecting build-graph plugin whose direct `ws` probe must fail. ++The defect canary permits no synchronous treatment escape, enumerates callable console methods ++across the console prototype chain, self-tests error/warn/log/info/debug/trace/dir/table capture, and ++intercepts direct stdout/stderr writes while requiring exactly one total fixed hook message. Production ++imports into test-named or experiment-support modules are forbidden, and Effect scans include every ++tracked or untracked repository TypeScript source with only the dedicated lifecycle test allowed. These ++checks are reproducible source-shape evidence, not proof of lifecycle semantics. ++ ++All control support remains byte-frozen except the results report/raw evidence and exactly ++`connection.test.ts` plus `brokerClient.test.ts`. Those two lifecycle-injection tests instead retain ++AST-derived ordered test names and assertion-call locations/counts; treatment must preserve every ++control name, may not reduce a matching body's assertion count, and may not introduce skip/todo/only. ++New tests remain allowed. This is mechanical anti-weakening evidence requiring independent review, ++not behavioral proof. ++Independent correctness/security review remains required. ++ ++## Verdict ++ ++Pending Phase 1. This document must end with either **discard Phase 1** or **recommend a separately ++approved Phase 2 experiment** after treatment evidence and independent review exist. +diff --git a/docs/session-broker-effect-lifecycle-experiment.md b/docs/session-broker-effect-lifecycle-experiment.md +new file mode 100644 +index 00000000..d6881af4 +--- /dev/null ++++ b/docs/session-broker-effect-lifecycle-experiment.md +@@ -0,0 +1,252 @@ ++# Session broker Effect lifecycle experiment ++ ++Status: Phase 0 control frozen; Phase 1 treatment not started. ++ ++## Question ++ ++The first experiment replaced only the generic connection's three native timers. It paid Effect's ++fixed runtime and audit cost without removing lifecycle ownership. This follow-up asks a narrower, ++falsifiable question: ++ ++> Can one process-owned Effect lifecycle supervise Hunk's complete producer startup and connection ++> path while deleting independent retry/timer ownership across both the generic connection and the ++> Hunk client? ++ ++This is a disposable experiment, not production migration approval. ++ ++## Identities ++ ++- Control source: `42d2b9dd2f3144e33080f159f4cc9c2824ad1708` ++- Control source description: `fix(ui): restore review stream responsiveness (#942)` ++- Required treatment dependency: exactly `effect@3.22.1`, only in ++ `packages/session-broker/package.json` ++- Required Bun: `1.3.14` ++- First experiment patch SHA-256: ++ `f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56` ++- First experiment remains untouched in `/home/bentlegen/Projects/hunk-effect-experiment`. ++ ++The control harness records the system Node version, Bun's embedded Node version, OS, kernel, ++architecture, lockfile, fixtures, all changed-file identities, and fresh command transcripts. It ++creates a complete reconstructable binary patch through a temporary Git index so untracked support ++files are included. The first-experiment patch path is optional; when unavailable, evidence records ++only its frozen expected hash. Raw evidence lives under ignored `tmp/session-lifecycle-effect/`. ++ ++## Scope ++ ++Phase 1 may change only the producer lifecycle path: ++ ++```text ++ensure daemon ++-> signed hello/connect ++-> register ++-> heartbeat/reconnect ++-> terminal stop ++``` ++ ++Candidate production modules are: ++ ++- `packages/session-broker/src/connection.ts` ++- a new private Effect-backed lifecycle module in `packages/session-broker/src/` ++- `src/session/broker/brokerClient.ts` ++- polling mechanics only in `src/session/broker/brokerLauncher.ts` ++- the interactive-process composition root in `src/ui/runInteractiveApp.tsx` ++ ++Phase 1 must not change the daemon engine, Node adapter, Bun adapter, authentication, ++cryptography, exact validation, broker state, resource budgets, ownership policy, delivery ++semantics, browser-review authority, or public Effect-free API style. Phase 2 is forbidden without a ++new decision after the Phase 1 gate. ++ ++## Frozen control semantics ++ ++The treatment must preserve these observations unless this document names an explicit difference: ++ ++1. `SessionBrokerConnection.start()` constructs its first socket synchronously, so a construction ++ throw escapes synchronously. ++2. Heartbeat uses delayed fixed-rate native `setInterval`: activation arms one interval, no heartbeat ++ is sent before its first callback, and the runtime owns cadence/catch-up behavior. ++3. Producer handshake timeout starts with socket creation and is cleared by activation, close, or ++ terminal stop. ++4. Reconnect preparation is not cancellable. Stop may win while preparation is pending; the foreign ++ Promise still settles, but no new socket may be created afterward. ++5. Connection stop is synchronous and terminal. It closes the current socket and refuses future ++ starts, but it does not await crypto, reconnect preparation, or app bridge work. ++6. Concurrent first `SessionBrokerClient.start()` calls return the same active attempt Promise. ++7. A first startup failure is warned once, resolves the public first-attempt Promise, and schedules a ++ complete retry. An explicit `start()` while that retry timer is waiting starts an immediate new ++ attempt; the existing retry timer remains owned until stop or callback. ++8. Client stop is synchronous and terminal. It does not cancel already-started health, credential, ++ crypto, filesystem, or detached-spawn work, but late settlement cannot connect, warn, or schedule ++ another retry. ++9. Launcher lock owners receive a fresh full health-poll timeout after the outer deadline was ++ established. This can exceed the nominal startup timeout. Lock release remains in `finally`. ++10. Fetch abort timers and TCP socket timeouts are native transport controls, not lifecycle-loop ++ ownership targeted by Phase 1. ++11. One generic connection object retains registration, snapshot, bridge queue, command budgets, ++ and reconnect ownership across socket generations. ++ ++## Explicit treatment decisions ++ ++These are decisions, not parity claims: ++ ++- **Heartbeat:** use a delayed first tick followed by Effect `Schedule.fixed`. Any observable ++ catch-up difference from native `setInterval` is reported as a treatment difference. ++- **Defects:** `createSessionBrokerProcessLifecycle` accepts the ordinary TypeScript option ++ `{ onDefect?: (message: string) => void }`. Unexpected lifecycle defects call it only with the ++ fixed text `Session broker lifecycle failed unexpectedly.` This is an intentional replacement for ++ today's mixture of uncaught timer exceptions and unhandled Promise rejections. Raw Effect Causes, ++ URLs, credentials, payloads, callback values, and defect objects must never reach this hook or the ++ console. ++- **Shutdown:** public `start()`/`stop()` gates remain synchronous and terminal. A separate internal ++ settlement Promise describes eventual scope closure; synchronous stop never claims that async ++ finalization has completed. ++- **Cancellation:** Effect interruption does not prove cancellation of foreign Promises or detached ++ processes. Phase 1 does not add `AbortSignal` to health, credential, crypto, filesystem, TCP, or ++ spawn operations. Synchronous terminal/epoch gates prevent late commits while native work settles. ++- **Runtime ownership:** `runInteractiveApp` constructs exactly one process lifecycle, runtime, and ++ unref-aware Clock and injects it through an ordinary TypeScript façade. There is no package-global ++ singleton, fallback runtime, runtime per connection, or Effect import outside the private package ++ lifecycle implementation. ++ ++## Frozen treatment source shape ++ ++The disposable treatment uses these exact mechanical names so Phase 1 evidence is falsifiable: ++ ++- implementation classes: `EffectSessionBrokerProcessLifecycle`, ++ `EffectSessionBrokerStartupSupervisor`, and `EffectSessionBrokerConnectionSupervisor`; ++- `SessionBrokerConnection` owns exactly one ++ `connectionSupervisor: SessionBrokerConnectionSupervisor` property. Its complete treatment ++ property set is frozen as `socket`, `activeSocket`, `bridge`, `limits`, `queuedMessages`, ++ `executingMessages`, `queuedCommandCountBudget`, `queuedCommandByteBudget`, `draining`, `stopped`, ++ `registration`, `snapshot`, `producerHellos`, `options`, and `connectionSupervisor`; ++- `SessionBrokerClient` owns exactly one ++ `startupSupervisor: SessionBrokerStartupSupervisor` property and retains its terminal `stopped` ++ commit gate. Its complete treatment property set is frozen as `connection`, `bridge`, `stopped`, ++ `lastConnectionWarning`, `credentials`, `waitingForIncumbentExit`, `incumbentLaunchFingerprint`, ++ `registration`, `snapshot`, `timing`, and `startupSupervisor`; ++- `createUnrefClock()` is the only lexical region allowed to call raw `setTimeout` or ++ `clearTimeout`; the process lifecycle calls it at exactly one construction site; ++- `runDelayedFixedHeartbeat()` performs the initial `Effect.sleep` and contains the only ++ `Schedule.fixed` call; ++- `ManagedRuntime.make` occurs exactly once in `processLifecycle.ts`; ++- `createSessionBrokerProcessLifecycle()` is called in production exactly once, from ++ `src/ui/runInteractiveApp.tsx`. ++ ++Conditional AST gates reject native timing calls in converted connection/client paths, `Bun.sleep` ++in the launcher, any property outside those exact domain sets, Promise/timer/task/fiber/handle ++owners, task registries, per-delay classes, additional runtime/Clock construction, or factory calls ++elsewhere. Existing connection queues and `producerHellos` remain allowed only by exact name. These ++named mechanical gates do ++not prove scheduling, interruption, shutdown, authentication, or resource semantics; independent ++review remains mandatory. ++ ++## Mechanical ownership inventory ++ ++The control owns these independent lifecycle mechanisms: ++ ++| Owner | Mechanism | Phase 1 disposition | ++| ------------------ | ------------------------------------------- | ------------------------------------- | ++| generic connection | reconnect timeout handle | delete into one generation supervisor | ++| generic connection | heartbeat interval handle | delete into one generation supervisor | ++| generic connection | handshake timeout WeakMap | delete into one generation supervisor | ++| Hunk client | outer reconnect timeout handle | delete into one startup supervisor | ++| Hunk client | active startup Promise field | delete into one startup supervisor | ++| Hunk client | terminal `stopped` gate | retain as public commit fence | ++| launcher | health deadline/poll loop and `Bun.sleep` | route through supervised attempt time | ++| launcher | launch-lock ownership and `finally` release | retain as cross-process authority | ++| launcher | fetch abort and TCP socket timeout | retain as native transport controls | ++ ++Renaming native timer fields to task/fiber fields, adding equivalent task Sets/maps, or retaining ++parallel native scheduling is not ownership reduction. ++ ++## Phase 1 gate ++ ++Phase 1 is promising only if all of the following hold: ++ ++1. The connection's reconnect, heartbeat, and handshake timer ownership becomes one whole-generation ++ supervisor with no per-delay task handles or task registry. ++2. The client's outer retry timer and startup Promise owner become one startup supervisor. ++3. The reduction spans both modules and deletes at least three independent native ownership ++ mechanisms. ++4. Exactly one runtime and one custom Clock are constructed at the interactive process root. ++5. Security/domain state stays explicit and byte-for-byte protocol behavior remains unchanged. ++6. Every frozen control characterization remains green, or a difference listed above is measured ++ and reported honestly. ++7. Bun and real Node authenticate/register where supported and exit with handshake, heartbeat, and ++ reconnect maintenance pending. Hunk startup timing is a Bun product path; absence of a stable ++ real-Node Hunk startup fixture is recorded rather than simulated. ++8. No Effect type appears in public declarations; core and `src/**` do not import Effect; Bun never ++ imports or executes `ws`. ++9. The bounded reconnect-close-policy canary reaches the fixed defect hook in treatment, its random ++ marker appears in no generated text evidence, and the hook/console contain only the fixed message. ++ This canary does not claim coverage of credentials, application payloads, finalizers, diagnostics, ++ snapshots, or rendered Effect Causes. ++10. Independent reviewers can find stop gates, retries, authority rechecks, and releases without ++ relying on Effect expertise. ++11. Bundle, startup, registration, command, reconnect, stop, CPU, and RSS evidence is recorded. A ++ greater-than-25% standalone broker increase requires a material, independently reviewed ++ ownership win; the coarse 1 MiB ceiling alone is insufficient. ++ ++Failure of any hard boundary, ownership, security, process-exit, or auditability item ends the ++experiment as a null result. Phase 2 never starts automatically. ++ ++## Evidence protocol ++ ++One unchanged harness, `scripts/session-broker-effect-lifecycle-experiment.ts`, collects both arms. ++It stores full command transcripts and hashes rather than truncated output. Each arm contains: ++ ++- `result.json` ++- `source-identity.json` ++- `ownership-inventory.json` ++- `semantics.json` ++- `validation/manifest.json` ++- three retained Bun broker bundles and three retained Node broker bundles ++- identities and timings for three compiled Hunk builds whose binaries remain in `dist/` and are not ++ retained as arm artifacts ++- raw warmups/samples for stable local fixtures ++- process-exit results ++- dependency/declaration/canary evidence ++- `MANIFEST.sha256` and `MANIFEST.attestation.sha256` ++ ++Every invocation clears the complete arm output first, then itself runs focused tests, the real Node ++suite, typecheck, lint, dependency boundaries, the full unit suite, and PTY integration. Non-zero ++exit fails the arm; counts are recorded only when a stable Bun/TAP summary can be parsed. A requested ++metric without a stable fixture is recorded as `not-measured` with a reason. It is never replaced ++with test-command duration or synthetic evidence. Listener acquisition and daemon ordered stop are ++unchanged transport context, not producer lifecycle startup/stop. Cold Bun subprocess lifecycle ++readiness and synchronous connection stop are the lifecycle measurements. CPU and RSS are coarse, ++non-isolated observations from which no strong performance or security conclusion may be drawn. ++ ++Treatment invocation additionally requires ++`--control-attestation-sha256 `. The harness first verifies that external hash against ++`control/MANIFEST.attestation.sha256`, verifies the attested `MANIFEST.sha256`, and then verifies ++every manifest entry before trusting control identities. It freezes every existing control ++experiment-support input hash except the results report, raw evidence, and exactly ++`packages/session-broker/src/connection.test.ts` plus `src/session/broker/brokerClient.test.ts`. ++Those two tests may change only to inject the process lifecycle: AST evidence requires every ordered ++control test name to remain, each matching body to retain at least its control assertion-call count, ++and no treatment test to use skip/todo/only. New treatment tests may be added. The plan, harness, ++analyzer, source-boundary test, launcher tests, fixtures, and runners remain exact-frozen. This is ++mechanical anti-weakening evidence plus independent review, not proof. The parent must anchor the final attestation-file hash outside the arm before ++Phase 1. This is local integrity and reproducibility evidence, not a cryptographic signer or trusted ++timestamp. ++ ++The treatment production change set is exact: `bun.lock`, `packages/session-broker/package.json`, ++`packages/session-broker/src/{index,processLifecycle,connection}.ts`, ++`src/session/broker/{brokerClient,brokerLauncher}.ts`, and `src/ui/runInteractiveApp.tsx` are the only ++allowed production changes. Every item except the optional launcher change is required. Daemon, ++adapters, authentication, crypto, validation, core, budgets, and browser-review production files ++remain forbidden. The Bun fixture is bundled by `Bun.build` through a `ws`/`ws/*` rejecting ++`onResolve` plugin using a package-bundling target, a direct `ws` import probe must fail under that ++plugin, and only the produced bundle is executed by Bun. AST specifier checks remain an independent ++backstop. Production modules may not import/re-export/require/dynamically import `.test.ts`, `test/`, ++`docs/`, `scripts/`, or any experiment-support module. Effect specifier scans include every tracked ++or untracked TypeScript source in the repository; ++only `processLifecycle.ts` and its unreachable dedicated `processLifecycle.test.ts` may import it. ++ ++## Operating constraints ++ ++- Use one writer and fresh independent reviewers. ++- Do not edit or delete the first experiment. ++- Do not commit, push, open a PR, merge, publish, or begin production migration. ++- Do not begin daemon/adapters Phase 2 without separate approval. ++- A well-attested null result is a successful experiment outcome. +diff --git a/packages/session-broker-bun/src/connectionRuntime.test.ts b/packages/session-broker-bun/src/connectionRuntime.test.ts +new file mode 100644 +index 00000000..069cade3 +--- /dev/null ++++ b/packages/session-broker-bun/src/connectionRuntime.test.ts +@@ -0,0 +1,92 @@ ++import { expect, test } from "bun:test"; ++import { mkdtemp, rm, writeFile } from "node:fs/promises"; ++import { tmpdir } from "node:os"; ++import { join, resolve } from "node:path"; ++ ++const fixture = resolve( ++ import.meta.dir, ++ "../../../test/session-broker-runtime/bun-connection-fixture.ts", ++); ++ ++/** Reject any ws module reachable from a Bun fixture build graph. */ ++function rejectWsPlugin(onRejected: (specifier: string) => void): Bun.BunPlugin { ++ return { ++ name: "reject-ws-in-bun-session-fixture", ++ setup(builder) { ++ builder.onResolve({ filter: /^ws(?:\/|$)/ }, (args) => { ++ onRejected(args.path); ++ throw new Error(`Forbidden ws resolution in Bun lifecycle fixture: ${args.path}`); ++ }); ++ }, ++ }; ++} ++ ++/** Run one bundled Bun fixture with a hard test-owned timeout and retain its transcript. */ ++async function runFixture(bundle: string, args: readonly string[]) { ++ const child = Bun.spawn({ ++ cmd: [process.execPath, bundle, ...args], ++ cwd: resolve(import.meta.dir, "../../.."), ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const timeout = setTimeout(() => child.kill(), 3_000); ++ const [stdout, stderr, exitCode] = await Promise.all([ ++ new Response(child.stdout).text(), ++ new Response(child.stderr).text(), ++ child.exited, ++ ]); ++ clearTimeout(timeout); ++ return { stdout, stderr, exitCode }; ++} ++ ++test("bundles without ws, authenticates, registers, and leaves maintenance time unref'ed", async () => { ++ const directory = await mkdtemp(join(tmpdir(), "hunk-bun-lifecycle-fixture-")); ++ try { ++ const probe = join(directory, "ws-probe.ts"); ++ await writeFile(probe, 'import WebSocket from "ws"; console.log(WebSocket);\n'); ++ const rejected: string[] = []; ++ let probeFailed = false; ++ try { ++ await Bun.build({ ++ entrypoints: [probe], ++ outdir: join(directory, "probe"), ++ target: "node", ++ plugins: [rejectWsPlugin((specifier) => rejected.push(specifier))], ++ }); ++ } catch { ++ probeFailed = true; ++ } ++ expect(probeFailed).toBe(true); ++ expect(rejected).toEqual(["ws"]); ++ ++ const fixtureRejected: string[] = []; ++ const bundle = join(directory, "bun-connection-fixture.js"); ++ const fixtureBuild = await Bun.build({ ++ entrypoints: [fixture], ++ outdir: directory, ++ naming: "bun-connection-fixture.js", ++ target: "node", ++ format: "esm", ++ sourcemap: "none", ++ plugins: [rejectWsPlugin((specifier) => fixtureRejected.push(specifier))], ++ }); ++ expect(fixtureBuild.success).toBe(true); ++ expect(fixtureRejected).toEqual([]); ++ ++ const real = await runFixture(bundle, ["real"]); ++ expect(real).toMatchObject({ exitCode: 0, stderr: "" }); ++ expect(real.stdout).toBe("signed-producer-register-observed\n"); ++ ++ const ready = await runFixture(bundle, ["lifecycle-ready"]); ++ expect(ready).toMatchObject({ exitCode: 0, stderr: "" }); ++ expect(ready.stdout).toMatch(/^lifecycle-ready:(?:control|treatment)\n$/); ++ ++ for (const mode of ["unref-handshake", "unref-heartbeat", "unref-reconnect"] as const) { ++ const result = await runFixture(bundle, [mode]); ++ expect(result).toMatchObject({ exitCode: 0, stderr: "" }); ++ expect(result.stdout).toContain(mode); ++ } ++ } finally { ++ await rm(directory, { force: true, recursive: true }); ++ } ++}); +diff --git a/packages/session-broker/package.json b/packages/session-broker/package.json +index 693d321e..96f0d424 100644 +--- a/packages/session-broker/package.json ++++ b/packages/session-broker/package.json +@@ -16,7 +16,8 @@ + } + }, + "dependencies": { +- "@hunk/session-broker-core": "workspace:*" ++ "@hunk/session-broker-core": "workspace:*", ++ "effect": "3.22.1" + }, + "engines": { + "bun": ">=1.3.14", +diff --git a/packages/session-broker/src/connection.test.ts b/packages/session-broker/src/connection.test.ts +index 2eed8032..89e28038 100644 +--- a/packages/session-broker/src/connection.test.ts ++++ b/packages/session-broker/src/connection.test.ts +@@ -1,4 +1,4 @@ +-import { describe, expect, test } from "bun:test"; ++import { afterAll, describe, expect, test } from "bun:test"; + import type { + ProducerGrant, + SessionRegistration, +@@ -9,8 +9,12 @@ import { + SESSION_BROKER_REGISTRATION_VERSION, + SESSION_BROKER_SIGNATURE_ALGORITHM, + } from "@hunk/session-broker-core"; ++import { SessionBroker } from "./broker"; ++import { SessionBrokerAuthenticator } from "./authentication"; + import { createSessionBrokerConnection } from "./connection"; ++import { createSessionBrokerDaemon } from "./daemon"; + import { createSessionBrokerProtocolParsers } from "./protocolParsers"; ++import { createSessionBrokerProcessLifecycle } from "./processLifecycle"; + import type { SessionBrokerSocketLike } from "./types"; + + interface TestSessionInfo { +@@ -28,6 +32,7 @@ class TestSocket implements SessionBrokerSocketLike { + sent: string[] = []; + throwOnSend = false; + lastClose: { code?: number; reason?: string } | null = null; ++ onSend?: (data: string) => void; + onopen: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; +@@ -36,6 +41,7 @@ class TestSocket implements SessionBrokerSocketLike { + send(data: string) { + if (this.throwOnSend) throw new Error("socket exploded"); + this.sent.push(data); ++ this.onSend?.(data); + } + + close(code?: number, reason?: string) { +@@ -108,7 +114,218 @@ const protocolParsers = createSessionBrokerProtocolParsers< + ], + }); + ++const lifecycle = createSessionBrokerProcessLifecycle(); ++afterAll(async () => { ++ lifecycle.close(); ++ await lifecycle.settled; ++}); ++ + describe("session broker connection", () => { ++ test("throws an initial socket construction failure synchronously from start", () => { ++ const failure = new Error("socket construction failed"); ++ const connection = createSessionBrokerConnection({ ++ url: "ws://broker.test/session", ++ createSocket: (): TestSocket => { ++ throw failure; ++ }, ++ registration: createRegistration(), ++ snapshot: createSnapshot(), ++ protocolParsers, ++ lifecycle, ++ }); ++ ++ expect(() => connection.start()).toThrow(failure); ++ connection.stop(); ++ }); ++ ++ test("arms delayed fixed-rate heartbeats through one cleared native interval", async () => { ++ const socket = new TestSocket(); ++ const connection = createSessionBrokerConnection({ ++ url: "ws://broker.test/session", ++ createSocket: () => socket, ++ registration: createRegistration(), ++ snapshot: createSnapshot(), ++ protocolParsers, ++ lifecycle, ++ heartbeatIntervalMs: 25, ++ }); ++ connection.start(); ++ socket.emitOpen(); ++ ++ expect(socket.sent.map((message) => JSON.parse(message).type)).toEqual(["register"]); ++ await Bun.sleep(15); ++ expect(socket.sent).toHaveLength(1); ++ await Bun.sleep(20); ++ expect(socket.sent.map((message) => JSON.parse(message).type)).toEqual([ ++ "register", ++ "heartbeat", ++ ]); ++ await Bun.sleep(25); ++ expect(socket.sent.filter((message) => JSON.parse(message).type === "heartbeat")).toHaveLength( ++ 2, ++ ); ++ ++ connection.stop(); ++ const stoppedCount = socket.sent.length; ++ await Bun.sleep(30); ++ expect(socket.sent).toHaveLength(stoppedCount); ++ }); ++ ++ test("clears each producer handshake timeout on close and terminal stop", async () => { ++ const pair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const grant: ProducerGrant = { ++ kind: "producer", ++ appId: "dev.example", ++ principalId: "producer-1", ++ keyId: "producer-key-1", ++ grantId: "producer-grant-1", ++ algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, ++ issuedAt: Date.now() - 1_000, ++ expiresAt: Date.now() + 60_000, ++ revocationId: "producer-revocation-1", ++ mayDelegate: false, ++ operations: ["register"], ++ }; ++ const originalSetTimeout = globalThis.setTimeout; ++ const originalClearTimeout = globalThis.clearTimeout; ++ const handles: Array> = []; ++ const cleared: Array> = []; ++ globalThis.setTimeout = ((_callback: () => void, _delay?: number) => { ++ const handle = { unref() {} } as ReturnType; ++ handles.push(handle); ++ return handle; ++ }) as typeof setTimeout; ++ globalThis.clearTimeout = ((handle: ReturnType) => { ++ cleared.push(handle); ++ }) as typeof clearTimeout; ++ ++ try { ++ const createConnection = (socket: TestSocket) => ++ createSessionBrokerConnection({ ++ url: "ws://broker.test/session", ++ createSocket: () => socket, ++ registration: createRegistration(), ++ snapshot: createSnapshot(), ++ protocolParsers, ++ lifecycle, ++ producerAuthentication: { ++ appId: "dev.example", ++ appRevision: 1, ++ credential: { grant, privateKey: pair.privateKey }, ++ daemon: { keyId: "daemon-key-1", publicKey: pair.publicKey }, ++ }, ++ reconnectDelayMs: 60_000, ++ resolveClose: () => ({ reconnect: false }), ++ }); ++ ++ const closedSocket = new TestSocket(); ++ const closedConnection = createConnection(closedSocket); ++ closedConnection.start(); ++ closedSocket.emitClose(); ++ closedConnection.stop(); ++ ++ const stoppedSocket = new TestSocket(); ++ const stoppedConnection = createConnection(stoppedSocket); ++ stoppedConnection.start(); ++ stoppedConnection.stop(); ++ await Bun.sleep(0); ++ ++ expect(handles).toHaveLength(2); ++ expect(cleared).toEqual(handles); ++ } finally { ++ globalThis.setTimeout = originalSetTimeout; ++ globalThis.clearTimeout = originalClearTimeout; ++ } ++ }); ++ ++ test("clears the producer handshake timeout after successful signed activation", async () => { ++ const producerPair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const daemonPair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const grant: ProducerGrant = { ++ kind: "producer", ++ appId: "dev.example", ++ principalId: "producer-1", ++ keyId: "producer-key-1", ++ grantId: "producer-grant-1", ++ algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, ++ issuedAt: Date.now() - 1_000, ++ expiresAt: Date.now() + 60_000, ++ revocationId: "producer-revocation-1", ++ mayDelegate: false, ++ operations: ["register"], ++ }; ++ const authenticator = new SessionBrokerAuthenticator({ ++ appId: grant.appId, ++ appRevision: 1, ++ generation: "generation-1", ++ daemonIdentity: { keyId: "daemon-key-1", privateKey: daemonPair.privateKey }, ++ credentials: [{ grant, publicKey: producerPair.publicKey }], ++ }); ++ const broker = new SessionBroker({ protocolParsers }); ++ const daemon = createSessionBrokerDaemon({ ++ broker, ++ appId: grant.appId, ++ producerEndpoint: "ws://broker.test/session", ++ helloAuthenticator: authenticator, ++ idleTimeoutMs: 0, ++ }); ++ const socket = new TestSocket(); ++ const peer = { ++ send: (data: string) => queueMicrotask(() => socket.emitMessage(data)), ++ close: (code?: number, reason?: string) => socket.close(code, reason), ++ markAuthenticated() {}, ++ }; ++ socket.onSend = (data) => daemon.handleConnectionMessage(peer, data); ++ ++ const originalSetTimeout = globalThis.setTimeout; ++ const originalClearTimeout = globalThis.clearTimeout; ++ const handle = { unref() {} } as ReturnType; ++ const cleared: Array> = []; ++ globalThis.setTimeout = (() => handle) as unknown as typeof setTimeout; ++ globalThis.clearTimeout = ((value: ReturnType) => { ++ cleared.push(value); ++ }) as typeof clearTimeout; ++ ++ const connection = createSessionBrokerConnection({ ++ url: "ws://broker.test/session", ++ createSocket: () => socket, ++ registration: createRegistration(), ++ snapshot: createSnapshot(), ++ protocolParsers, ++ lifecycle, ++ producerAuthentication: { ++ appId: grant.appId, ++ appRevision: 1, ++ credential: { grant, privateKey: producerPair.privateKey }, ++ daemon: { keyId: "daemon-key-1", publicKey: daemonPair.publicKey }, ++ }, ++ }); ++ try { ++ connection.start(); ++ socket.emitOpen(); ++ for (let index = 0; index < 200 && broker.getSessionIds().length === 0; index += 1) { ++ await Bun.sleep(1); ++ } ++ expect(broker.getSessionIds()).toEqual(["session-1"]); ++ expect(cleared).toEqual([handle]); ++ } finally { ++ connection.stop(); ++ daemon.shutdown(); ++ await daemon.stopped; ++ globalThis.setTimeout = originalSetTimeout; ++ globalThis.clearTimeout = originalClearTimeout; ++ } ++ }); ++ + test("registers on open and sends later snapshot updates", () => { + const sockets: TestSocket[] = []; + const connection = createSessionBrokerConnection< +@@ -127,6 +344,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + }); + + connection.start(); +@@ -184,6 +402,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + producerAuthentication: { + appId: "dev.example", + appRevision: 1, +@@ -220,6 +439,7 @@ describe("session broker connection", () => { + registration, + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + }); + connection.start(); + socket.emitOpen(); +@@ -249,6 +469,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + }); + + connection.start(); +@@ -317,6 +538,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers: transformingParsers, ++ lifecycle, + bridge: { + dispatchCommand: async (message) => { + bridgedInput = message.input; +@@ -360,6 +582,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + bridge: { + dispatchCommand: async () => { + dispatched += 1; +@@ -438,6 +661,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers: boundedParsers, ++ lifecycle, + bridge: { + dispatchCommand: async () => { + bridgeCalls += 1; +@@ -484,6 +708,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers: boundedParsers, ++ lifecycle, + bridge: { + dispatchCommand: async () => { + bridgeCalls += 1; +@@ -556,6 +781,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers: lateParsers, ++ lifecycle, + bridge: { + dispatchCommand: async () => { + bridgeCalls += 1; +@@ -602,6 +828,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + bridge: { dispatchCommand: () => commandResult }, + reconnectDelayMs: 1, + }); +@@ -647,6 +874,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + reconnectDelayMs: 1, + }); + +@@ -696,6 +924,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + reconnectDelayMs: 1_000, + }); + +@@ -744,6 +973,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + }); + connection.start(); + socket.emitOpen(); +@@ -786,6 +1016,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + bridge: { dispatchCommand: () => never }, + }); + connection.start(); +@@ -827,6 +1058,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + bridge: { dispatchCommand: () => never }, + reconnectDelayMs: 1, + limits: { maxPreBridgeCommands: 1 }, +@@ -881,6 +1113,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + bridge: { + dispatchCommand: async (message) => { + started.push(message.requestId); +@@ -934,6 +1167,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + producerAuthentication: { + appId: "dev.example", + appRevision: 1, +@@ -980,6 +1214,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + reconnectDelayMs: 1, + prepareReconnect: () => prepare(), + onWarning: (message) => warnings.push(message), +@@ -1029,6 +1264,7 @@ describe("session broker connection", () => { + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, ++ lifecycle, + reconnectDelayMs: 5, + resolveClose: (event) => + event.reason === "stop" +diff --git a/packages/session-broker/src/connection.ts b/packages/session-broker/src/connection.ts +index 6d17d331..c67527df 100644 +--- a/packages/session-broker/src/connection.ts ++++ b/packages/session-broker/src/connection.ts +@@ -16,6 +16,11 @@ import { + } from "@hunk/session-broker-core"; + import type { SessionBrokerProtocolParsers } from "./protocolParsers"; + import { parseSessionBrokerJsonText } from "./protocolParsers"; ++import type { ++ SessionBrokerConnectionGenerationControls, ++ SessionBrokerConnectionSupervisor, ++ SessionBrokerProcessLifecycle, ++} from "./processLifecycle"; + import { + answerSessionBrokerHelloChallenge, + createSessionBrokerHelloRequest, +@@ -81,6 +86,7 @@ export interface SessionBrokerConnectionOptions< + snapshot: SessionSnapshot; + bridge?: SessionBrokerConnectionBridge | null; + protocolParsers: SessionBrokerProtocolParsers; ++ lifecycle: SessionBrokerProcessLifecycle; + producerAuthentication?: { + readonly appId: string; + readonly appRevision: number; +@@ -92,7 +98,9 @@ export interface SessionBrokerConnectionOptions< + openState?: number; + resolveClose?: (event: SessionBrokerSocketCloseEvent) => SessionBrokerConnectionCloseDirective; + /** Prepare application-owned discovery before one reconnect attempt opens a new socket. */ +- prepareReconnect?: () => Promise; ++ prepareReconnect?: ( ++ timing: import("./processLifecycle").SessionBrokerStartupAttempt, ++ ) => Promise; + onConnected?: () => void; + onWarning?: (message: string) => void; + limits?: SessionBrokerLimitOptions["limits"]; +@@ -120,12 +128,9 @@ export class SessionBrokerConnection< + private readonly queuedCommandCountBudget: ResourceBudget; + private readonly queuedCommandByteBudget: ResourceBudget; + private draining = false; +- private reconnectTimer: ReturnType | null = null; +- private heartbeatTimer: ReturnType | null = null; + private stopped = false; + private registration: SessionRegistration; + private snapshot: SessionSnapshot; +- private readonly handshakeTimers = new WeakMap>(); + private readonly producerHellos = new WeakMap< + Socket, + { +@@ -133,6 +138,7 @@ export class SessionBrokerConnection< + pending?: PendingSessionBrokerHello | null; + } + >(); ++ private readonly connectionSupervisor: SessionBrokerConnectionSupervisor; + + constructor( + private readonly options: SessionBrokerConnectionOptions< +@@ -160,6 +166,25 @@ export class SessionBrokerConnection< + "maxQueuedCommandBytes", + "queue-full", + ); ++ this.connectionSupervisor = options.lifecycle.createConnectionSupervisor< ++ Socket, ++ SessionBrokerSocketCloseEvent ++ >({ ++ openGeneration: (controls) => this.openGeneration(controls), ++ authenticationRequired: Boolean(options.producerAuthentication), ++ handshakeTimeoutMs: this.limits.maxHandshakeDurationMs, ++ heartbeatIntervalMs: options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS, ++ reconnectDelayMs: options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS, ++ closeHandshake: (socket) => socket.close(1008, "Session broker authentication timed out."), ++ sendHeartbeat: (socket) => ++ this.sendToSocket(socket, { ++ type: "heartbeat", ++ sessionId: this.registration.sessionId, ++ }), ++ resolveClose: (event) => options.resolveClose?.(event) ?? { reconnect: true }, ++ ...(options.prepareReconnect ? { prepareReconnect: options.prepareReconnect } : {}), ++ ...(options.onWarning ? { onWarning: options.onWarning } : {}), ++ }); + } + + start() { +@@ -167,7 +192,7 @@ export class SessionBrokerConnection< + return; + } + +- this.connect(); ++ this.connectionSupervisor.start(); + } + + stop() { +@@ -175,16 +200,8 @@ export class SessionBrokerConnection< + for (const entry of this.queuedMessages.splice(0)) entry.reservation.release(); + for (const entry of this.executingMessages) entry.reservation.release(); + this.executingMessages.clear(); +- if (this.reconnectTimer) { +- clearTimeout(this.reconnectTimer); +- this.reconnectTimer = null; +- } +- +- this.stopHeartbeat(); ++ this.connectionSupervisor.stop(); + if (this.socket) { +- const handshakeTimer = this.handshakeTimers.get(this.socket); +- if (handshakeTimer) clearTimeout(handshakeTimer); +- this.handshakeTimers.delete(this.socket); + this.socket.close(); + } + this.socket = null; +@@ -228,20 +245,16 @@ export class SessionBrokerConnection< + }); + } + +- private connect() { ++ /** Construct one socket and bridge its native callbacks into the generation supervisor. */ ++ private openGeneration( ++ controls: SessionBrokerConnectionGenerationControls, ++ ) { + if (this.stopped || this.socket) { +- return; ++ throw new Error("Session broker connection cannot open another socket generation."); + } + + const socket = this.options.createSocket(this.options.url); + this.socket = socket; +- if (this.options.producerAuthentication) { +- const timer = setTimeout(() => { +- socket.close(1008, "Session broker authentication timed out."); +- }, this.limits.maxHandshakeDurationMs); +- timer.unref?.(); +- this.handshakeTimers.set(socket, timer); +- } + + socket.onopen = () => { + if (this.options.producerAuthentication) { +@@ -254,7 +267,7 @@ export class SessionBrokerConnection< + socket.send(JSON.stringify({ type: "hello-init", hello: request })); + return; + } +- this.activateSocket(socket); ++ this.activateSocket(socket, controls); + }; + + socket.onmessage = (event) => { +@@ -268,7 +281,7 @@ export class SessionBrokerConnection< + return; + } + if (this.options.producerAuthentication && this.activeSocket !== socket) { +- void this.handleProducerHello(socket, event.data); ++ void this.handleProducerHello(socket, event.data, controls); + return; + } + let parsed: ServerMessage; +@@ -282,8 +295,6 @@ export class SessionBrokerConnection< + throw new BrokerCapacityError("capacity-exceeded", "maxCommandInputBytes"); + } + } catch { +- // Never invoke the app bridge after a malformed or mismatched command contract. Closing +- // prevents this producer from retaining daemon assumptions that were not actually parsed. + socket.close(1008, "Malformed session broker command."); + return; + } +@@ -293,13 +304,9 @@ export class SessionBrokerConnection< + + socket.onclose = (event) => { + const wasAuthenticated = this.activeSocket === socket; +- const handshakeTimer = this.handshakeTimers.get(socket); +- if (handshakeTimer) clearTimeout(handshakeTimer); +- this.handshakeTimers.delete(socket); + if (this.socket === socket) { + this.socket = null; + this.activeSocket = null; +- this.stopHeartbeat(); + } + + this.queuedMessages = this.queuedMessages.filter((queued) => { +@@ -307,40 +314,25 @@ export class SessionBrokerConnection< + queued.reservation.release(); + return false; + }); +- // Executing bridge work retains its reservation until its promise settles. A disconnect +- // prevents its response from migrating but does not make the retained input or work vanish. +- if (this.stopped) { +- return; +- } +- +- const directive = this.options.resolveClose?.({ ++ controls.closed({ + code: event.code, + reason: event.reason, + authenticated: wasAuthenticated, +- }) ?? { reconnect: true }; +- if (directive.warning) { +- this.options.onWarning?.(directive.warning); +- } +- +- if (directive.reconnect !== false) { +- this.scheduleReconnect(); +- } ++ }); + }; + + socket.onerror = () => { +- // Normalize raw socket errors through onclose so reconnect and warning policy stays in one +- // place instead of splitting behavior across runtime-specific error events. + socket.close(); + }; ++ return socket; + } + +- private activateSocket(socket: Socket) { ++ private activateSocket( ++ socket: Socket, ++ controls: SessionBrokerConnectionGenerationControls, ++ ) { + if (this.socket !== socket || this.activeSocket === socket) return; + this.activeSocket = socket; +- const handshakeTimer = this.handshakeTimers.get(socket); +- if (handshakeTimer) clearTimeout(handshakeTimer); +- this.handshakeTimers.delete(socket); +- this.startHeartbeat(); + this.options.onConnected?.(); + this.sendToSocket(socket, { + type: "register", +@@ -348,10 +340,15 @@ export class SessionBrokerConnection< + snapshot: this.snapshot, + }); + void this.flushQueuedMessages(socket); ++ controls.authenticated(); + } + + /** Verify the daemon challenge and acknowledgement before registration leaves this process. */ +- private async handleProducerHello(socket: Socket, message: unknown) { ++ private async handleProducerHello( ++ socket: Socket, ++ message: unknown, ++ controls: SessionBrokerConnectionGenerationControls, ++ ) { + try { + if (typeof message === "string" && utf8ByteLength(message) > this.limits.maxWsMessageBytes) { + socket.close(1009, "Session broker authentication message exceeded its limit."); +@@ -382,61 +379,12 @@ export class SessionBrokerConnection< + if (!hello.pending) throw new Error(); + const envelope = exactHelloEnvelope(value, "hello-ack", "ack"); + await verifyProducerHelloAck(hello.pending, envelope.ack as SessionBrokerProducerHelloAck); +- this.activateSocket(socket); ++ this.activateSocket(socket, controls); + } catch { + socket.close(1008, "Session broker authentication failed."); + } + } + +- private scheduleReconnect(delayMs = this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS) { +- if (this.reconnectTimer || this.stopped) return; +- +- this.reconnectTimer = setTimeout(() => { +- this.reconnectTimer = null; +- void this.prepareAndReconnect(); +- }, delayMs); +- this.reconnectTimer.unref?.(); +- } +- +- /** Run app discovery once per retry while retaining this connection's aggregate budgets. */ +- private async prepareAndReconnect() { +- try { +- await this.options.prepareReconnect?.(); +- } catch (error) { +- if (this.stopped) return; +- this.options.onWarning?.( +- error instanceof Error ? error.message : "Session broker reconnect preparation failed.", +- ); +- this.scheduleReconnect(); +- return; +- } +- if (!this.stopped) this.connect(); +- } +- +- private startHeartbeat() { +- if (this.heartbeatTimer) { +- return; +- } +- +- this.heartbeatTimer = setInterval(() => { +- this.send({ +- type: "heartbeat", +- sessionId: this.registration.sessionId, +- }); +- }, this.options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS); +- +- this.heartbeatTimer.unref?.(); +- } +- +- private stopHeartbeat() { +- if (!this.heartbeatTimer) { +- return; +- } +- +- clearInterval(this.heartbeatTimer); +- this.heartbeatTimer = null; +- } +- + private send(message: SessionClientMessage) { + if (!this.activeSocket) { + return; +diff --git a/packages/session-broker/src/index.ts b/packages/session-broker/src/index.ts +index dddd0821..94b02005 100644 +--- a/packages/session-broker/src/index.ts ++++ b/packages/session-broker/src/index.ts +@@ -3,6 +3,21 @@ export * from "./types"; + export * from "./broker"; + export * from "./daemon"; + export * from "./connection"; ++import { createSessionBrokerProcessLifecycle as createSessionBrokerProcessLifecycleImplementation } from "./processLifecycle"; ++ ++export const createSessionBrokerProcessLifecycle: ++ | typeof createSessionBrokerProcessLifecycleImplementation ++ | undefined = createSessionBrokerProcessLifecycleImplementation; ++ ++export { ++ type SessionBrokerConnectionGenerationControls, ++ type SessionBrokerConnectionSupervisor, ++ type SessionBrokerConnectionSupervisorOptions, ++ type SessionBrokerProcessLifecycle, ++ type SessionBrokerStartupAttempt, ++ type SessionBrokerStartupSupervisor, ++ type SessionBrokerStartupSupervisorOptions, ++} from "./processLifecycle"; + export * from "./crypto"; + export * from "./authentication"; + export * from "./clientAuthentication"; +diff --git a/packages/session-broker/src/processLifecycle.test.ts b/packages/session-broker/src/processLifecycle.test.ts +new file mode 100644 +index 00000000..431eea4b +--- /dev/null ++++ b/packages/session-broker/src/processLifecycle.test.ts +@@ -0,0 +1,231 @@ ++import { describe, expect, test } from "bun:test"; ++import { Effect, TestClock, TestContext } from "effect"; ++import { ++ createSessionBrokerProcessLifecycle, ++ createSessionBrokerProcessLifecycleForTest, ++ type SessionBrokerConnectionGenerationControls, ++ type SessionBrokerTestProcessLifecycle, ++} from "./processLifecycle"; ++ ++/** Create one deterministic lifecycle whose Effect sleeps share a controllable TestClock. */ ++async function createTestLifecycle(onDefect?: (message: string) => void) { ++ const lifecycle = createSessionBrokerProcessLifecycleForTest(TestContext.TestContext, { ++ adjust: (milliseconds) => TestClock.adjust(milliseconds), ++ sleeps: () => TestClock.sleeps().pipe(Effect.map((values) => Array.from(values))), ++ ...(onDefect ? { onDefect } : {}), ++ }); ++ return { ++ lifecycle, ++ clock: lifecycle, ++ adjust: async (milliseconds: number) => { ++ await lifecycle.adjust(milliseconds); ++ await Promise.resolve(); ++ }, ++ }; ++} ++ ++/** Let Effect's runtime consume native callback resolutions without advancing virtual time. */ ++async function flushRuntime() { ++ for (let index = 0; index < 4; index += 1) { ++ await Bun.sleep(0); ++ await Promise.resolve(); ++ } ++} ++ ++/** Wait until the lifecycle program has registered one virtual sleep with TestClock. */ ++async function waitForScheduledSleep(clock: SessionBrokerTestProcessLifecycle, expected: number) { ++ let observed: readonly number[] = []; ++ for (let index = 0; index < 20; index += 1) { ++ observed = await clock.scheduledSleeps(); ++ if (observed.includes(expected)) return; ++ await flushRuntime(); ++ } ++ throw new Error(`Expected TestClock sleep at ${expected}; observed ${observed.join(",")}.`); ++} ++ ++describe("session broker process lifecycle", () => { ++ test("delays the first heartbeat and catches fixed-rate ticks up through TestClock", async () => { ++ const { lifecycle, clock, adjust } = await createTestLifecycle(); ++ let controls!: SessionBrokerConnectionGenerationControls<{ code: number }>; ++ let heartbeats = 0; ++ const supervisor = lifecycle.createConnectionSupervisor({ ++ openGeneration: (generationControls) => { ++ controls = generationControls; ++ return {}; ++ }, ++ authenticationRequired: false, ++ handshakeTimeoutMs: 1_000, ++ heartbeatIntervalMs: 100, ++ reconnectDelayMs: 100, ++ closeHandshake: () => undefined, ++ sendHeartbeat: () => { ++ heartbeats += 1; ++ }, ++ resolveClose: () => ({ reconnect: false }), ++ }); ++ ++ supervisor.start(); ++ controls.authenticated(); ++ await waitForScheduledSleep(clock, 100); ++ await adjust(99); ++ expect(heartbeats).toBe(0); ++ await adjust(1); ++ expect(heartbeats).toBe(1); ++ await adjust(350); ++ expect(heartbeats).toBe(4); ++ ++ supervisor.stop(); ++ lifecycle.close(); ++ await lifecycle.settled; ++ }); ++ ++ test("owns handshake expiry and serial reconnect generations under TestClock", async () => { ++ const { lifecycle, clock, adjust } = await createTestLifecycle(); ++ const controls: Array> = []; ++ let handshakeCloses = 0; ++ const supervisor = lifecycle.createConnectionSupervisor({ ++ openGeneration: (generationControls) => { ++ controls.push(generationControls); ++ return { generation: controls.length }; ++ }, ++ authenticationRequired: true, ++ handshakeTimeoutMs: 50, ++ heartbeatIntervalMs: 1_000, ++ reconnectDelayMs: 25, ++ closeHandshake: () => { ++ handshakeCloses += 1; ++ controls.at(-1)!.closed({ code: 1008 }); ++ }, ++ sendHeartbeat: () => undefined, ++ resolveClose: () => ({ reconnect: true }), ++ }); ++ ++ supervisor.start(); ++ await waitForScheduledSleep(clock, 50); ++ await adjust(49); ++ expect(handshakeCloses).toBe(0); ++ await adjust(1); ++ expect(handshakeCloses).toBe(1); ++ await adjust(25); ++ expect(controls).toHaveLength(2); ++ ++ controls[1]!.authenticated(); ++ controls[1]!.closed({ code: 1006 }); ++ await adjust(25); ++ expect(controls).toHaveLength(3); ++ ++ supervisor.stop(); ++ lifecycle.close(); ++ await lifecycle.settled; ++ }); ++ ++ test("keeps manual startup wake separate from the retained automatic retry", async () => { ++ const { lifecycle, adjust } = await createTestLifecycle(); ++ const failures: string[] = []; ++ let attempts = 0; ++ const supervisor = lifecycle.createStartupSupervisor({ ++ retryDelayMs: 100, ++ attempt: async () => { ++ attempts += 1; ++ if (attempts === 1) throw new Error("expected startup failure"); ++ }, ++ onFailure: (error) => failures.push((error as Error).message), ++ }); ++ ++ const first = supervisor.start(); ++ expect(supervisor.start()).toBe(first); ++ await first; ++ expect(failures).toEqual(["expected startup failure"]); ++ await flushRuntime(); ++ ++ const manual = supervisor.start(); ++ await manual; ++ expect(attempts).toBe(2); ++ await adjust(100); ++ expect(attempts).toBe(3); ++ ++ supervisor.stop(); ++ lifecycle.close(); ++ await lifecycle.settled; ++ }); ++ ++ test("keeps a stopped foreign attempt pending until its native Promise settles", async () => { ++ const { lifecycle } = await createTestLifecycle(); ++ let release!: () => void; ++ const foreign = new Promise((resolve) => { ++ release = resolve; ++ }); ++ const supervisor = lifecycle.createStartupSupervisor({ ++ retryDelayMs: 100, ++ attempt: () => foreign, ++ onFailure: () => undefined, ++ }); ++ ++ let settled = false; ++ const started = supervisor.start()!; ++ void started.then(() => { ++ settled = true; ++ }); ++ supervisor.stop(); ++ await flushRuntime(); ++ expect(settled).toBe(false); ++ release(); ++ await started; ++ expect(settled).toBe(true); ++ ++ lifecycle.close(); ++ lifecycle.close(); ++ await lifecycle.settled; ++ expect(await supervisor.settled).toBeUndefined(); ++ }); ++ ++ test("stops reconnect sleep and reports unexpected defects only through fixed redaction", async () => { ++ const defects: string[] = []; ++ const { lifecycle, clock, adjust } = await createTestLifecycle((message) => ++ defects.push(message), ++ ); ++ let controls!: SessionBrokerConnectionGenerationControls<{ code: number }>; ++ let generations = 0; ++ const supervisor = lifecycle.createConnectionSupervisor({ ++ openGeneration: (generationControls) => { ++ generations += 1; ++ controls = generationControls; ++ return {}; ++ }, ++ authenticationRequired: false, ++ handshakeTimeoutMs: 100, ++ heartbeatIntervalMs: 10, ++ reconnectDelayMs: 50, ++ closeHandshake: () => undefined, ++ sendHeartbeat: () => { ++ throw new Error("credential=secret payload=canary"); ++ }, ++ resolveClose: () => ({ reconnect: true }), ++ }); ++ ++ supervisor.start(); ++ controls.authenticated(); ++ await waitForScheduledSleep(clock, 10); ++ await adjust(10); ++ await flushRuntime(); ++ expect(defects).toEqual(["Session broker lifecycle failed unexpectedly."]); ++ expect(defects.join(" ")).not.toContain("secret"); ++ ++ controls.closed({ code: 1006 }); ++ supervisor.stop(); ++ await adjust(100); ++ expect(generations).toBe(1); ++ ++ lifecycle.close(); ++ lifecycle.close(); ++ await lifecycle.settled; ++ }); ++ ++ test("keeps Clock injection off the public production factory", async () => { ++ const lifecycle = createSessionBrokerProcessLifecycle(); ++ expect(createSessionBrokerProcessLifecycle.length).toBe(0); ++ expect(Object.keys(lifecycle).includes("clock")).toBe(false); ++ lifecycle.close(); ++ await lifecycle.settled; ++ }); ++}); +diff --git a/packages/session-broker/src/processLifecycle.ts b/packages/session-broker/src/processLifecycle.ts +new file mode 100644 +index 00000000..a47f30aa +--- /dev/null ++++ b/packages/session-broker/src/processLifecycle.ts +@@ -0,0 +1,515 @@ ++import { ++ Cause, ++ Clock, ++ Duration, ++ Effect, ++ Exit, ++ Fiber, ++ FiberId, ++ Layer, ++ ManagedRuntime, ++ Schedule, ++ Scope, ++} from "effect"; ++ ++const LIFECYCLE_DEFECT_MESSAGE = "Session broker lifecycle failed unexpectedly."; ++ ++export interface SessionBrokerStartupAttempt { ++ now(): number; ++ sleep(delayMs: number): Promise; ++} ++ ++export interface SessionBrokerStartupSupervisorOptions { ++ attempt: (timing: SessionBrokerStartupAttempt) => Promise; ++ retryDelayMs: number; ++ onFailure: (error: unknown) => void; ++} ++ ++export interface SessionBrokerStartupSupervisor { ++ start(): Promise | undefined; ++ stop(): void; ++ readonly settled: Promise; ++} ++ ++export interface SessionBrokerConnectionGenerationControls { ++ authenticated(): void; ++ closed(event: Event): void; ++} ++ ++export interface SessionBrokerConnectionCloseDirective { ++ reconnect?: boolean; ++ warning?: string; ++} ++ ++export interface SessionBrokerConnectionSupervisorOptions { ++ openGeneration: (controls: SessionBrokerConnectionGenerationControls) => Socket; ++ authenticationRequired: boolean; ++ handshakeTimeoutMs: number; ++ heartbeatIntervalMs: number; ++ reconnectDelayMs: number; ++ closeHandshake: (socket: Socket) => void; ++ sendHeartbeat: (socket: Socket) => void; ++ resolveClose: (event: Event) => SessionBrokerConnectionCloseDirective; ++ prepareReconnect?: (timing: SessionBrokerStartupAttempt) => Promise; ++ onWarning?: (message: string) => void; ++} ++ ++export interface SessionBrokerConnectionSupervisor { ++ start(): void; ++ stop(): void; ++ readonly settled: Promise; ++} ++ ++export interface SessionBrokerProcessLifecycle { ++ createStartupSupervisor( ++ options: SessionBrokerStartupSupervisorOptions, ++ ): SessionBrokerStartupSupervisor; ++ createConnectionSupervisor( ++ options: SessionBrokerConnectionSupervisorOptions, ++ ): SessionBrokerConnectionSupervisor; ++ close(): void; ++ readonly settled: Promise; ++} ++ ++interface DeferredSignal { ++ readonly promise: Promise; ++ resolve(value: Value): void; ++} ++ ++interface ConnectionGeneration { ++ readonly socket: Socket; ++ readonly authenticated: Promise; ++ readonly closed: Promise; ++} ++ ++interface ProcessLifecycleOptions { ++ readonly layer: Layer.Layer; ++ readonly clock?: Clock.Clock; ++ readonly onDefect?: (message: string) => void; ++ readonly adjustTestClock?: (milliseconds: number) => unknown; ++ readonly testClockSleeps?: () => unknown; ++} ++ ++export interface SessionBrokerTestProcessLifecycle extends SessionBrokerProcessLifecycle { ++ adjust(milliseconds: number): Promise; ++ scheduledSleeps(): Promise; ++} ++ ++/** Create one externally resolvable Promise used only to bridge native callbacks into a program. */ ++function deferredSignal(): DeferredSignal { ++ let resolve!: (value: Value) => void; ++ const promise = new Promise((settle) => { ++ resolve = settle; ++ }); ++ return { promise, resolve }; ++} ++ ++/** Supply Effect with unref-aware native sleeps so maintenance never retains the process. */ ++function createUnrefClock(): Clock.Clock { ++ const clock: Clock.Clock = { ++ [Clock.ClockTypeId]: Clock.ClockTypeId, ++ unsafeCurrentTimeMillis: () => Date.now(), ++ unsafeCurrentTimeNanos: () => BigInt(Date.now()) * 1_000_000n, ++ currentTimeMillis: Effect.sync(() => Date.now()), ++ currentTimeNanos: Effect.sync(() => BigInt(Date.now()) * 1_000_000n), ++ sleep: (duration) => ++ Effect.async((resume) => { ++ const timer = setTimeout(() => resume(Effect.void), Duration.toMillis(duration)); ++ timer.unref?.(); ++ return Effect.sync(() => clearTimeout(timer)); ++ }), ++ }; ++ return clock; ++} ++ ++/** Run heartbeats after one full interval and retain Effect's fixed scheduling policy thereafter. */ ++function runDelayedFixedHeartbeat(intervalMs: number, heartbeat: () => void): Effect.Effect { ++ return Effect.sleep(Duration.millis(intervalMs)).pipe( ++ Effect.zipRight( ++ Effect.sync(heartbeat).pipe(Effect.repeat(Schedule.fixed(Duration.millis(intervalMs)))), ++ ), ++ ) as Effect.Effect; ++} ++ ++/** Own one startup attempt stream, its retry wait, and all public Promise projections. */ ++class EffectSessionBrokerStartupSupervisor implements SessionBrokerStartupSupervisor { ++ private stopped = false; ++ private started = false; ++ private waiting = false; ++ private activeAttempt: Promise | undefined; ++ private manualWake: DeferredSignal | undefined; ++ private manualAttempt: DeferredSignal | undefined; ++ private program: Fiber.RuntimeFiber | undefined; ++ private resolveSettled!: () => void; ++ readonly settled = new Promise((resolve) => { ++ this.resolveSettled = resolve; ++ }); ++ ++ constructor( ++ private readonly owner: EffectSessionBrokerProcessLifecycle, ++ private readonly options: SessionBrokerStartupSupervisorOptions, ++ ) {} ++ ++ start(): Promise | undefined { ++ if (this.stopped || this.owner.isClosed()) return undefined; ++ if (this.activeAttempt) return this.activeAttempt; ++ ++ if (!this.started) { ++ this.started = true; ++ const first = deferredSignal(); ++ this.activeAttempt = first.promise; ++ this.program = this.owner.forkProgram( ++ this.run(first).pipe(Effect.ensuring(Effect.sync(() => this.resolveSettled()))), ++ ); ++ return this.activeAttempt; ++ } ++ ++ const attempt = deferredSignal(); ++ this.activeAttempt = attempt.promise; ++ this.manualAttempt = attempt; ++ this.manualWake?.resolve(); ++ return attempt.promise; ++ } ++ ++ stop(): void { ++ if (this.stopped) return; ++ this.stopped = true; ++ this.manualWake?.resolve(); ++ if (this.program) this.owner.interrupt(this.program); ++ else this.resolveSettled(); ++ } ++ ++ /** Run first, manual, and automatic attempts without moving terminal commit policy from callers. */ ++ private run(first: DeferredSignal) { ++ const attempt = (projection?: DeferredSignal) => ++ Effect.suspend(() => { ++ let foreign: Promise; ++ try { ++ foreign = Promise.resolve(this.options.attempt(this.owner.startupTiming())); ++ } catch (error) { ++ foreign = Promise.reject(error); ++ } ++ const handled = foreign.then( ++ () => false, ++ (error) => { ++ if (!this.stopped) this.options.onFailure(error); ++ return true; ++ }, ++ ); ++ if (projection) { ++ void handled.then( ++ () => { ++ projection.resolve(); ++ if (this.activeAttempt === projection.promise) this.activeAttempt = undefined; ++ }, ++ () => { ++ projection.resolve(); ++ if (this.activeAttempt === projection.promise) this.activeAttempt = undefined; ++ }, ++ ); ++ } ++ return Effect.promise(() => handled); ++ }); ++ ++ const waitAndRetry = (): Effect.Effect => ++ Effect.suspend(() => { ++ if (this.stopped) return Effect.void; ++ this.waiting = true; ++ const wake = deferredSignal(); ++ this.manualWake = wake; ++ if (this.manualAttempt) wake.resolve(); ++ const deadline = this.owner.now() + this.options.retryDelayMs; ++ const wait = (): Effect.Effect => { ++ const remaining = Math.max(0, deadline - this.owner.now()); ++ if (remaining === 0) return Effect.void; ++ return Effect.raceFirst( ++ Effect.sleep(Duration.millis(remaining)), ++ Effect.promise(() => wake.promise), ++ ).pipe( ++ Effect.flatMap(() => { ++ const manual = this.manualAttempt; ++ this.manualAttempt = undefined; ++ if (!manual) return Effect.void; ++ this.manualWake = undefined; ++ return attempt(manual).pipe( ++ Effect.zipRight( ++ Effect.sync(() => { ++ if (!this.stopped) { ++ this.manualWake = deferredSignal(); ++ } ++ }), ++ ), ++ Effect.zipRight(wait()), ++ ); ++ }), ++ ); ++ }; ++ return wait().pipe( ++ Effect.tap(() => ++ Effect.sync(() => { ++ this.waiting = false; ++ this.manualWake = undefined; ++ }), ++ ), ++ Effect.zipRight(attempt()), ++ Effect.flatMap((failed) => (failed ? waitAndRetry() : Effect.void)), ++ ); ++ }); ++ ++ return attempt(first).pipe(Effect.flatMap((failed) => (failed ? waitAndRetry() : Effect.void))); ++ } ++} ++ ++/** Own one socket-generation program including handshake, heartbeat, close, and reconnect. */ ++class EffectSessionBrokerConnectionSupervisor< ++ Socket, ++ Event, ++> implements SessionBrokerConnectionSupervisor { ++ private stopped = false; ++ private started = false; ++ private program: Fiber.RuntimeFiber | undefined; ++ private resolveSettled!: () => void; ++ readonly settled = new Promise((resolve) => { ++ this.resolveSettled = resolve; ++ }); ++ ++ constructor( ++ private readonly owner: EffectSessionBrokerProcessLifecycle, ++ private readonly options: SessionBrokerConnectionSupervisorOptions, ++ ) {} ++ ++ start(): void { ++ if (this.started || this.stopped || this.owner.isClosed()) return; ++ const initial = this.openGeneration(); ++ this.started = true; ++ this.program = this.owner.forkProgram( ++ this.run(initial).pipe(Effect.ensuring(Effect.sync(() => this.resolveSettled()))), ++ ); ++ } ++ ++ stop(): void { ++ if (this.stopped) return; ++ this.stopped = true; ++ if (this.program) this.owner.interrupt(this.program); ++ else this.resolveSettled(); ++ } ++ ++ /** Construct and wire a generation synchronously before its Effect program starts. */ ++ private openGeneration(): ConnectionGeneration { ++ const authenticated = deferredSignal(); ++ const closed = deferredSignal(); ++ const socket = this.options.openGeneration({ ++ authenticated: () => authenticated.resolve(), ++ closed: (event) => closed.resolve(event), ++ }); ++ return { socket, authenticated: authenticated.promise, closed: closed.promise }; ++ } ++ ++ /** Run generations serially so one program owns every retry and timing transition. */ ++ private run(generation: ConnectionGeneration): Effect.Effect { ++ const closed = Effect.promise(() => generation.closed); ++ const activation = this.options.authenticationRequired ++ ? Effect.raceFirst( ++ Effect.promise(() => generation.authenticated).pipe(Effect.as("authenticated" as const)), ++ Effect.raceFirst( ++ closed.pipe(Effect.map((event) => ({ event }) as const)), ++ Effect.sleep(Duration.millis(this.options.handshakeTimeoutMs)).pipe( ++ Effect.as("timeout" as const), ++ ), ++ ), ++ ) ++ : Effect.raceFirst( ++ Effect.promise(() => generation.authenticated).pipe(Effect.as("authenticated" as const)), ++ closed.pipe(Effect.map((event) => ({ event }) as const)), ++ ); ++ ++ return activation.pipe( ++ Effect.flatMap((result) => { ++ if (result === "timeout") { ++ return Effect.sync(() => this.options.closeHandshake(generation.socket)).pipe( ++ Effect.zipRight(closed), ++ ); ++ } ++ if (result !== "authenticated") return Effect.succeed(result.event); ++ return Effect.raceFirst( ++ closed, ++ runDelayedFixedHeartbeat(this.options.heartbeatIntervalMs, () => ++ this.options.sendHeartbeat(generation.socket), ++ ), ++ ); ++ }), ++ Effect.flatMap((event) => ++ Effect.sync(() => this.options.resolveClose(event)).pipe( ++ Effect.flatMap((directive) => { ++ if (directive.warning) { ++ this.options.onWarning?.(directive.warning); ++ } ++ if (this.stopped || directive.reconnect === false) return Effect.void; ++ const reconnect = (): Effect.Effect => ++ Effect.sleep(Duration.millis(this.options.reconnectDelayMs)).pipe( ++ Effect.flatMap(() => { ++ if (this.stopped) return Effect.void; ++ if (!this.options.prepareReconnect) { ++ return this.run(this.openGeneration()); ++ } ++ return Effect.tryPromise({ ++ try: () => this.options.prepareReconnect!(this.owner.startupTiming()), ++ catch: (error) => error, ++ }).pipe( ++ Effect.matchEffect({ ++ onFailure: (error) => ++ Effect.sync(() => { ++ if (!this.stopped) { ++ this.options.onWarning?.( ++ error instanceof Error ++ ? error.message ++ : "Session broker reconnect preparation failed.", ++ ); ++ } ++ }).pipe(Effect.zipRight(reconnect())), ++ onSuccess: () => ++ this.stopped ? Effect.void : this.run(this.openGeneration()), ++ }), ++ ); ++ }), ++ ); ++ return reconnect(); ++ }), ++ ), ++ ), ++ ); ++ } ++} ++ ++/** Own the process runtime, root scope, and the two lifecycle supervisor factories. */ ++class EffectSessionBrokerProcessLifecycle implements SessionBrokerProcessLifecycle { ++ private closed = false; ++ private closeStarted = false; ++ private readonly runtime; ++ private readonly scope; ++ private resolveSettled!: () => void; ++ readonly settled = new Promise((resolve) => { ++ this.resolveSettled = resolve; ++ }); ++ ++ constructor(private readonly lifecycleOptions: ProcessLifecycleOptions) { ++ this.runtime = ManagedRuntime.make(lifecycleOptions.layer); ++ this.scope = this.runtime.runSync(Scope.make()); ++ } ++ ++ createStartupSupervisor( ++ options: SessionBrokerStartupSupervisorOptions, ++ ): SessionBrokerStartupSupervisor { ++ if (this.closed) throw new Error("Session broker process lifecycle is closed."); ++ return new EffectSessionBrokerStartupSupervisor(this, options); ++ } ++ ++ createConnectionSupervisor( ++ options: SessionBrokerConnectionSupervisorOptions, ++ ): SessionBrokerConnectionSupervisor { ++ if (this.closed) throw new Error("Session broker process lifecycle is closed."); ++ return new EffectSessionBrokerConnectionSupervisor(this, options); ++ } ++ ++ close(): void { ++ if (this.closeStarted) return; ++ this.closeStarted = true; ++ this.closed = true; ++ void this.runtime ++ .runPromise(Scope.close(this.scope, Exit.void)) ++ .then(() => this.runtime.dispose()) ++ .then(this.resolveSettled, this.resolveSettled); ++ } ++ ++ isClosed(): boolean { ++ return this.closed; ++ } ++ ++ now(): number { ++ return ( ++ this.lifecycleOptions.clock?.unsafeCurrentTimeMillis() ?? ++ this.runtime.runSync(Clock.currentTimeMillis) ++ ); ++ } ++ ++ startupTiming(): SessionBrokerStartupAttempt { ++ return { ++ now: () => this.now(), ++ sleep: (delayMs) => { ++ const effect = Effect.sleep(Duration.millis(delayMs)); ++ return this.runtime.runPromise( ++ this.lifecycleOptions.clock ++ ? effect.pipe(Effect.withClock(this.lifecycleOptions.clock)) ++ : effect, ++ ); ++ }, ++ }; ++ } ++ ++ forkProgram(effect: Effect.Effect): Fiber.RuntimeFiber { ++ const clockEffect = this.lifecycleOptions.clock ++ ? effect.pipe(Effect.withClock(this.lifecycleOptions.clock)) ++ : effect; ++ const guarded = clockEffect.pipe( ++ Effect.catchAllCause((cause) => ++ Cause.isInterruptedOnly(cause) ++ ? Effect.void ++ : Effect.sync(() => this.lifecycleOptions.onDefect?.(LIFECYCLE_DEFECT_MESSAGE)), ++ ), ++ ); ++ return this.runtime.runSync(Effect.forkIn(guarded, this.scope)); ++ } ++ ++ interrupt(program: Fiber.RuntimeFiber): void { ++ program.unsafeInterruptAsFork(FiberId.none); ++ } ++ ++ adjust(milliseconds: number): Promise { ++ if (!this.lifecycleOptions.adjustTestClock) { ++ return Promise.reject(new Error("This lifecycle does not own a test Clock.")); ++ } ++ return this.runtime.runPromise( ++ this.lifecycleOptions.adjustTestClock(milliseconds) as Effect.Effect, ++ ); ++ } ++ ++ scheduledSleeps(): Promise { ++ if (!this.lifecycleOptions.testClockSleeps) { ++ return Promise.reject(new Error("This lifecycle does not own a test Clock.")); ++ } ++ return this.runtime.runPromise( ++ this.lifecycleOptions.testClockSleeps() as Effect.Effect, ++ ); ++ } ++} ++ ++/** Create the one production lifecycle with an unref-aware process Clock. */ ++export function createSessionBrokerProcessLifecycle( ++ options: { ++ onDefect?: (message: string) => void; ++ } = {}, ++): SessionBrokerProcessLifecycle { ++ const clock = createUnrefClock(); ++ return new EffectSessionBrokerProcessLifecycle({ ++ layer: Layer.succeed(Clock.Clock, clock), ++ clock, ++ ...(options.onDefect ? { onDefect: options.onDefect } : {}), ++ }); ++} ++ ++/** Create a private test lifecycle around an externally supplied Effect test layer. */ ++export function createSessionBrokerProcessLifecycleForTest( ++ layer: unknown, ++ controls: { ++ adjust: (milliseconds: number) => unknown; ++ sleeps: () => unknown; ++ onDefect?: (message: string) => void; ++ }, ++): SessionBrokerTestProcessLifecycle { ++ return new EffectSessionBrokerProcessLifecycle({ ++ layer: layer as Layer.Layer, ++ adjustTestClock: controls.adjust, ++ testClockSleeps: controls.sleeps, ++ ...(controls.onDefect ? { onDefect: controls.onDefect } : {}), ++ }); ++} +diff --git a/scripts/session-broker-effect-lifecycle-experiment.ts b/scripts/session-broker-effect-lifecycle-experiment.ts +new file mode 100644 +index 00000000..1da37549 +--- /dev/null ++++ b/scripts/session-broker-effect-lifecycle-experiment.ts +@@ -0,0 +1,1520 @@ ++#!/usr/bin/env bun ++ ++import { createHash } from "node:crypto"; ++import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; ++import { arch, cpus, platform, release } from "node:os"; ++import { basename, join, relative, resolve } from "node:path"; ++import { ++ SESSION_BROKER_REGISTRATION_VERSION, ++ SessionBroker, ++ createSessionBrokerConnection, ++ createSessionBrokerDaemon, ++ createSessionBrokerProtocolParsers, ++ type SessionBrokerSocketLike, ++} from "@hunk/session-broker"; ++import * as sessionBroker from "@hunk/session-broker"; ++import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun"; ++import { ++ allTypeScriptFiles, ++ analyzeTestContinuity, ++ analyzeTreatmentOwnership, ++ astModuleSpecifiers, ++ forbiddenProductionSupportImports, ++ repositoryTypeScriptFiles, ++} from "./session-lifecycle-ownership-analysis"; ++ ++const CONTROL_SHA = "42d2b9dd2f3144e33080f159f4cc9c2824ad1708"; ++const BEHAVIORALLY_FROZEN_TESTS = [ ++ "packages/session-broker/src/connection.test.ts", ++ "src/session/broker/brokerClient.test.ts", ++] as const; ++const FIRST_EXPERIMENT_PATCH_SHA256 = ++ "f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56"; ++const REDACTED_LIFECYCLE_DEFECT = "Session broker lifecycle failed unexpectedly."; ++const WARMUPS = 5; ++const SAMPLES = 30; ++ ++interface CommandEvidence { ++ readonly name: string; ++ readonly command: readonly string[]; ++ readonly cwd: string; ++ readonly startedAt: string; ++ readonly finishedAt: string; ++ readonly durationMs: number; ++ readonly exitCode: number; ++ readonly stdoutTranscript: string; ++ readonly stderrTranscript: string; ++ readonly stdoutSha256: string; ++ readonly stderrSha256: string; ++ readonly parsedCounts?: Record; ++} ++ ++interface SampleSeries { ++ readonly status: "measured"; ++ readonly unit: "ms"; ++ readonly warmups: readonly number[]; ++ readonly samples: readonly number[]; ++ readonly median: number; ++ readonly p95: number; ++} ++ ++interface NotMeasured { ++ readonly status: "not-measured"; ++ readonly reason: string; ++} ++ ++type Measurement = SampleSeries | NotMeasured; ++ ++type Arm = "control" | "treatment"; ++ ++interface ExperimentLifecycle { ++ close(): void; ++ readonly settled: Promise; ++} ++ ++interface LifecycleFactoryOptions { ++ readonly onDefect?: (message: string) => void; ++} ++ ++/** Hash bytes without normalizing text or line endings. */ ++function sha256(bytes: Uint8Array | string) { ++ return createHash("sha256").update(bytes).digest("hex"); ++} ++ ++/** Hash one file exactly as stored. */ ++async function hashFile(path: string) { ++ return sha256(await readFile(path)); ++} ++ ++/** Return one nearest-rank percentile from raw sorted samples. */ ++function percentile(samples: readonly number[], fraction: number) { ++ const sorted = [...samples].sort((left, right) => left - right); ++ return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)] ?? 0; ++} ++ ++/** Measure one stable local operation with frozen warmup and sample counts. */ ++async function measure(operation: () => void | Promise): Promise { ++ return measureReported(async () => { ++ const startedAt = performance.now(); ++ await operation(); ++ return performance.now() - startedAt; ++ }); ++} ++ ++/** Retain measurements reported directly by the operation rather than including fixture setup. */ ++async function measureReported(operation: () => number | Promise): Promise { ++ const warmups: number[] = []; ++ const samples: number[] = []; ++ for (let index = 0; index < WARMUPS + SAMPLES; index += 1) { ++ const duration = await operation(); ++ (index < WARMUPS ? warmups : samples).push(duration); ++ } ++ return { ++ status: "measured", ++ unit: "ms", ++ warmups, ++ samples, ++ median: percentile(samples, 0.5), ++ p95: percentile(samples, 0.95), ++ }; ++} ++ ++/** Recursively list files in stable protocol-path order. */ ++async function listFiles(root: string): Promise { ++ const entries = await readdir(root, { withFileTypes: true }); ++ const files: string[] = []; ++ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { ++ const path = join(root, entry.name); ++ if (entry.isDirectory()) files.push(...(await listFiles(path))); ++ else if (entry.isFile()) files.push(path); ++ } ++ return files; ++} ++ ++/** Parse only stable summary lines emitted by Bun test or Node's TAP runner. */ ++function parseTestCounts(stdout: string, stderr: string) { ++ const text = `${stdout}\n${stderr}`; ++ const counts: Record = {}; ++ const patterns = { ++ passed: [ ++ /(?:^|\n)\s*(\d+) pass(?:ed)?\b/m, ++ /(?:^|\n)# pass (\d+)\b/m, ++ /(?:^|\n)ℹ pass (\d+)\b/m, ++ ], ++ failed: [ ++ /(?:^|\n)\s*(\d+) fail(?:ed)?\b/m, ++ /(?:^|\n)# fail (\d+)\b/m, ++ /(?:^|\n)ℹ fail (\d+)\b/m, ++ ], ++ skipped: [ ++ /(?:^|\n)\s*(\d+) skip(?:ped)?\b/m, ++ /(?:^|\n)# skipped (\d+)\b/m, ++ /(?:^|\n)ℹ skipped (\d+)\b/m, ++ ], ++ }; ++ let ambiguous = false; ++ for (const [name, candidates] of Object.entries(patterns)) { ++ const matches = candidates.flatMap((pattern) => [ ++ ...text.matchAll(new RegExp(pattern.source, "gm")), ++ ]); ++ if (matches.length > 1) ambiguous = true; ++ if (matches.length === 1 && matches[0]?.[1]) counts[name] = Number(matches[0][1]); ++ } ++ return !ambiguous && counts.passed !== undefined ? counts : undefined; ++} ++ ++/** Run one command and persist complete stdout/stderr transcripts and exact identities. */ ++async function runCommand( ++ root: string, ++ transcriptRoot: string, ++ name: string, ++ command: readonly string[], ++ parseCounts = false, ++): Promise { ++ await mkdir(transcriptRoot, { recursive: true }); ++ const stdoutPath = join(transcriptRoot, `${name}.stdout.log`); ++ const stderrPath = join(transcriptRoot, `${name}.stderr.log`); ++ const startedAt = new Date().toISOString(); ++ const started = performance.now(); ++ const child = Bun.spawn({ ++ cmd: [...command], ++ cwd: root, ++ env: { ...process.env, NO_COLOR: "1" }, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const [stdoutBuffer, stderrBuffer, exitCode] = await Promise.all([ ++ new Response(child.stdout).arrayBuffer(), ++ new Response(child.stderr).arrayBuffer(), ++ child.exited, ++ ]); ++ const stdout = new Uint8Array(stdoutBuffer); ++ const stderr = new Uint8Array(stderrBuffer); ++ await writeFile(stdoutPath, stdout); ++ await writeFile(stderrPath, stderr); ++ return { ++ name, ++ command, ++ cwd: root, ++ startedAt, ++ finishedAt: new Date().toISOString(), ++ durationMs: performance.now() - started, ++ exitCode, ++ stdoutTranscript: relative(root, stdoutPath), ++ stderrTranscript: relative(root, stderrPath), ++ stdoutSha256: sha256(stdout), ++ stderrSha256: sha256(stderr), ++ ...(parseCounts ++ ? { ++ parsedCounts: parseTestCounts( ++ new TextDecoder().decode(stdout), ++ new TextDecoder().decode(stderr), ++ ), ++ } ++ : {}), ++ }; ++} ++ ++/** Run a required validation command and fail the arm on any non-zero exit. */ ++async function runRequiredCommand( ++ root: string, ++ transcriptRoot: string, ++ name: string, ++ command: readonly string[], ++ parseCounts = false, ++) { ++ const evidence = await runCommand(root, transcriptRoot, name, command, parseCounts); ++ if (evidence.exitCode !== 0) throw new Error(`Required command failed: ${name}.`); ++ return evidence; ++} ++ ++/** Find every installed version of one package in an npm dependency-tree projection. */ ++function findDependencyVersions(value: unknown, name: string, versions = new Set()) { ++ if (!value || typeof value !== "object") return versions; ++ const dependencies = (value as { dependencies?: unknown }).dependencies; ++ if (!dependencies || typeof dependencies !== "object") return versions; ++ for (const [dependencyName, dependency] of Object.entries(dependencies)) { ++ if (dependencyName === name) { ++ const version = (dependency as { version?: unknown })?.version; ++ if (typeof version === "string") versions.add(version); ++ } ++ findDependencyVersions(dependency, name, versions); ++ } ++ return versions; ++} ++ ++/** Construct exactly one arm lifecycle and fail closed on a missing treatment factory. */ ++function createArmLifecycle( ++ label: Arm, ++ onDefect: (message: string) => void, ++): ExperimentLifecycle | undefined { ++ const factory = ( ++ sessionBroker as typeof sessionBroker & { ++ createSessionBrokerProcessLifecycle?: ( ++ options?: LifecycleFactoryOptions, ++ ) => ExperimentLifecycle | undefined; ++ } ++ ).createSessionBrokerProcessLifecycle; ++ if (label === "control") { ++ if (factory) throw new Error("Control unexpectedly exports the treatment lifecycle factory."); ++ return undefined; ++ } ++ if (!factory) throw new Error("Treatment does not export createSessionBrokerProcessLifecycle."); ++ const lifecycle = factory({ onDefect }); ++ if ( ++ !lifecycle || ++ typeof lifecycle.close !== "function" || ++ !(lifecycle.settled instanceof Promise) ++ ) { ++ throw new Error("Treatment lifecycle factory returned an invalid lifecycle façade."); ++ } ++ return lifecycle; ++} ++ ++class FixtureSocket implements SessionBrokerSocketLike { ++ readyState = 0; ++ sent: string[] = []; ++ onopen: (() => void) | null = null; ++ onmessage: ((event: { data: unknown }) => void) | null = null; ++ onclose: ((event: { code: number; reason: string }) => void) | null = null; ++ onerror: (() => void) | null = null; ++ onSend?: (value: unknown) => void; ++ ++ send(data: string) { ++ this.sent.push(data); ++ this.onSend?.(JSON.parse(data)); ++ } ++ ++ close(code = 1000, reason = "") { ++ if (this.readyState === 3) return; ++ this.readyState = 3; ++ this.onclose?.({ code, reason }); ++ } ++ ++ open() { ++ this.readyState = 1; ++ this.onopen?.(); ++ } ++} ++ ++const protocolParsers = createSessionBrokerProtocolParsers({ ++ appRevision: 1, ++ features: [], ++ parseRegistration: (value) => value as never, ++ parseSnapshot: (value) => value as never, ++ commands: [ ++ { ++ command: "ping", ++ version: 1, ++ parseInput: (value) => (value === "ping" ? "ping" : null), ++ parseResult: (value) => (value === "pong" ? "pong" : null), ++ }, ++ ], ++}); ++ ++/** Localize the planned required lifecycle option across control and treatment declarations. */ ++function createExperimentConnection(options: Record) { ++ const factory = createSessionBrokerConnection as unknown as ( ++ value: Record, ++ ) => ReturnType; ++ return factory(options); ++} ++ ++/** Build common connection options without adding treatment types to the control. */ ++function connectionOptions(createSocket: () => FixtureSocket, lifecycle?: ExperimentLifecycle) { ++ return { ++ url: "ws://127.0.0.1/session", ++ createSocket, ++ registration: { ++ registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, ++ sessionId: "experiment-session", ++ pid: process.pid, ++ cwd: process.cwd(), ++ launchedAt: new Date(0).toISOString(), ++ info: {}, ++ }, ++ snapshot: { updatedAt: new Date(0).toISOString(), state: {} }, ++ protocolParsers, ++ heartbeatIntervalMs: 60_000, ++ reconnectDelayMs: 1, ++ ...(lifecycle ? { lifecycle } : {}), ++ }; ++} ++ ++/** Measure one explicitly unauthenticated in-memory registration projection. */ ++async function measureUnauthenticatedRegistration(lifecycle?: ExperimentLifecycle) { ++ const socket = new FixtureSocket(); ++ const connection = createExperimentConnection(connectionOptions(() => socket, lifecycle)); ++ connection.start(); ++ socket.open(); ++ if ((JSON.parse(socket.sent[0] ?? "null") as { type?: string } | null)?.type !== "register") { ++ throw new Error("Registration fixture did not emit register."); ++ } ++ connection.stop(); ++} ++ ++/** Measure one parsed command/result through the real FIFO bridge path. */ ++async function measureCommand(lifecycle?: ExperimentLifecycle) { ++ const socket = new FixtureSocket(); ++ let resolveCompleted!: () => void; ++ const completed = new Promise((resolve) => (resolveCompleted = resolve)); ++ socket.onSend = (value) => { ++ if ((value as { type?: unknown }).type === "command-result") resolveCompleted(); ++ }; ++ const connection = createExperimentConnection({ ++ ...connectionOptions(() => socket, lifecycle), ++ bridge: { dispatchCommand: async () => "pong" }, ++ }); ++ connection.start(); ++ socket.open(); ++ socket.onmessage?.({ ++ data: JSON.stringify({ ++ type: "command", ++ requestId: "experiment-request", ++ command: "ping", ++ input: "ping", ++ }), ++ }); ++ await completed; ++ connection.stop(); ++} ++ ++/** Measure one close, delay, preparation, and replacement socket creation. */ ++async function measureReconnect(lifecycle?: ExperimentLifecycle) { ++ const sockets: FixtureSocket[] = []; ++ let resolveReconnected!: () => void; ++ const reconnected = new Promise((resolve) => (resolveReconnected = resolve)); ++ const connection = createExperimentConnection({ ++ ...connectionOptions(() => { ++ const socket = new FixtureSocket(); ++ sockets.push(socket); ++ if (sockets.length === 2) resolveReconnected(); ++ return socket; ++ }, lifecycle), ++ prepareReconnect: async () => {}, ++ }); ++ connection.start(); ++ sockets[0]!.open(); ++ sockets[0]!.close(1006, "retry"); ++ await reconnected; ++ connection.stop(); ++} ++ ++/** Measure only the synchronous connection stop call, excluding fixture construction. */ ++async function measureSynchronousConnectionStop(lifecycle?: ExperimentLifecycle) { ++ const socket = new FixtureSocket(); ++ const connection = createExperimentConnection(connectionOptions(() => socket, lifecycle)); ++ connection.start(); ++ socket.open(); ++ const startedAt = performance.now(); ++ connection.stop(); ++ const duration = performance.now() - startedAt; ++ if (socket.readyState !== 3) throw new Error("Synchronous stop did not close the socket."); ++ return duration; ++} ++ ++/** Create the unchanged daemon/adapter context used by transport-only measurements. */ ++function createFixtureDaemon() { ++ return createSessionBrokerDaemon({ ++ broker: new SessionBroker({ protocolParsers }), ++ idleTimeoutMs: 0, ++ }); ++} ++ ++/** Measure native Bun listener acquisition while settling cleanup before returning. */ ++async function measureTransportListenerAcquisition() { ++ const daemon = createFixtureDaemon(); ++ const startedAt = performance.now(); ++ const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port: 0 }); ++ const duration = performance.now() - startedAt; ++ server.stop(true); ++ await server.stopped; ++ return duration; ++} ++ ++/** Measure unchanged daemon-plus-Bun-adapter ordered stop context. */ ++async function measureTransportOrderedStop() { ++ const daemon = createFixtureDaemon(); ++ const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port: 0 }); ++ server.stop(true); ++ await server.stopped; ++} ++ ++/** Bundle the Bun fixture through a ws-rejecting graph and prove the plugin with a direct probe. */ ++async function buildBunRuntimeFixture(root: string, outputRoot: string) { ++ const runtimeRoot = join(outputRoot, "bun-runtime-fixture"); ++ await mkdir(runtimeRoot, { recursive: true }); ++ const rejected: string[] = []; ++ const rejectWsPlugin = (sink: string[]): Bun.BunPlugin => ({ ++ name: "reject-ws-in-bun-session-fixture", ++ setup(builder) { ++ builder.onResolve({ filter: /^ws(?:\/|$)/ }, (args) => { ++ sink.push(args.path); ++ throw new Error(`Forbidden ws resolution in Bun lifecycle fixture: ${args.path}`); ++ }); ++ }, ++ }); ++ const probe = join(runtimeRoot, "ws-probe.ts"); ++ await writeFile(probe, 'import WebSocket from "ws"; console.log(WebSocket);\n'); ++ let probeFailed = false; ++ try { ++ await Bun.build({ ++ entrypoints: [probe], ++ outdir: join(runtimeRoot, "probe-output"), ++ target: "node", ++ plugins: [rejectWsPlugin(rejected)], ++ }); ++ } catch { ++ probeFailed = true; ++ } ++ if (!probeFailed || rejected.length !== 1 || rejected[0] !== "ws") { ++ throw new Error("Bun ws-rejection plugin direct probe did not fail closed."); ++ } ++ await rm(probe, { force: true }); ++ await rm(join(runtimeRoot, "probe-output"), { force: true, recursive: true }); ++ ++ const fixtureRejected: string[] = []; ++ const bundle = join(runtimeRoot, "bun-connection-fixture.js"); ++ const fixtureBuild = await Bun.build({ ++ entrypoints: [join(root, "test/session-broker-runtime/bun-connection-fixture.ts")], ++ outdir: runtimeRoot, ++ naming: "bun-connection-fixture.js", ++ target: "node", ++ format: "esm", ++ sourcemap: "none", ++ plugins: [rejectWsPlugin(fixtureRejected)], ++ }); ++ if (!fixtureBuild.success || fixtureRejected.length !== 0) { ++ throw new Error("Bun lifecycle fixture build graph reached ws."); ++ } ++ return { ++ bundle, ++ evidence: { ++ buildTarget: "node package-bundling graph; produced bundle executed by Bun", ++ pluginFilter: "^ws(?:\\/|$)", ++ directProbeRejected: rejected, ++ fixtureRejected, ++ bundleSha256: await hashFile(bundle), ++ }, ++ }; ++} ++ ++/** Measure cold process-to-lifecycle-ready using the gated Bun bundle in both arms. */ ++async function measureColdLifecycleReady(root: string, label: Arm, bundle: string) { ++ return measureReported(async () => { ++ const startedAt = performance.now(); ++ const child = Bun.spawn({ ++ cmd: [process.execPath, bundle, "lifecycle-ready"], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const [stdout, stderr, exitCode] = await Promise.all([ ++ new Response(child.stdout).text(), ++ new Response(child.stderr).text(), ++ child.exited, ++ ]); ++ const duration = performance.now() - startedAt; ++ const expected = `lifecycle-ready:${label}\n`; ++ if (exitCode !== 0 || stderr !== "" || stdout !== expected) { ++ throw new Error("Cold lifecycle-ready subprocess did not exercise the requested arm."); ++ } ++ return duration; ++ }); ++} ++ ++/** Build one minified standalone broker bundle and return its exact identity. */ ++async function buildBrokerBundle( ++ root: string, ++ buildRoot: string, ++ target: "bun" | "node", ++ index: number, ++) { ++ const name = `session-broker-${target}-${index}.js`; ++ const startedAt = performance.now(); ++ const result = await Bun.build({ ++ entrypoints: [join(root, "packages/session-broker/src/index.ts")], ++ target, ++ format: "esm", ++ minify: true, ++ sourcemap: "none", ++ outdir: buildRoot, ++ naming: name, ++ }); ++ if (!result.success) throw new Error(result.logs.map((entry) => entry.message).join("\n")); ++ return { ++ target, ++ index, ++ retained: true, ++ durationMs: performance.now() - startedAt, ++ bytes: (await stat(join(buildRoot, name))).size, ++ sha256: await hashFile(join(buildRoot, name)), ++ }; ++} ++ ++/** Read one file when present without treating a control-only absence as an error. */ ++async function readOptional(path: string) { ++ return readFile(path).catch((error: NodeJS.ErrnoException) => { ++ if (error.code === "ENOENT") return null; ++ throw error; ++ }); ++} ++ ++/** List the complete dirty tree as tracked differences plus untracked non-ignored files. */ ++function changedFiles(root: string) { ++ const tracked = Bun.spawnSync({ ++ cmd: ["git", "diff", "--name-only", CONTROL_SHA, "--"], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const untracked = Bun.spawnSync({ ++ cmd: ["git", "ls-files", "--others", "--exclude-standard"], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ if (tracked.exitCode !== 0 || untracked.exitCode !== 0) { ++ throw new Error(`${tracked.stderr}${untracked.stderr}`); ++ } ++ return [tracked.stdout, untracked.stdout] ++ .flatMap((bytes) => bytes.toString().split("\n")) ++ .map((path) => path.trim()) ++ .filter(Boolean) ++ .filter((path, index, paths) => paths.indexOf(path) === index) ++ .sort(); ++} ++ ++/** Classify experiment support separately so new production files cannot hide among fixtures. */ ++function isExperimentSupport(path: string) { ++ return ( ++ path === "docs/session-broker-effect-lifecycle-experiment.md" || ++ path === "docs/session-broker-effect-lifecycle-experiment-results.md" || ++ path === "scripts/session-broker-effect-lifecycle-experiment.ts" || ++ path === "scripts/session-lifecycle-ownership-analysis.ts" || ++ path === "scripts/test-session-broker-node.ts" || ++ path.startsWith("test/") || ++ /(?:^|\/)\w[^/]*\.test\.[cm]?[jt]sx?$/.test(path) ++ ); ++} ++ ++/** Produce one complete binary patch using a temporary index so untracked files are included. */ ++async function createReconstructablePatch(root: string, outputRoot: string) { ++ const indexPath = join(outputRoot, ".reconstruct-index"); ++ await rm(indexPath, { force: true }); ++ const env = { ...process.env, GIT_INDEX_FILE: indexPath }; ++ for (const command of [ ++ ["git", "read-tree", CONTROL_SHA], ++ ["git", "add", "-A", "--", "."], ++ ]) { ++ const result = Bun.spawnSync({ cmd: command, cwd: root, env, stdout: "pipe", stderr: "pipe" }); ++ if (result.exitCode !== 0) throw new Error(result.stderr.toString()); ++ } ++ const diff = Bun.spawnSync({ ++ cmd: ["git", "diff", "--cached", "--binary", CONTROL_SHA, "--"], ++ cwd: root, ++ env, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ await rm(indexPath, { force: true }); ++ if (diff.exitCode !== 0) throw new Error(diff.stderr.toString()); ++ const path = join(outputRoot, "complete-experiment.patch"); ++ await writeFile(path, diff.stdout); ++ return { ++ path: relative(root, path), ++ bytes: diff.stdout.byteLength, ++ sha256: sha256(diff.stdout), ++ includesUntracked: true, ++ }; ++} ++ ++/** Hash all changed files and every required production candidate against the frozen base. */ ++async function collectSourceIdentity( ++ root: string, ++ outputRoot: string, ++ label: Arm, ++ optionalFirstPatch?: string, ++) { ++ const changed = changedFiles(root); ++ const identities = await Promise.all( ++ changed.map(async (path) => { ++ const bytes = await readOptional(join(root, path)); ++ return { ++ path, ++ classification: isExperimentSupport(path) ? "experiment-support" : "production", ++ exists: bytes !== null, ++ ...(bytes ? { bytes: bytes.byteLength, sha256: sha256(bytes) } : {}), ++ }; ++ }), ++ ); ++ const requiredProductionPaths = [ ++ "bun.lock", ++ "packages/session-broker/package.json", ++ "packages/session-broker/src/index.ts", ++ "packages/session-broker/src/processLifecycle.ts", ++ "packages/session-broker/src/connection.ts", ++ "packages/session-broker/src/daemon.ts", ++ "packages/session-broker-node/src/serve.ts", ++ "packages/session-broker-bun/src/serve.ts", ++ "src/session/broker/brokerClient.ts", ++ "src/session/broker/brokerLauncher.ts", ++ "src/session/broker/brokerServer.ts", ++ "src/ui/runInteractiveApp.tsx", ++ ]; ++ const dynamicProductionPaths = identities ++ .filter((entry) => entry.classification === "production") ++ .map((entry) => entry.path); ++ const productionPaths = [ ++ ...new Set([...requiredProductionPaths, ...dynamicProductionPaths]), ++ ].sort(); ++ const production = await Promise.all( ++ productionPaths.map(async (path) => { ++ const current = await readOptional(join(root, path)); ++ const base = Bun.spawnSync({ ++ cmd: ["git", "show", `${CONTROL_SHA}:${path}`], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const baseBytes = base.exitCode === 0 ? new Uint8Array(base.stdout) : null; ++ return { ++ path, ++ currentExists: current !== null, ++ baseExists: baseBytes !== null, ++ currentSha256: current ? sha256(current) : null, ++ baseSha256: baseBytes ? sha256(baseBytes) : null, ++ matchesBase: ++ current === null && baseBytes === null ++ ? true ++ : current !== null && baseBytes !== null && sha256(current) === sha256(baseBytes), ++ }; ++ }), ++ ); ++ const productionMatchesBase = production.every((entry) => entry.matchesBase); ++ const changedProduction = identities ++ .filter((entry) => entry.classification === "production") ++ .map((entry) => entry.path) ++ .sort(); ++ const allowedTreatmentProduction = new Set([ ++ "bun.lock", ++ "packages/session-broker/package.json", ++ "packages/session-broker/src/index.ts", ++ "packages/session-broker/src/processLifecycle.ts", ++ "packages/session-broker/src/connection.ts", ++ "src/session/broker/brokerClient.ts", ++ "src/session/broker/brokerLauncher.ts", ++ "src/ui/runInteractiveApp.tsx", ++ ]); ++ const requiredTreatmentProduction = [ ++ "bun.lock", ++ "packages/session-broker/package.json", ++ "packages/session-broker/src/index.ts", ++ "packages/session-broker/src/processLifecycle.ts", ++ "packages/session-broker/src/connection.ts", ++ "src/session/broker/brokerClient.ts", ++ "src/ui/runInteractiveApp.tsx", ++ ]; ++ const productionChangeViolations = ++ label === "treatment" ++ ? [ ++ ...changedProduction ++ .filter((path) => !allowedTreatmentProduction.has(path)) ++ .map((path) => `disallowed:${path}`), ++ ...requiredTreatmentProduction ++ .filter((path) => !changedProduction.includes(path)) ++ .map((path) => `missing:${path}`), ++ ] ++ : []; ++ if (productionChangeViolations.length > 0) { ++ throw new Error( ++ `Treatment production change set rejected: ${productionChangeViolations.join(", ")}`, ++ ); ++ } ++ if (label === "control" && !productionMatchesBase) { ++ throw new Error("Control production files do not match the frozen base."); ++ } ++ if (label === "treatment" && productionMatchesBase) { ++ throw new Error("Treatment did not change the required production lifecycle path."); ++ } ++ const behavioralTestContracts = BEHAVIORALLY_FROZEN_TESTS.map((path) => ++ analyzeTestContinuity(root, join(root, path)), ++ ); ++ const reconstructablePatch = await createReconstructablePatch(root, outputRoot); ++ const observedFirstPatch = optionalFirstPatch ++ ? await readOptional(resolve(optionalFirstPatch)) ++ : null; ++ if (observedFirstPatch && sha256(observedFirstPatch) !== FIRST_EXPERIMENT_PATCH_SHA256) { ++ throw new Error("Optional first-experiment patch does not match its frozen identity."); ++ } ++ return { ++ schemaVersion: 2, ++ baseHead: CONTROL_SHA, ++ currentHead: Bun.spawnSync({ cmd: ["git", "rev-parse", "HEAD"], cwd: root }) ++ .stdout.toString() ++ .trim(), ++ changedFiles: identities, ++ behavioralTestContracts, ++ production, ++ productionMatchesBase, ++ productionChangePolicy: { ++ changedProduction, ++ allowed: [...allowedTreatmentProduction].sort(), ++ required: requiredTreatmentProduction, ++ violations: productionChangeViolations, ++ }, ++ reconstructablePatch, ++ firstExperiment: { ++ expectedPatchSha256: FIRST_EXPERIMENT_PATCH_SHA256, ++ observation: observedFirstPatch ++ ? { suppliedPath: resolve(optionalFirstPatch!), observedSha256: sha256(observedFirstPatch) } ++ : { suppliedPath: null, observedSha256: null, status: "expected-hash-only" }, ++ }, ++ }; ++} ++ ++/** Enforce AST-derived ownership gates and retain every relevant source location. */ ++async function collectOwnership(root: string, label: Arm) { ++ const analysis = analyzeTreatmentOwnership(root); ++ if (label === "control") { ++ if ( ++ analysis.treatmentPresent || ++ analysis.locations.connectionNativeTiming.length === 0 || ++ analysis.locations.clientNativeTiming.length === 0 || ++ analysis.locations.launcherBunSleep.length === 0 ++ ) { ++ throw new Error("Control no longer exposes the frozen native lifecycle mechanisms."); ++ } ++ } else if (!analysis.treatmentPresent || analysis.violations.length > 0) { ++ throw new Error(`Treatment ownership gate failed: ${analysis.violations.join("; ")}`); ++ } ++ return { schemaVersion: 3, label, ...analysis }; ++} ++ ++/** Label semantics from AST-derived mechanism locations without claiming semantic proof. */ ++function collectSemantics(label: Arm, ownership: Awaited>) { ++ const locations = ownership.locations; ++ return { ++ schemaVersion: 3, ++ label, ++ heartbeat: ++ locations.fixedHeartbeatSchedule.length === 1 ++ ? "delayed-Effect-Schedule.fixed-mechanical-match" ++ : "native-timing-control", ++ connectionOwner: ++ locations.connectionSupervisorProperties.length === 1 ++ ? "one-frozen-supervisor-property" ++ : "native-timing-control", ++ clientStartupOwner: ++ locations.clientSupervisorProperties.length === 1 ++ ? "one-frozen-supervisor-property" ++ : "native-timing-control", ++ staticAnalysisLimit: ++ "AST gates prove only named mechanical source shapes; independent review must establish lifecycle semantics and correctness.", ++ cancellationBoundary: ++ "foreign native promises and detached processes are not claimed cancelled", ++ intentionalTreatmentDifferences: ++ label === "treatment" ++ ? [ ++ "Schedule.fixed catch-up may differ from native setInterval.", ++ `Unexpected lifecycle defects use the fixed message: ${REDACTED_LIFECYCLE_DEFECT}`, ++ ] ++ : [], ++ }; ++} ++ ++/** Enforce exact Effect placement and return all package declarations that mention it. */ ++async function collectDependencyPlacement(root: string, label: Arm, effectVersions: string[]) { ++ const packageList = Bun.spawnSync({ ++ cmd: ["git", "ls-files", "package.json", "**/package.json"], ++ cwd: root, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ if (packageList.exitCode !== 0) throw new Error(packageList.stderr.toString()); ++ const declarations: Array<{ path: string; section: string; value: string }> = []; ++ for (const path of packageList.stdout.toString().split("\n").filter(Boolean).sort()) { ++ const value = JSON.parse(await readFile(join(root, path), "utf8")) as Record; ++ for (const section of [ ++ "dependencies", ++ "devDependencies", ++ "peerDependencies", ++ "optionalDependencies", ++ ] as const) { ++ const dependency = (value[section] as Record | undefined)?.effect; ++ if (typeof dependency === "string") declarations.push({ path, section, value: dependency }); ++ } ++ } ++ if (label === "control") { ++ if (effectVersions.length > 0 || declarations.length > 0) { ++ throw new Error("Control unexpectedly contains Effect."); ++ } ++ } else if ( ++ effectVersions.length !== 1 || ++ effectVersions[0] !== "3.22.1" || ++ declarations.length !== 1 || ++ declarations[0]?.path !== "packages/session-broker/package.json" || ++ declarations[0]?.section !== "dependencies" || ++ declarations[0]?.value !== "3.22.1" ++ ) { ++ throw new Error( ++ "Treatment must declare and resolve exact effect@3.22.1 only in session-broker.", ++ ); ++ } ++ return { effectVersions, declarations }; ++} ++ ++/** Enforce actual Effect/ws module specifier boundaries and retain syntax-derived locations. */ ++function collectModuleBoundaries(root: string, label: Arm) { ++ const processLifecycle = "packages/session-broker/src/processLifecycle.ts"; ++ const allFiles = repositoryTypeScriptFiles(root); ++ const effectImports = allFiles.flatMap((path) => ++ astModuleSpecifiers(path) ++ .filter(({ specifier }) => specifier === "effect" || specifier.startsWith("effect/")) ++ .map(({ specifier, location }) => ({ specifier, ...location })), ++ ); ++ const allowedEffectFiles = new Set([ ++ processLifecycle, ++ "packages/session-broker/src/processLifecycle.test.ts", ++ ]); ++ const misplacedEffectImports = effectImports.filter( ++ (entry) => !allowedEffectFiles.has(entry.file), ++ ); ++ const forbiddenSupportImports = forbiddenProductionSupportImports(root); ++ const bunFiles = [ ++ ...allTypeScriptFiles(join(root, "packages/session-broker-bun")), ++ ...allTypeScriptFiles(join(root, "test/session-broker-runtime")), ++ ]; ++ const bunWsImports = bunFiles.flatMap((path) => ++ astModuleSpecifiers(path) ++ .filter(({ specifier }) => specifier === "ws" || specifier.startsWith("ws/")) ++ .map(({ specifier, location }) => ({ specifier, ...location })), ++ ); ++ if ( ++ misplacedEffectImports.length > 0 || ++ forbiddenSupportImports.length > 0 || ++ bunWsImports.length > 0 || ++ (label === "control" && effectImports.length > 0) || ++ (label === "treatment" && ++ (effectImports.length === 0 || ++ effectImports.some((entry) => !allowedEffectFiles.has(entry.file)))) ++ ) { ++ throw new Error("Effect/ws syntax-aware module boundary failed."); ++ } ++ return { ++ effectImports, ++ misplacedEffectImports, ++ forbiddenSupportImports, ++ bunWsImports, ++ bunBuildGate: ++ "The Bun runtime fixture is executed only from a bundle built with a ws/ws/* rejecting onResolve plugin; a direct ws import probe must fail under the same plugin.", ++ }; ++} ++ ++/** Trigger the frozen unexpected-close-policy defect without persisting the random marker. */ ++async function exerciseCanary( ++ label: Arm, ++ lifecycle: ExperimentLifecycle | undefined, ++ marker: string, ++ defectMessages: string[], ++) { ++ const socket = new FixtureSocket(); ++ const outputMessages: Array<{ channel: string; text: string }> = []; ++ const consoleMethodNames = new Set(); ++ for ( ++ let value: object | null = console; ++ value && value !== Object.prototype; ++ value = Object.getPrototypeOf(value) ++ ) { ++ for (const name of Object.getOwnPropertyNames(value)) { ++ if (name !== "constructor" && typeof Reflect.get(console, name) === "function") { ++ consoleMethodNames.add(name); ++ } ++ } ++ } ++ const previousConsoleDescriptors = new Map(); ++ for (const method of consoleMethodNames) { ++ previousConsoleDescriptors.set(method, Object.getOwnPropertyDescriptor(console, method)); ++ Object.defineProperty(console, method, { ++ configurable: true, ++ writable: true, ++ value: (...values: unknown[]) => { ++ outputMessages.push({ ++ channel: `console.${method}`, ++ text: values.map((value) => String(value)).join(" "), ++ }); ++ }, ++ }); ++ } ++ const previousStdoutWrite = process.stdout.write; ++ const previousStderrWrite = process.stderr.write; ++ process.stdout.write = ((chunk: unknown) => { ++ outputMessages.push({ channel: "process.stdout.write", text: String(chunk) }); ++ return true; ++ }) as typeof process.stdout.write; ++ process.stderr.write = ((chunk: unknown) => { ++ outputMessages.push({ channel: "process.stderr.write", text: String(chunk) }); ++ return true; ++ }) as typeof process.stderr.write; ++ let synchronousEscape: string | null = null; ++ if (label === "treatment" && defectMessages.length !== 0) { ++ throw new Error("Treatment emitted a lifecycle defect before the canary."); ++ } ++ defectMessages.length = 0; ++ try { ++ for (const method of ["error", "warn", "log", "info", "debug", "trace", "dir", "table"]) { ++ (console as unknown as Record void>)[method]?.("self-test"); ++ } ++ const selfTestChannels = new Set(outputMessages.map((entry) => entry.channel)); ++ for (const method of ["error", "warn", "log", "info", "debug", "trace", "dir", "table"]) { ++ if (!selfTestChannels.has(`console.${method}`)) { ++ throw new Error(`Console interception self-test missed ${method}.`); ++ } ++ } ++ outputMessages.length = 0; ++ const connection = createExperimentConnection({ ++ ...connectionOptions(() => socket, lifecycle), ++ resolveClose: () => { ++ throw new Error(marker); ++ }, ++ }); ++ connection.start(); ++ socket.open(); ++ try { ++ socket.close(1006, "canary"); ++ } catch (error) { ++ synchronousEscape = String(error); ++ } ++ for ( ++ let index = 0; ++ label === "treatment" && defectMessages.length === 0 && index < 40; ++ index += 1 ++ ) { ++ await Bun.sleep(5); ++ } ++ connection.stop(); ++ } finally { ++ for (const [method, descriptor] of previousConsoleDescriptors) { ++ if (descriptor) Object.defineProperty(console, method, descriptor); ++ else delete (console as unknown as Record)[method]; ++ } ++ process.stdout.write = previousStdoutWrite; ++ process.stderr.write = previousStderrWrite; ++ } ++ const controlExposedRawDefect = synchronousEscape?.includes(marker) ?? false; ++ if (label === "control" && !controlExposedRawDefect) { ++ throw new Error("Control canary no longer exposes the frozen synchronous close-policy defect."); ++ } ++ const canaryDefectMessages = [...defectMessages]; ++ if (label === "treatment") { ++ if (synchronousEscape !== null) { ++ throw new Error("Treatment synchronously exposed a lifecycle defect."); ++ } ++ if ( ++ canaryDefectMessages.length !== 1 || ++ canaryDefectMessages[0] !== REDACTED_LIFECYCLE_DEFECT ++ ) { ++ throw new Error("Treatment must report exactly one fixed redacted lifecycle defect."); ++ } ++ if (outputMessages.length !== 0) { ++ throw new Error( ++ "Treatment wrote process or console output during the lifecycle defect canary.", ++ ); ++ } ++ } ++ return { ++ schemaVersion: 3, ++ label, ++ markerSha256: sha256(marker), ++ exercised: "resolveClose callback defect during reconnect close policy", ++ fixedTreatmentMessage: REDACTED_LIFECYCLE_DEFECT, ++ defectCallbackMessages: canaryDefectMessages, ++ outputMessages, ++ synchronousEscapeCharacterized: synchronousEscape !== null, ++ controlLimitation: ++ label === "control" ++ ? "The raw Error remains synchronously observable to the close caller in memory; the harness catches it and never persists the marker. This does not exercise credentials, payloads, finalizers, or rendered Effect Causes." ++ : null, ++ treatmentAssertionsApplied: label === "treatment", ++ }; ++} ++ ++/** Scan all generated evidence bytes for the random marker. */ ++async function scanCanary(outputRoot: string, marker: string) { ++ const matches: string[] = []; ++ for (const path of await listFiles(outputRoot)) { ++ if (basename(path).startsWith("MANIFEST")) continue; ++ const bytes = await readFile(path); ++ if (bytes.includes(Buffer.from(marker))) matches.push(relative(outputRoot, path)); ++ } ++ return matches; ++} ++ ++/** Write JSON with one stable final newline. */ ++async function writeJson(path: string, value: unknown) { ++ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); ++} ++ ++/** Write a sorted checksum manifest without making it self-referential. */ ++async function writeManifest(outputRoot: string) { ++ const manifestPath = join(outputRoot, "MANIFEST.sha256"); ++ const attestationPath = join(outputRoot, "MANIFEST.attestation.sha256"); ++ const files = (await listFiles(outputRoot)).filter( ++ (path) => path !== manifestPath && path !== attestationPath, ++ ); ++ const lines = await Promise.all( ++ files.map(async (path) => `${await hashFile(path)} ${relative(outputRoot, path)}`), ++ ); ++ await writeFile(manifestPath, `${lines.sort().join("\n")}\n`); ++ await writeFile(attestationPath, `${await hashFile(manifestPath)} ${basename(manifestPath)}\n`); ++} ++ ++/** Verify an externally anchored local control manifest before trusting control identities. */ ++async function verifyControlAttestation(root: string, expectedAttestationSha256: string) { ++ if (!/^[a-f0-9]{64}$/.test(expectedAttestationSha256)) { ++ throw new TypeError("Control attestation SHA-256 must be 64 lowercase hexadecimal characters."); ++ } ++ const controlRoot = join(root, "tmp/session-lifecycle-effect/control"); ++ const attestationPath = join(controlRoot, "MANIFEST.attestation.sha256"); ++ const manifestPath = join(controlRoot, "MANIFEST.sha256"); ++ const attestation = await readFile(attestationPath); ++ if (sha256(attestation) !== expectedAttestationSha256) { ++ throw new Error("External control attestation SHA-256 does not match the attestation file."); ++ } ++ const attestationMatch = attestation.toString().match(/^([a-f0-9]{64}) MANIFEST\.sha256\n$/); ++ if (!attestationMatch?.[1] || (await hashFile(manifestPath)) !== attestationMatch[1]) { ++ throw new Error("Control attestation does not authenticate MANIFEST.sha256."); ++ } ++ const entries = (await readFile(manifestPath, "utf8")).trimEnd().split("\n"); ++ let resultVerified = false; ++ for (const entry of entries) { ++ const match = entry.match(/^([a-f0-9]{64}) (.+)$/); ++ if (!match?.[1] || !match[2]) throw new Error("Control manifest contains a malformed entry."); ++ const relativePath = match[2]; ++ const resolvedPath = resolve(controlRoot, relativePath); ++ if ( ++ relative(controlRoot, resolvedPath).startsWith("..") || ++ (await hashFile(resolvedPath)) !== match[1] ++ ) { ++ throw new Error(`Control manifest verification failed for ${relativePath}.`); ++ } ++ if (relativePath === "result.json") resultVerified = true; ++ } ++ if (!resultVerified) throw new Error("Control manifest does not authenticate result.json."); ++ return { ++ attestationFileSha256: expectedAttestationSha256, ++ manifestSha256: attestationMatch[1], ++ verifiedEntries: entries.length, ++ note: "Local integrity/reproducibility evidence only; this is not a cryptographic signer or timestamp.", ++ }; ++} ++ ++/** Freeze support bytes except lifecycle-injection tests, whose assertions remain monotonic. */ ++async function verifyFrozenControlInputs(root: string, sourceIdentity: unknown) { ++ type TestContract = ReturnType; ++ const controlIdentity = sourceIdentity as { ++ changedFiles?: Array<{ path: string; classification: string; sha256?: string }>; ++ behavioralTestContracts?: TestContract[]; ++ }; ++ const entries = controlIdentity.changedFiles; ++ if (!Array.isArray(entries)) ++ throw new Error("Control source identity lacks changed-file hashes."); ++ const frozen = entries.filter( ++ (entry) => ++ entry.classification === "experiment-support" && ++ entry.path !== "docs/session-broker-effect-lifecycle-experiment-results.md" && ++ !BEHAVIORALLY_FROZEN_TESTS.includes(entry.path as (typeof BEHAVIORALLY_FROZEN_TESTS)[number]), ++ ); ++ for (const entry of frozen) { ++ if (!entry.sha256 || (await hashFile(join(root, entry.path))) !== entry.sha256) { ++ throw new Error(`Frozen control input changed before treatment: ${entry.path}.`); ++ } ++ } ++ const controlContracts = controlIdentity.behavioralTestContracts; ++ if ( ++ !Array.isArray(controlContracts) || ++ controlContracts.length !== BEHAVIORALLY_FROZEN_TESTS.length ++ ) { ++ throw new Error("Control source identity lacks behavioral test contracts."); ++ } ++ const currentContracts = BEHAVIORALLY_FROZEN_TESTS.map((path) => ++ analyzeTestContinuity(root, join(root, path)), ++ ); ++ for (const current of currentContracts) { ++ if (current.forbiddenModes.length > 0) { ++ throw new Error(`Treatment weakened tests with skip/todo/only: ${current.path}.`); ++ } ++ const control = controlContracts.find((entry) => entry.path === current.path); ++ if (!control) throw new Error(`Missing control test contract: ${current.path}.`); ++ const seenNames = new Map(); ++ for (const expected of control.tests) { ++ const occurrence = seenNames.get(expected.name) ?? 0; ++ seenNames.set(expected.name, occurrence + 1); ++ const observed = current.tests.filter((test) => test.name === expected.name)[occurrence]; ++ if (!observed || observed.assertions.length < expected.assertions.length) { ++ throw new Error(`Treatment weakened control test: ${current.path} :: ${expected.name}.`); ++ } ++ } ++ } ++ return { ++ count: frozen.length, ++ files: frozen.map((entry) => ({ path: entry.path, sha256: entry.sha256 })), ++ behavioralTestContinuity: { ++ control: controlContracts, ++ treatment: currentContracts, ++ note: "Mechanical anti-weakening evidence only; independent review must establish behavioral preservation.", ++ }, ++ excluded: [ ++ "docs/session-broker-effect-lifecycle-experiment-results.md", ++ ...BEHAVIORALLY_FROZEN_TESTS, ++ "raw arm evidence", ++ ], ++ }; ++} ++ ++/** Collect one fresh, enforced control or treatment arm. */ ++async function main() { ++ const labelIndex = process.argv.indexOf("--label"); ++ const label = labelIndex >= 0 ? process.argv[labelIndex + 1] : undefined; ++ if (label !== "control" && label !== "treatment") { ++ throw new TypeError("Expected --label control or --label treatment."); ++ } ++ const firstPatchIndex = process.argv.indexOf("--first-experiment-patch"); ++ const optionalFirstPatch = firstPatchIndex >= 0 ? process.argv[firstPatchIndex + 1] : undefined; ++ const controlAttestationIndex = process.argv.indexOf("--control-attestation-sha256"); ++ const externalControlAttestation = ++ controlAttestationIndex >= 0 ? process.argv[controlAttestationIndex + 1] : undefined; ++ const root = process.cwd(); ++ const outputRoot = resolve(root, "tmp/session-lifecycle-effect", label); ++ const buildRoot = join(outputRoot, "builds"); ++ const commandRoot = join(outputRoot, "commands"); ++ const validationRoot = join(outputRoot, "validation"); ++ const declarationRoot = join(outputRoot, "declarations"); ++ const harnessPath = join(root, "scripts/session-broker-effect-lifecycle-experiment.ts"); ++ const planPath = join(root, "docs/session-broker-effect-lifecycle-experiment.md"); ++ ++ let verifiedControlAttestation: Awaited> | null = ++ null; ++ let frozenControlInputs: Awaited> | null = null; ++ if (label === "treatment") { ++ if (!externalControlAttestation) { ++ throw new TypeError("Treatment requires --control-attestation-sha256 ."); ++ } ++ verifiedControlAttestation = await verifyControlAttestation(root, externalControlAttestation); ++ const controlResult = JSON.parse( ++ await readFile(join(root, "tmp/session-lifecycle-effect/control/result.json"), "utf8"), ++ ) as { identities?: { harnessSha256?: string; planSha256?: string } }; ++ const controlSourceIdentity = JSON.parse( ++ await readFile( ++ join(root, "tmp/session-lifecycle-effect/control/source-identity.json"), ++ "utf8", ++ ), ++ ); ++ frozenControlInputs = await verifyFrozenControlInputs(root, controlSourceIdentity); ++ if ( ++ controlResult.identities?.harnessSha256 !== (await hashFile(harnessPath)) || ++ controlResult.identities?.planSha256 !== (await hashFile(planPath)) ++ ) { ++ throw new Error("Treatment must use the unchanged frozen control harness and plan."); ++ } ++ } ++ ++ // No prior arm file may satisfy a current run or enter its manifest. ++ await rm(outputRoot, { force: true, recursive: true }); ++ await mkdir(buildRoot, { recursive: true }); ++ ++ const validationCommands = [ ++ await runRequiredCommand( ++ root, ++ validationRoot, ++ "focused", ++ [ ++ "bun", ++ "test", ++ "packages/session-broker-core", ++ "packages/session-broker", ++ "packages/session-broker-bun", ++ "src/session/broker", ++ "scripts/source-boundaries.test.ts", ++ ], ++ true, ++ ), ++ await runRequiredCommand( ++ root, ++ validationRoot, ++ "real-node", ++ ["bun", "run", "test:session-broker-node"], ++ true, ++ ), ++ await runRequiredCommand(root, validationRoot, "typecheck", ["bun", "run", "typecheck"]), ++ await runRequiredCommand(root, validationRoot, "lint", ["bun", "run", "lint"]), ++ await runRequiredCommand(root, validationRoot, "dependencies", ["bun", "run", "deps:check"]), ++ await runRequiredCommand(root, validationRoot, "full-unit", ["bun", "run", "test"], true), ++ await runRequiredCommand( ++ root, ++ validationRoot, ++ "pty-integration", ++ ["bun", "run", "test:integration"], ++ true, ++ ), ++ ]; ++ ++ const commandEvidence: CommandEvidence[] = []; ++ const systemNode = await runRequiredCommand(root, commandRoot, "system-node-version", [ ++ "node", ++ "--version", ++ ]); ++ commandEvidence.push(systemNode); ++ const dependencyTree = await runCommand(root, commandRoot, "dependency-tree", [ ++ "npm", ++ "ls", ++ "effect", ++ "--all", ++ "--json", ++ ]); ++ commandEvidence.push(dependencyTree); ++ const dependencyTreeValue = JSON.parse( ++ await readFile(join(root, dependencyTree.stdoutTranscript), "utf8"), ++ ); ++ const effectVersions = [...findDependencyVersions(dependencyTreeValue, "effect")].sort(); ++ const dependencyPlacement = await collectDependencyPlacement(root, label, effectVersions); ++ if (label === "treatment" && dependencyTree.exitCode !== 0) { ++ throw new Error("Treatment dependency-tree command must exit successfully."); ++ } ++ const moduleBoundaries = collectModuleBoundaries(root, label); ++ ++ const builds = []; ++ for (let index = 1; index <= 3; index += 1) { ++ builds.push(await buildBrokerBundle(root, buildRoot, "bun", index)); ++ builds.push(await buildBrokerBundle(root, buildRoot, "node", index)); ++ } ++ for (let index = 1; index <= 3; index += 1) { ++ const evidence = await runRequiredCommand(root, commandRoot, `compiled-hunk-${index}`, [ ++ "bun", ++ "run", ++ "build:bin", ++ ]); ++ commandEvidence.push(evidence); ++ const binary = join(root, "dist", platform() === "win32" ? "hunk.exe" : "hunk"); ++ builds.push({ ++ target: "compiled-hunk" as const, ++ index, ++ retained: false, ++ retentionNote: ++ "Identity measured in dist and recorded; binary is not copied into arm evidence.", ++ durationMs: evidence.durationMs, ++ bytes: (await stat(binary)).size, ++ sha256: await hashFile(binary), ++ }); ++ } ++ ++ const bunRuntimeFixture = await buildBunRuntimeFixture(root, outputRoot); ++ const defectMessages: string[] = []; ++ const lifecycle = createArmLifecycle(label, (message) => defectMessages.push(message)); ++ const measurements: Record = { ++ processToLifecycleReadyCold: await measureColdLifecycleReady( ++ root, ++ label, ++ bunRuntimeFixture.bundle, ++ ), ++ synchronousConnectionStop: await measureReported(() => ++ measureSynchronousConnectionStop(lifecycle), ++ ), ++ unauthenticatedInMemoryRegistration: await measure(() => ++ measureUnauthenticatedRegistration(lifecycle), ++ ), ++ authenticatedRegistration: { ++ status: "not-measured", ++ reason: ++ "The signed Bun/Node fixtures prove hello and broker-observed register, but do not expose a stable isolated registration timestamp in both arms.", ++ }, ++ commandRoundTrip: await measure(() => measureCommand(lifecycle)), ++ reconnectGeneration: await measure(() => measureReconnect(lifecycle)), ++ transportListenerAcquisitionContext: await measureReported(measureTransportListenerAcquisition), ++ transportDaemonOrderedStopContext: await measure(measureTransportOrderedStop), ++ hunkStartupPolling: { ++ status: "not-measured", ++ reason: ++ "Hunk startup polling is a Bun product path with no honest portable real-Node equivalent; characterization tests freeze behavior instead of timing a synthetic timer.", ++ }, ++ }; ++ ++ const cpuBefore = process.cpuUsage(); ++ const cpuStartedAt = performance.now(); ++ for (let index = 0; index < 100; index += 1) await measureCommand(lifecycle); ++ const cpu = { ++ classification: "coarse-non-isolated-observation", ++ conclusionPolicy: ++ "Do not infer lifecycle performance or security conclusions from this sample.", ++ operations: 100, ++ wallMs: performance.now() - cpuStartedAt, ++ ...process.cpuUsage(cpuBefore), ++ }; ++ const rssSamples: number[] = []; ++ for (let index = 0; index < 5; index += 1) { ++ await Bun.sleep(10); ++ rssSamples.push(process.memoryUsage.rss()); ++ } ++ ++ const declaration = await runRequiredCommand(root, commandRoot, "public-declarations", [ ++ "./node_modules/.bin/tsc", ++ "packages/session-broker/src/index.ts", ++ "--declaration", ++ "--emitDeclarationOnly", ++ "--outDir", ++ relative(root, declarationRoot), ++ "--module", ++ "ESNext", ++ "--moduleResolution", ++ "Bundler", ++ "--target", ++ "ES2022", ++ "--lib", ++ "ES2022,DOM", ++ "--skipLibCheck", ++ "--strict", ++ "--types", ++ "bun", ++ ]); ++ commandEvidence.push(declaration); ++ const declarationFiles = (await listFiles(declarationRoot)).filter((path) => ++ path.endsWith(".d.ts"), ++ ); ++ const forbiddenDeclarationPattern = ++ /(?:from\s+["']effect["']|\b(?:Effect|ManagedRuntime|Runtime|Layer|Scope|Fiber|Cause|Exit|Duration|Schedule|Clock)\b)/; ++ const declarationMatches: Array<{ file: string; line: number; text: string }> = []; ++ for (const path of declarationFiles) { ++ const lines = (await readFile(path, "utf8")).split("\n"); ++ lines.forEach((text, index) => { ++ const declarationText = /^\s*(?:\/\*|\*|\/\/)/.test(text) ? "" : text; ++ if (forbiddenDeclarationPattern.test(declarationText)) { ++ declarationMatches.push({ file: relative(declarationRoot, path), line: index + 1, text }); ++ } ++ }); ++ } ++ if (declarationMatches.length > 0) ++ throw new Error("Public declarations expose Effect vocabulary."); ++ ++ const canaryMarker = `session-lifecycle-canary-${crypto.randomUUID()}`; ++ const canary = await exerciseCanary(label, lifecycle, canaryMarker, defectMessages); ++ lifecycle?.close(); ++ await lifecycle?.settled; ++ if ( ++ label === "treatment" && ++ (defectMessages.length !== 1 || defectMessages[0] !== REDACTED_LIFECYCLE_DEFECT) ++ ) { ++ throw new Error( ++ "Treatment lifecycle emitted defect-hook output outside the single canary event.", ++ ); ++ } ++ ++ const sourceIdentity = await collectSourceIdentity(root, outputRoot, label, optionalFirstPatch); ++ const ownership = await collectOwnership(root, label); ++ const semantics = collectSemantics(label, ownership); ++ await writeJson(join(outputRoot, "source-identity.json"), sourceIdentity); ++ await writeJson(join(outputRoot, "ownership-inventory.json"), ownership); ++ await writeJson(join(outputRoot, "semantics.json"), semantics); ++ await writeJson(join(outputRoot, "canary.json"), canary); ++ await writeJson(join(validationRoot, "manifest.json"), { ++ schemaVersion: 2, ++ recordedAt: new Date().toISOString(), ++ freshness: "all required commands executed by this arm invocation after clearing output", ++ commands: validationCommands, ++ }); ++ ++ const result = { ++ schemaVersion: 3, ++ label, ++ recordedAt: new Date().toISOString(), ++ environment: { ++ head: sourceIdentity.currentHead, ++ bun: Bun.version, ++ embeddedNode: process.versions.node, ++ systemNodeTranscript: systemNode.stdoutTranscript, ++ os: platform(), ++ release: release(), ++ arch: arch(), ++ cpu: cpus()[0]?.model ?? "unknown", ++ }, ++ identities: { ++ harnessSha256: await hashFile(harnessPath), ++ planSha256: await hashFile(planPath), ++ lockfileSha256: await hashFile(join(root, "bun.lock")), ++ firstExperimentPatchSha256: FIRST_EXPERIMENT_PATCH_SHA256, ++ }, ++ builds, ++ measurements, ++ coarseObservations: { ++ cpu, ++ rss: { ++ classification: "coarse-non-isolated-observation", ++ conclusionPolicy: ++ "Do not infer lifecycle performance or security conclusions from this sample.", ++ unit: "bytes", ++ samples: rssSamples, ++ median: percentile(rssSamples, 0.5), ++ p95: percentile(rssSamples, 0.95), ++ }, ++ }, ++ processExit: { ++ bun: { ++ status: "passed", ++ evidence: validationCommands.find((entry) => entry.name === "focused"), ++ modes: [ ++ "signed-register", ++ "lifecycle-ready", ++ "pending-handshake", ++ "heartbeat", ++ "reconnect", ++ ], ++ }, ++ node: { ++ status: "passed", ++ evidence: validationCommands.find((entry) => entry.name === "real-node"), ++ modes: [ ++ "signed-register", ++ "lifecycle-ready", ++ "pending-handshake", ++ "heartbeat", ++ "reconnect", ++ ], ++ }, ++ }, ++ declarations: { ++ files: declarationFiles.map((path) => relative(declarationRoot, path)), ++ forbiddenPattern: forbiddenDeclarationPattern.source, ++ forbiddenMatches: declarationMatches, ++ }, ++ dependencyPlacement: { ++ ...dependencyPlacement, ++ commandExitCode: dependencyTree.exitCode, ++ commandExpectation: ++ label === "control" ++ ? "Effect is absent; npm ls may exit nonzero for the missing requested package." ++ : "Treatment requires exit 0 with exact effect@3.22.1.", ++ }, ++ moduleBoundaries: { ++ ...moduleBoundaries, ++ bunBuildGraph: bunRuntimeFixture.evidence, ++ }, ++ behavioralTestContinuity: { ++ contracts: sourceIdentity.behavioralTestContracts, ++ treatmentComparison: frozenControlInputs?.behavioralTestContinuity ?? null, ++ note: "Mechanical anti-weakening evidence only; independent review must establish behavioral preservation.", ++ }, ++ verifiedControlAttestation, ++ frozenControlInputs, ++ validation: validationCommands, ++ commands: commandEvidence, ++ }; ++ await writeJson(join(outputRoot, "result.json"), result); ++ ++ const canaryMatches = await scanCanary(outputRoot, canaryMarker); ++ if (canaryMatches.length > 0) { ++ throw new Error(`Canary marker escaped into evidence: ${canaryMatches.join(", ")}`); ++ } ++ await writeManifest(outputRoot); ++ console.log(join(outputRoot, "result.json")); ++} ++ ++await main(); +diff --git a/scripts/session-lifecycle-ownership-analysis.ts b/scripts/session-lifecycle-ownership-analysis.ts +new file mode 100644 +index 00000000..645bd84b +--- /dev/null ++++ b/scripts/session-lifecycle-ownership-analysis.ts +@@ -0,0 +1,676 @@ ++import { execFileSync } from "node:child_process"; ++import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; ++import { extname, join, relative, resolve, sep } from "node:path"; ++import ts from "typescript"; ++ ++export interface SourceLocationFact { ++ readonly file: string; ++ readonly line: number; ++ readonly column: number; ++ readonly kind: string; ++ readonly text: string; ++} ++ ++export interface TreatmentOwnershipAnalysis { ++ readonly treatmentPresent: boolean; ++ readonly locations: { ++ readonly connectionNativeTiming: readonly SourceLocationFact[]; ++ readonly clientNativeTiming: readonly SourceLocationFact[]; ++ readonly launcherBunSleep: readonly SourceLocationFact[]; ++ readonly connectionProperties: readonly SourceLocationFact[]; ++ readonly clientProperties: readonly SourceLocationFact[]; ++ readonly connectionSupervisorProperties: readonly SourceLocationFact[]; ++ readonly clientSupervisorProperties: readonly SourceLocationFact[]; ++ readonly connectionDelayOwnerProperties: readonly SourceLocationFact[]; ++ readonly clientDelayOwnerProperties: readonly SourceLocationFact[]; ++ readonly clientPromiseOwnerProperties: readonly SourceLocationFact[]; ++ readonly processTaskRegistries: readonly SourceLocationFact[]; ++ readonly processImplementationClasses: readonly SourceLocationFact[]; ++ readonly processDelayClasses: readonly SourceLocationFact[]; ++ readonly processSetInterval: readonly SourceLocationFact[]; ++ readonly processTimersOutsideClock: readonly SourceLocationFact[]; ++ readonly managedRuntimeConstruction: readonly SourceLocationFact[]; ++ readonly customClockConstruction: readonly SourceLocationFact[]; ++ readonly fixedHeartbeatSchedule: readonly SourceLocationFact[]; ++ readonly delayedHeartbeatSleep: readonly SourceLocationFact[]; ++ readonly runtimeFactoryCalls: readonly SourceLocationFact[]; ++ }; ++ readonly violations: readonly string[]; ++} ++ ++/** Return stable repo-relative paths in evidence and failures. */ ++function repoPath(root: string, path: string) { ++ return relative(root, path).split(sep).join("/"); ++} ++ ++/** Identify TypeScript source extensions while excluding declaration files. */ ++function isTypeScriptSource(path: string) { ++ return /\.(?:ts|tsx|mts|cts)$/.test(path) && !/\.d\.(?:ts|tsx|mts|cts)$/.test(path); ++} ++ ++/** Return every TypeScript source file, including tests, while excluding declarations. */ ++export function allTypeScriptFiles(directory: string): string[] { ++ if (!existsSync(directory)) return []; ++ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { ++ const path = join(directory, entry.name); ++ if (entry.isDirectory()) return allTypeScriptFiles(path); ++ return isTypeScriptSource(entry.name) ? [path] : []; ++ }); ++} ++ ++/** Return every tracked or untracked TypeScript source in a repository. */ ++export function repositoryTypeScriptFiles(repoRoot: string): string[] { ++ const root = resolve(repoRoot); ++ const paths = execFileSync( ++ "git", ++ ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], ++ { encoding: "utf8" }, ++ ).split("\0"); ++ return paths.filter(isTypeScriptSource).map((path) => join(root, path)); ++} ++ ++/** Return production TypeScript files while excluding tests and declarations. */ ++export function productionTypeScriptFiles(directory: string): string[] { ++ return allTypeScriptFiles(directory).filter((path) => !/\.test\.(?:ts|tsx|mts|cts)$/.test(path)); ++} ++ ++/** Resolve a relative ESM specifier to its TypeScript source when one exists. */ ++function resolveTypeScriptSpecifier(importer: string, specifier: string) { ++ const base = resolve(join(importer, ".."), specifier); ++ const extension = extname(base); ++ const mapped = ++ extension === ".js" ++ ? [`${base.slice(0, -3)}.ts`, `${base.slice(0, -3)}.tsx`] ++ : extension === ".mjs" ++ ? [`${base.slice(0, -4)}.mts`] ++ : extension === ".cjs" ++ ? [`${base.slice(0, -4)}.cts`] ++ : []; ++ const extensionless = extension ++ ? [] ++ : [".ts", ".tsx", ".mts", ".cts"].map((suffix) => `${base}${suffix}`); ++ const indexes = ["index.ts", "index.tsx", "index.mts", "index.cts"].map((name) => ++ join(base, name), ++ ); ++ return ( ++ [base, ...mapped, ...extensionless, ...indexes].find( ++ (candidate) => existsSync(candidate) && statSync(candidate).isFile(), ++ ) ?? base ++ ); ++} ++ ++/** Reject production edges into tests or other experiment-support trees. */ ++export function forbiddenProductionSupportImports(repoRoot: string) { ++ const root = resolve(repoRoot); ++ const files = [ ++ ...productionTypeScriptFiles(join(root, "packages")), ++ ...productionTypeScriptFiles(join(root, "src")), ++ ]; ++ return files.flatMap((path) => ++ astModuleSpecifiers(path).flatMap(({ specifier, location }) => { ++ if (!specifier.startsWith(".")) return []; ++ const target = resolveTypeScriptSpecifier(path, specifier); ++ const relativeTarget = repoPath(root, target); ++ return /\.test\.(?:ts|tsx|mts|cts)$/.test(relativeTarget) || ++ /^(?:test|docs|scripts)\//.test(relativeTarget) ++ ? [{ ...location, file: repoPath(root, path), specifier, resolved: relativeTarget }] ++ : []; ++ }), ++ ); ++} ++ ++/** Record test names and assertion-call locations for cross-arm anti-weakening checks. */ ++export function analyzeTestContinuity(repoRoot: string, path: string) { ++ const source = parseTypeScript(path); ++ const forbiddenModes: SourceLocationFact[] = []; ++ const tests: Array<{ ++ name: string; ++ line: number; ++ column: number; ++ mode: string; ++ assertions: SourceLocationFact[]; ++ }> = []; ++ const testCall = (node: ts.CallExpression) => { ++ if (ts.isIdentifier(node.expression) && ["test", "it"].includes(node.expression.text)) { ++ return { mode: "normal", nameArgument: node.arguments[0], body: node.arguments[1] }; ++ } ++ if ( ++ ts.isPropertyAccessExpression(node.expression) && ++ ts.isIdentifier(node.expression.expression) && ++ ["test", "it"].includes(node.expression.expression.text) ++ ) { ++ return { ++ mode: node.expression.name.text, ++ nameArgument: node.arguments[0], ++ body: node.arguments[1], ++ }; ++ } ++ return null; ++ }; ++ const assertionName = (node: ts.CallExpression) => { ++ if (ts.isIdentifier(node.expression)) { ++ return /^(?:expect|assert|assertEquals|assertStrictEquals|deepStrictEqual|strictEqual|ok|fail)$/.test( ++ node.expression.text, ++ ) ++ ? node.expression.text ++ : null; ++ } ++ if ( ++ ts.isPropertyAccessExpression(node.expression) && ++ ts.isIdentifier(node.expression.expression) && ++ node.expression.expression.text === "assert" ++ ) { ++ return `assert.${node.expression.name.text}`; ++ } ++ return null; ++ }; ++ const visit = (node: ts.Node) => { ++ if (ts.isCallExpression(node)) { ++ if ( ++ ts.isPropertyAccessExpression(node.expression) && ++ ts.isIdentifier(node.expression.expression) && ++ ["test", "it", "describe"].includes(node.expression.expression.text) && ++ /^(?:skip|todo|only)/.test(node.expression.name.text) ++ ) { ++ forbiddenModes.push(locationFact(repoRoot, path, source, node, node.expression.name.text)); ++ } ++ const declaration = testCall(node); ++ const nameArgument = declaration?.nameArgument; ++ if (declaration && nameArgument && ts.isStringLiteralLike(nameArgument)) { ++ const assertions: SourceLocationFact[] = []; ++ if (declaration.body) { ++ const findAssertions = (child: ts.Node) => { ++ if (ts.isCallExpression(child)) { ++ const name = assertionName(child); ++ if (name) assertions.push(locationFact(repoRoot, path, source, child, name)); ++ } ++ ts.forEachChild(child, findAssertions); ++ }; ++ findAssertions(declaration.body); ++ } ++ const position = source.getLineAndCharacterOfPosition(node.getStart(source)); ++ tests.push({ ++ name: nameArgument.text, ++ line: position.line + 1, ++ column: position.character + 1, ++ mode: declaration.mode, ++ assertions, ++ }); ++ } ++ } ++ ts.forEachChild(node, visit); ++ }; ++ visit(source); ++ return { path: repoPath(repoRoot, path), tests, forbiddenModes }; ++} ++ ++/** Parse one TypeScript module for syntax-aware import and mechanism checks. */ ++export function parseTypeScript(path: string) { ++ return ts.createSourceFile( ++ path, ++ readFileSync(path, "utf8"), ++ ts.ScriptTarget.Latest, ++ true, ++ path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ++ ); ++} ++ ++/** Read real static/dynamic import, export, require, and import-meta resolution specifiers. */ ++export function astModuleSpecifiers(path: string) { ++ const source = parseTypeScript(path); ++ const specifiers: Array<{ specifier: string; location: SourceLocationFact }> = []; ++ const add = (node: ts.Node, value: ts.Expression | undefined, kind: string) => { ++ if (!value || !ts.isStringLiteralLike(value)) return; ++ specifiers.push({ ++ specifier: value.text, ++ location: locationFact(process.cwd(), path, source, node, kind), ++ }); ++ }; ++ const visit = (node: ts.Node) => { ++ if (ts.isImportDeclaration(node)) add(node, node.moduleSpecifier, "import"); ++ if (ts.isExportDeclaration(node)) add(node, node.moduleSpecifier, "export"); ++ if (ts.isCallExpression(node) && node.arguments.length > 0) { ++ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) { ++ add(node, node.arguments[0], "dynamic-import"); ++ } else if (ts.isIdentifier(node.expression) && node.expression.text === "require") { ++ add(node, node.arguments[0], "require"); ++ } else if (ts.isPropertyAccessExpression(node.expression)) { ++ const owner = node.expression.expression; ++ const method = node.expression.name.text; ++ if (ts.isMetaProperty(owner) && method === "resolve") { ++ add(node, node.arguments[0], "import-meta-resolve"); ++ } else if (ts.isIdentifier(owner) && owner.text === "require" && method === "resolve") { ++ add(node, node.arguments[0], "require-resolve"); ++ } else if (ts.isIdentifier(owner) && owner.text === "Bun" && method === "resolveSync") { ++ add(node, node.arguments[0], "bun-resolve-sync"); ++ } ++ } ++ } ++ ts.forEachChild(node, visit); ++ }; ++ visit(source); ++ return specifiers; ++} ++ ++/** Describe one AST node without reading comments or unrelated string literals. */ ++function locationFact( ++ root: string, ++ path: string, ++ source: ts.SourceFile, ++ node: ts.Node, ++ kind: string, ++): SourceLocationFact { ++ const start = node.getStart(source); ++ const position = source.getLineAndCharacterOfPosition(start); ++ return { ++ file: repoPath(root, path), ++ line: position.line + 1, ++ column: position.character + 1, ++ kind, ++ text: node.getText(source).slice(0, 240), ++ }; ++} ++ ++/** Return a class property name without treating computed expressions as ownership names. */ ++function propertyName(member: ts.PropertyDeclaration) { ++ return ts.isIdentifier(member.name) || ts.isStringLiteralLike(member.name) ++ ? member.name.text ++ : member.name.getText(); ++} ++ ++/** Return whether a node is lexically contained by one frozen named function. */ ++function isInsideFunction(node: ts.Node, expectedName: string) { ++ let current: ts.Node | undefined = node.parent; ++ while (current) { ++ if ( ++ (ts.isFunctionDeclaration(current) || ++ ts.isMethodDeclaration(current) || ++ ts.isFunctionExpression(current)) && ++ current.name && ++ ts.isIdentifier(current.name) && ++ current.name.text === expectedName ++ ) { ++ return true; ++ } ++ if ( ++ ts.isArrowFunction(current) && ++ ts.isVariableDeclaration(current.parent) && ++ ts.isIdentifier(current.parent.name) && ++ current.parent.name.text === expectedName ++ ) { ++ return true; ++ } ++ current = current.parent; ++ } ++ return false; ++} ++ ++/** Collect calls with one exact identifier or property-access shape. */ ++function callFacts( ++ root: string, ++ path: string, ++ source: ts.SourceFile, ++ predicate: (call: ts.CallExpression) => boolean, ++ kind: string, ++) { ++ const facts: SourceLocationFact[] = []; ++ const visit = (node: ts.Node) => { ++ if (ts.isCallExpression(node) && predicate(node)) { ++ facts.push(locationFact(root, path, source, node, kind)); ++ } ++ ts.forEachChild(node, visit); ++ }; ++ visit(source); ++ return facts; ++} ++ ++/** Match direct or global timing APIs while ignoring comments, strings, and similarly named methods. */ ++function nativeTimingCall(call: ts.CallExpression) { ++ const expression = call.expression; ++ if (ts.isIdentifier(expression)) { ++ return new Set(["setTimeout", "setInterval", "clearTimeout", "clearInterval"]).has( ++ expression.text, ++ ); ++ } ++ if (!ts.isPropertyAccessExpression(expression)) return false; ++ if ( ++ ts.isIdentifier(expression.expression) && ++ expression.expression.text === "Bun" && ++ expression.name.text === "sleep" ++ ) { ++ return true; ++ } ++ return ( ++ ts.isIdentifier(expression.expression) && ++ new Set(["globalThis", "window"]).has(expression.expression.text) && ++ new Set(["setTimeout", "setInterval", "clearTimeout", "clearInterval"]).has( ++ expression.name.text, ++ ) ++ ); ++} ++ ++/** Analyze frozen Phase 1 ownership mechanics and report source locations for independent review. */ ++export function analyzeTreatmentOwnership(repoRoot: string): TreatmentOwnershipAnalysis { ++ const root = resolve(repoRoot); ++ const paths = { ++ connection: join(root, "packages/session-broker/src/connection.ts"), ++ client: join(root, "src/session/broker/brokerClient.ts"), ++ launcher: join(root, "src/session/broker/brokerLauncher.ts"), ++ lifecycle: join(root, "packages/session-broker/src/processLifecycle.ts"), ++ }; ++ const treatmentPresent = existsSync(paths.lifecycle); ++ const connection = parseTypeScript(paths.connection); ++ const client = parseTypeScript(paths.client); ++ const launcher = parseTypeScript(paths.launcher); ++ const lifecycle = treatmentPresent ? parseTypeScript(paths.lifecycle) : undefined; ++ ++ const connectionNativeTiming = callFacts( ++ root, ++ paths.connection, ++ connection, ++ nativeTimingCall, ++ "native-timing-call", ++ ); ++ const clientNativeTiming = callFacts( ++ root, ++ paths.client, ++ client, ++ nativeTimingCall, ++ "native-timing-call", ++ ); ++ const launcherBunSleep = callFacts( ++ root, ++ paths.launcher, ++ launcher, ++ (call) => ++ ts.isPropertyAccessExpression(call.expression) && ++ ts.isIdentifier(call.expression.expression) && ++ call.expression.expression.text === "Bun" && ++ call.expression.name.text === "sleep", ++ "Bun.sleep", ++ ); ++ ++ const classProperties = ( ++ path: string, ++ source: ts.SourceFile, ++ className: string, ++ ): SourceLocationFact[] => { ++ const facts: SourceLocationFact[] = []; ++ source.forEachChild((node) => { ++ if (!ts.isClassDeclaration(node) || node.name?.text !== className) return; ++ for (const member of node.members) { ++ if (ts.isPropertyDeclaration(member)) { ++ facts.push(locationFact(root, path, source, member, `property:${propertyName(member)}`)); ++ } ++ if (ts.isConstructorDeclaration(member)) { ++ for (const parameter of member.parameters) { ++ const ownsProperty = parameter.modifiers?.some((modifier) => ++ new Set([ ++ ts.SyntaxKind.PrivateKeyword, ++ ts.SyntaxKind.ProtectedKeyword, ++ ts.SyntaxKind.PublicKeyword, ++ ts.SyntaxKind.ReadonlyKeyword, ++ ]).has(modifier.kind), ++ ); ++ if (ownsProperty && ts.isIdentifier(parameter.name)) { ++ facts.push( ++ locationFact(root, path, source, parameter, `property:${parameter.name.text}`), ++ ); ++ } ++ } ++ } ++ } ++ }); ++ return facts; ++ }; ++ const connectionProperties = classProperties( ++ paths.connection, ++ connection, ++ "SessionBrokerConnection", ++ ); ++ const clientProperties = classProperties(paths.client, client, "SessionBrokerClient"); ++ const supervisorProperty = (fact: SourceLocationFact, expected: string, typeName: string) => ++ fact.kind === `property:${expected}` && new RegExp(`\\b${typeName}\\b`).test(fact.text); ++ const delayOwnerProperty = (fact: SourceLocationFact) => ++ /(?:timer|task|fiber|handle|delay)/i.test(fact.kind.replace("property:", "")) || ++ /\b(?:Timeout|Interval|Task|Fiber|Handle)\b/.test(fact.text); ++ const connectionSupervisorProperties = connectionProperties.filter((fact) => ++ supervisorProperty(fact, "connectionSupervisor", "SessionBrokerConnectionSupervisor"), ++ ); ++ const clientSupervisorProperties = clientProperties.filter((fact) => ++ supervisorProperty(fact, "startupSupervisor", "SessionBrokerStartupSupervisor"), ++ ); ++ const connectionDelayOwnerProperties = connectionProperties.filter(delayOwnerProperty); ++ const clientDelayOwnerProperties = clientProperties.filter(delayOwnerProperty); ++ const clientPromiseOwnerProperties = clientProperties.filter( ++ (fact) => /Promise/i.test(fact.kind.replace("property:", "")) || /\bPromise\b/.test(fact.text), ++ ); ++ ++ const processTaskRegistries: SourceLocationFact[] = []; ++ const processImplementationClasses: SourceLocationFact[] = []; ++ const processDelayClasses: SourceLocationFact[] = []; ++ const processSetInterval: SourceLocationFact[] = []; ++ const processTimersOutsideClock: SourceLocationFact[] = []; ++ const managedRuntimeConstruction: SourceLocationFact[] = []; ++ const customClockConstruction: SourceLocationFact[] = []; ++ const fixedHeartbeatSchedule: SourceLocationFact[] = []; ++ const delayedHeartbeatSleep: SourceLocationFact[] = []; ++ if (lifecycle) { ++ const visit = (node: ts.Node) => { ++ if ( ++ ts.isNewExpression(node) && ++ ts.isIdentifier(node.expression) && ++ new Set(["Set", "Map", "WeakMap"]).has(node.expression.text) ++ ) { ++ processTaskRegistries.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "task-registry-construction"), ++ ); ++ } ++ if (ts.isClassDeclaration(node) && node.name) { ++ processImplementationClasses.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, `class:${node.name.text}`), ++ ); ++ if (/(?:Delay|Timer|Task|Fiber|Handle)/.test(node.name.text)) { ++ processDelayClasses.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "per-delay-class"), ++ ); ++ } ++ } ++ if (ts.isCallExpression(node)) { ++ const expression = node.expression; ++ if (ts.isIdentifier(expression) && expression.text === "setInterval") { ++ processSetInterval.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "setInterval"), ++ ); ++ } ++ if ( ++ (ts.isIdentifier(expression) && ++ new Set(["setTimeout", "clearTimeout"]).has(expression.text)) || ++ (ts.isPropertyAccessExpression(expression) && ++ ts.isIdentifier(expression.expression) && ++ expression.expression.text === "globalThis" && ++ new Set(["setTimeout", "clearTimeout"]).has(expression.name.text)) ++ ) { ++ if (!isInsideFunction(node, "createUnrefClock")) { ++ processTimersOutsideClock.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "timer-outside-custom-clock"), ++ ); ++ } ++ } ++ if ( ++ ts.isPropertyAccessExpression(expression) && ++ expression.expression.getText(lifecycle) === "ManagedRuntime" && ++ expression.name.text === "make" ++ ) { ++ managedRuntimeConstruction.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "ManagedRuntime.make"), ++ ); ++ } ++ if (ts.isIdentifier(expression) && expression.text === "createUnrefClock") { ++ customClockConstruction.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "createUnrefClock"), ++ ); ++ } ++ if ( ++ ts.isPropertyAccessExpression(expression) && ++ expression.expression.getText(lifecycle) === "Schedule" && ++ expression.name.text === "fixed" && ++ isInsideFunction(node, "runDelayedFixedHeartbeat") ++ ) { ++ fixedHeartbeatSchedule.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "Schedule.fixed"), ++ ); ++ } ++ if ( ++ ts.isPropertyAccessExpression(expression) && ++ expression.expression.getText(lifecycle) === "Effect" && ++ expression.name.text === "sleep" && ++ isInsideFunction(node, "runDelayedFixedHeartbeat") ++ ) { ++ delayedHeartbeatSleep.push( ++ locationFact(root, paths.lifecycle, lifecycle, node, "Effect.sleep"), ++ ); ++ } ++ } ++ ts.forEachChild(node, visit); ++ }; ++ visit(lifecycle); ++ } ++ ++ const runtimeFactoryCalls: SourceLocationFact[] = []; ++ for (const path of [ ++ ...productionTypeScriptFiles(join(root, "packages")), ++ ...productionTypeScriptFiles(join(root, "src")), ++ ]) { ++ const source = parseTypeScript(path); ++ runtimeFactoryCalls.push( ++ ...callFacts( ++ root, ++ path, ++ source, ++ (call) => ++ ts.isIdentifier(call.expression) && ++ call.expression.text === "createSessionBrokerProcessLifecycle", ++ "process-lifecycle-factory-call", ++ ), ++ ); ++ } ++ ++ const locations = { ++ connectionNativeTiming, ++ clientNativeTiming, ++ launcherBunSleep, ++ connectionProperties, ++ clientProperties, ++ connectionSupervisorProperties, ++ clientSupervisorProperties, ++ connectionDelayOwnerProperties, ++ clientDelayOwnerProperties, ++ clientPromiseOwnerProperties, ++ processTaskRegistries, ++ processImplementationClasses, ++ processDelayClasses, ++ processSetInterval, ++ processTimersOutsideClock, ++ managedRuntimeConstruction, ++ customClockConstruction, ++ fixedHeartbeatSchedule, ++ delayedHeartbeatSleep, ++ runtimeFactoryCalls, ++ }; ++ const violations: string[] = []; ++ if (treatmentPresent) { ++ const empty = (name: keyof typeof locations) => { ++ if (locations[name].length > 0) violations.push(`${name} must be empty`); ++ }; ++ for (const name of [ ++ "connectionNativeTiming", ++ "clientNativeTiming", ++ "launcherBunSleep", ++ "connectionDelayOwnerProperties", ++ "clientDelayOwnerProperties", ++ "clientPromiseOwnerProperties", ++ "processTaskRegistries", ++ "processDelayClasses", ++ "processSetInterval", ++ "processTimersOutsideClock", ++ ] as const) { ++ empty(name); ++ } ++ const exactlyOne = (name: keyof typeof locations) => { ++ if (locations[name].length !== 1) ++ violations.push(`${name} must contain exactly one location`); ++ }; ++ for (const name of [ ++ "connectionSupervisorProperties", ++ "clientSupervisorProperties", ++ "managedRuntimeConstruction", ++ "customClockConstruction", ++ "fixedHeartbeatSchedule", ++ "delayedHeartbeatSleep", ++ "runtimeFactoryCalls", ++ ] as const) { ++ exactlyOne(name); ++ } ++ const propertyNames = (facts: readonly SourceLocationFact[]) => ++ facts.map((fact) => fact.kind.replace("property:", "")).sort(); ++ const expectedConnectionProperties = [ ++ "activeSocket", ++ "bridge", ++ "connectionSupervisor", ++ "draining", ++ "executingMessages", ++ "limits", ++ "options", ++ "producerHellos", ++ "queuedCommandByteBudget", ++ "queuedCommandCountBudget", ++ "queuedMessages", ++ "registration", ++ "snapshot", ++ "socket", ++ "stopped", ++ ].sort(); ++ const expectedClientProperties = [ ++ "bridge", ++ "connection", ++ "credentials", ++ "incumbentLaunchFingerprint", ++ "lastConnectionWarning", ++ "registration", ++ "snapshot", ++ "startupSupervisor", ++ "stopped", ++ "timing", ++ "waitingForIncumbentExit", ++ ].sort(); ++ if ( ++ JSON.stringify(propertyNames(connectionProperties)) !== ++ JSON.stringify(expectedConnectionProperties) ++ ) { ++ violations.push("SessionBrokerConnection properties must equal the frozen treatment set"); ++ } ++ if ( ++ JSON.stringify(propertyNames(clientProperties)) !== JSON.stringify(expectedClientProperties) ++ ) { ++ violations.push("SessionBrokerClient properties must equal the frozen treatment set"); ++ } ++ const implementationClasses = processImplementationClasses.map((fact) => fact.kind).sort(); ++ const expectedClasses = [ ++ "class:EffectSessionBrokerConnectionSupervisor", ++ "class:EffectSessionBrokerProcessLifecycle", ++ "class:EffectSessionBrokerStartupSupervisor", ++ ]; ++ if (JSON.stringify(implementationClasses) !== JSON.stringify(expectedClasses)) { ++ violations.push( ++ "processLifecycle.ts must contain exactly the three frozen implementation classes", ++ ); ++ } ++ if (runtimeFactoryCalls[0]?.file !== "src/ui/runInteractiveApp.tsx") { ++ violations.push( ++ "the only process lifecycle factory call must be in src/ui/runInteractiveApp.tsx", ++ ); ++ } ++ } ++ return { treatmentPresent, locations, violations }; ++} +diff --git a/scripts/source-boundaries.test.ts b/scripts/source-boundaries.test.ts +index a0835172..cf73a128 100644 +--- a/scripts/source-boundaries.test.ts ++++ b/scripts/source-boundaries.test.ts +@@ -1,6 +1,22 @@ + import { describe, expect, test } from "bun:test"; +-import { existsSync, readdirSync, readFileSync } from "node:fs"; ++import { ++ existsSync, ++ mkdirSync, ++ mkdtempSync, ++ readdirSync, ++ readFileSync, ++ rmSync, ++ writeFileSync, ++} from "node:fs"; ++import { tmpdir } from "node:os"; + import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; ++import { ++ allTypeScriptFiles, ++ analyzeTreatmentOwnership, ++ astModuleSpecifiers, ++ forbiddenProductionSupportImports, ++ repositoryTypeScriptFiles, ++} from "./session-lifecycle-ownership-analysis"; + + const REPO_ROOT = resolve(import.meta.dir, ".."); + const SRC_ROOT = join(REPO_ROOT, "src"); +@@ -16,6 +32,19 @@ const REVIEW_MODEL_ROOT = join(CORE_ROOT, "review"); + const EXTENSION_API_TYPES_PATH = join(SRC_ROOT, "extension-api", "types.ts"); + const REVIEW_PROTOCOL_PATH = join(SRC_ROOT, "session", "reviewProtocol.ts"); + const WEB_CLIENT_ROOT = join(SRC_ROOT, "web"); ++const SESSION_BROKER_PACKAGE_ROOT = join(REPO_ROOT, "packages", "session-broker"); ++const SESSION_BROKER_PROCESS_LIFECYCLE = join( ++ SESSION_BROKER_PACKAGE_ROOT, ++ "src", ++ "processLifecycle.ts", ++); ++const SESSION_BROKER_BUN_ROOT = join(REPO_ROOT, "packages", "session-broker-bun"); ++const BUN_CONNECTION_FIXTURE = join( ++ REPO_ROOT, ++ "test", ++ "session-broker-runtime", ++ "bun-connection-fixture.ts", ++); + + // Session modules a browser bundle imports verbatim: the wire schema, the HTTP surface's + // addressing and authorization grammar, the SSE event contract, and the user-facing error +@@ -243,6 +272,107 @@ describe("source architecture boundaries", () => { + }); + }); + ++describe("session broker lifecycle experiment boundaries", () => { ++ test("keeps Effect imports isolated to the private process lifecycle implementation", () => { ++ const files = repositoryTypeScriptFiles(REPO_ROOT); ++ const violations = files.flatMap((path) => ++ astModuleSpecifiers(path) ++ .filter(({ specifier }) => specifier === "effect" || specifier.startsWith("effect/")) ++ .filter( ++ () => ++ path !== SESSION_BROKER_PROCESS_LIFECYCLE && ++ path !== join(SESSION_BROKER_PACKAGE_ROOT, "src", "processLifecycle.test.ts"), ++ ) ++ .map(({ specifier, location }) => `${location.file}:${location.line} -> ${specifier}`), ++ ); ++ expect(violations).toEqual([]); ++ }); ++ ++ test("declares exact Effect only in the session-broker package when treatment exists", () => { ++ const packagePaths = [ ++ join(REPO_ROOT, "package.json"), ++ ...readdirSync(join(REPO_ROOT, "packages"), { withFileTypes: true }) ++ .filter((entry) => entry.isDirectory()) ++ .map((entry) => join(REPO_ROOT, "packages", entry.name, "package.json")) ++ .filter(existsSync), ++ ]; ++ const declarations = packagePaths.flatMap((path) => { ++ const value = JSON.parse(readFileSync(path, "utf8")) as Record; ++ return [ ++ "dependencies", ++ "devDependencies", ++ "peerDependencies", ++ "optionalDependencies", ++ ].flatMap((section) => { ++ const effect = (value[section] as Record | undefined)?.effect; ++ return typeof effect === "string" ? [{ path: repoPath(path), section, effect }] : []; ++ }); ++ }); ++ expect(declarations).toEqual( ++ existsSync(SESSION_BROKER_PROCESS_LIFECYCLE) ++ ? [ ++ { ++ path: "packages/session-broker/package.json", ++ section: "dependencies", ++ effect: "3.22.1", ++ }, ++ ] ++ : [], ++ ); ++ }); ++ ++ test("keeps ws resolution out of the Bun adapter and Bun runtime fixture", () => { ++ const files = [ ++ ...allTypeScriptFiles(SESSION_BROKER_BUN_ROOT), ++ ...(existsSync(BUN_CONNECTION_FIXTURE) ? [BUN_CONNECTION_FIXTURE] : []), ++ ]; ++ const violations = files.flatMap((path) => ++ astModuleSpecifiers(path) ++ .filter(({ specifier }) => specifier === "ws" || specifier.startsWith("ws/")) ++ .map(({ specifier, location }) => `${location.file}:${location.line} -> ${specifier}`), ++ ); ++ expect(violations).toEqual([]); ++ }); ++ ++ test("keeps production imports out of tests and experiment-support trees", () => { ++ expect(forbiddenProductionSupportImports(REPO_ROOT)).toEqual([]); ++ }); ++ ++ test("rejects a production import of a test-named Effect implementation", () => { ++ const root = mkdtempSync(join(tmpdir(), "hunk-support-import-boundary-")); ++ try { ++ const sourceRoot = join(root, "src"); ++ mkdirSync(sourceRoot, { recursive: true }); ++ writeFileSync( ++ join(sourceRoot, "runtime.ts"), ++ 'export { lifecycle } from "./runtime.test.js";\n', ++ ); ++ writeFileSync( ++ join(sourceRoot, "runtime.test.ts"), ++ 'import "effect"; export const lifecycle = 1;\n', ++ ); ++ expect(forbiddenProductionSupportImports(root)).toEqual([ ++ expect.objectContaining({ ++ file: "src/runtime.ts", ++ specifier: "./runtime.test.js", ++ resolved: "src/runtime.test.ts", ++ }), ++ ]); ++ } finally { ++ rmSync(root, { force: true, recursive: true }); ++ } ++ }); ++ ++ test("enforces the frozen treatment ownership shape when the lifecycle exists", () => { ++ const analysis = analyzeTreatmentOwnership(REPO_ROOT); ++ if (!analysis.treatmentPresent) { ++ expect(analysis.violations).toEqual([]); ++ return; ++ } ++ expect(analysis.violations).toEqual([]); ++ }); ++}); ++ + // The seam contract for sharing the review experience with a browser surface: the semantic + // review model and its wire protocol are the primitives every consumer (terminal UI, session + // runtime, browser client) builds on, so they must stay renderer-free and platform-neutral. +diff --git a/scripts/test-session-broker-node.ts b/scripts/test-session-broker-node.ts +index b772d3e5..247d5ec1 100644 +--- a/scripts/test-session-broker-node.ts ++++ b/scripts/test-session-broker-node.ts +@@ -6,25 +6,47 @@ import { join } from "node:path"; + async function main() { + const directory = await mkdtemp(join(tmpdir(), "hunk-session-broker-node-")); + const outfile = join(directory, "adapter.mjs"); ++ const connectionFixture = join(directory, "connection-fixture.mjs"); + try { +- const build = await Bun.build({ +- entrypoints: [join(process.cwd(), "packages/session-broker-node/src/index.ts")], +- outdir: directory, +- naming: "adapter.mjs", +- target: "node", +- format: "esm", +- minify: false, +- sourcemap: "none", +- }); +- if (!build.success) { +- for (const log of build.logs) console.error(log); ++ const builds = await Promise.all([ ++ Bun.build({ ++ entrypoints: [join(process.cwd(), "packages/session-broker-node/src/index.ts")], ++ outdir: directory, ++ naming: "adapter.mjs", ++ target: "node", ++ format: "esm", ++ minify: false, ++ sourcemap: "none", ++ }), ++ Bun.build({ ++ entrypoints: [join(process.cwd(), "test/session-broker-node/connection-fixture.ts")], ++ outdir: directory, ++ naming: "connection-fixture.mjs", ++ target: "node", ++ format: "esm", ++ minify: false, ++ sourcemap: "none", ++ }), ++ ]); ++ if (builds.some((build) => !build.success)) { ++ for (const build of builds) { ++ for (const log of build.logs) console.error(log); ++ } + process.exitCode = 1; + return; + } + const child = Bun.spawn({ + cmd: ["node", "--test", "test/session-broker-node/adapter.test.mjs"], + cwd: process.cwd(), +- env: { ...process.env, HUNK_NODE_ADAPTER_BUNDLE: outfile }, ++ env: { ++ ...process.env, ++ HUNK_NODE_ADAPTER_BUNDLE: outfile, ++ HUNK_NODE_CONNECTION_FIXTURE: connectionFixture, ++ HUNK_SESSION_BROKER_NODE_PACKAGE_JSON: join( ++ process.cwd(), ++ "packages/session-broker-node/package.json", ++ ), ++ }, + stdout: "inherit", + stderr: "inherit", + }); +diff --git a/src/session/broker/brokerClient.test.ts b/src/session/broker/brokerClient.test.ts +index ee213dc3..72e6dbef 100644 +--- a/src/session/broker/brokerClient.test.ts ++++ b/src/session/broker/brokerClient.test.ts +@@ -1,4 +1,4 @@ +-import { afterEach, describe, expect, test } from "bun:test"; ++import { afterAll, afterEach, describe, expect, test } from "bun:test"; + import { createServer } from "node:http"; + import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; + import { tmpdir } from "node:os"; +@@ -9,7 +9,11 @@ import { + createTestSessionSnapshot, + } from "../../../test/helpers/session-daemon-fixtures"; + import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "../protocol"; +-import { SessionBroker, createSessionBrokerDaemon } from "@hunk/session-broker"; ++import { ++ SessionBroker, ++ createSessionBrokerDaemon, ++ createSessionBrokerProcessLifecycle, ++} from "@hunk/session-broker"; + import { serveSessionBrokerDaemon as serveBunSessionBrokerDaemon } from "@hunk/session-broker-bun"; + import { hunkSessionProtocolParsers } from "./protocolParsers"; + import { isQuiescentUpgradeRefusal, SessionBrokerClient } from "./brokerClient"; +@@ -24,6 +28,18 @@ const originalDisable = process.env.HUNK_MCP_DISABLE; + const originalUnsafeRemote = process.env.HUNK_MCP_UNSAFE_ALLOW_REMOTE; + const originalRuntimeDir = process.env.XDG_RUNTIME_DIR; + const originalConsoleError = console.error; ++if (!createSessionBrokerProcessLifecycle) throw new Error("Missing process lifecycle factory."); ++const lifecycle = createSessionBrokerProcessLifecycle(); ++ ++class TestSessionBrokerClient extends SessionBrokerClient { ++ constructor( ++ registration: ConstructorParameters[0], ++ snapshot: ConstructorParameters[1], ++ timing: ConstructorParameters[2] = {}, ++ ) { ++ super(registration, snapshot, timing, lifecycle); ++ } ++} + + function createRegistration() { + return createTestSessionRegistration({ +@@ -63,6 +79,11 @@ async function waitUntil( + throw new Error(`Timed out waiting for ${label}.`); + } + ++afterAll(async () => { ++ lifecycle.close(); ++ await lifecycle.settled; ++}); ++ + afterEach(() => { + if (originalHost === undefined) { + delete process.env.HUNK_MCP_HOST; +@@ -121,7 +142,7 @@ describe("Hunk session daemon client", () => { + + test("keeps its previous registration when the live connection rejects replacement", () => { + const registration = createRegistration(); +- const client = new SessionBrokerClient(registration, createSnapshot()); ++ const client = new TestSessionBrokerClient(registration, createSnapshot()); + (client as any).connection = { + replaceSession() { + throw new Error("connection exploded"); +@@ -137,6 +158,63 @@ describe("Hunk session daemon client", () => { + expect(client.getRegistration()).toBe(registration); + }); + ++ test("shares one active first-attempt promise across concurrent starts", async () => { ++ let release!: () => void; ++ const gate = new Promise((resolve) => { ++ release = resolve; ++ }); ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot()); ++ let attempts = 0; ++ (client as any).ensureDaemonAndConnect = async () => { ++ attempts += 1; ++ await gate; ++ }; ++ ++ const first = client.start(); ++ const second = client.start(); ++ expect(first).toBe(second); ++ expect(attempts).toBe(1); ++ ++ client.stop(); ++ release(); ++ await first; ++ }); ++ ++ test("an explicit start bypasses but does not displace the scheduled automatic retry", async () => { ++ const messages: string[] = []; ++ console.error = (...args: unknown[]) => { ++ messages.push(args.map((value) => String(value)).join(" ")); ++ }; ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot(), { ++ reconnectDelayMs: 50, ++ }); ++ let attempts = 0; ++ (client as any).ensureDaemonAndConnect = async () => { ++ attempts += 1; ++ if (attempts === 1) throw new Error("first startup failed"); ++ }; ++ ++ try { ++ await client.start(); ++ const supervisor = (client as any).startupSupervisor; ++ expect(attempts).toBe(1); ++ expect(messages).toEqual(["[session:broker] first startup failed"]); ++ expect(supervisor).toBeDefined(); ++ expect((client as any).reconnectTimer).toBeUndefined(); ++ ++ await client.start(); ++ expect(attempts).toBe(2); ++ expect((client as any).startupSupervisor).toBe(supervisor); ++ expect(messages).toHaveLength(1); ++ ++ await waitUntil("retained automatic retry", () => attempts === 3); ++ expect(attempts).toBe(3); ++ expect((client as any).reconnectTimer).toBeUndefined(); ++ } finally { ++ client.stop(); ++ } ++ }); ++ + test("logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", async () => { + process.env.HUNK_MCP_HOST = "0.0.0.0"; + process.env.HUNK_MCP_PORT = "47657"; +@@ -148,7 +226,7 @@ describe("Hunk session daemon client", () => { + messages.push(args.map((value) => String(value)).join(" ")); + }; + +- const client = new SessionBrokerClient(createRegistration(), createSnapshot()); ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot()); + + try { + client.start(); +@@ -164,7 +242,7 @@ describe("Hunk session daemon client", () => { + }, 10_000); + + test("does not retain the legacy PID-based incompatible-daemon replacement path", () => { +- const client = new SessionBrokerClient(createRegistration(), createSnapshot()); ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot()); + expect((client as any).restartIncompatibleDaemon).toBeUndefined(); + client.stop(); + }); +@@ -239,12 +317,12 @@ describe("Hunk session daemon client", () => { + console.error = (...args: unknown[]) => { + messages.push(args.map((value) => String(value)).join(" ")); + }; +- const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot(), { + reconnectDelayMs: 10, + }); + const skewedRegistration = createRegistration(); + skewedRegistration.sessionId = "session-skewed"; +- const skewedClient = new SessionBrokerClient(skewedRegistration, createSnapshot(), { ++ const skewedClient = new TestSessionBrokerClient(skewedRegistration, createSnapshot(), { + reconnectDelayMs: 17, + }); + +@@ -270,10 +348,10 @@ describe("Hunk session daemon client", () => { + wsOrigin: `ws://127.0.0.1:${port}`, + }; + (client as any).credentials = credentials; +- (client as any).connect(config); ++ (client as any).connect(config, lifecycle); + await Bun.sleep(7); + (skewedClient as any).credentials = credentials; +- (skewedClient as any).connect(config); ++ (skewedClient as any).connect(config, lifecycle); + await waitUntil("both incompatible session warnings", () => messages.length === 2); + expect(messages.every((message) => message.includes("Close older Hunk windows"))).toBe(true); + await Bun.sleep(60); +@@ -325,7 +403,7 @@ describe("Hunk session daemon client", () => { + process.env.HUNK_MCP_PORT = String(port); + void incumbentDaemon.stopped.then(() => incumbent.stop(true)); + let successor: Awaited> | null = null; +- const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot(), { + // Leave enough time to replace the listener before this client polls again, preserving the + // missed-absence race this test exercises even when Windows delays port reuse. + reconnectDelayMs: 2_000, +@@ -439,7 +517,7 @@ describe("Hunk session daemon client", () => { + }); + void incumbentDaemon.stopped.then(() => incumbent.stop(true)); + let successor: Awaited> | null = null; +- const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot(), { + reconnectDelayMs: 10, + }); + (client as any).ensureDaemonAvailable = async () => { +@@ -520,7 +598,7 @@ describe("Hunk session daemon client", () => { + console.error = (...args: unknown[]) => { + messages.push(args.map((value) => String(value)).join(" ")); + }; +- const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot(), { + reconnectDelayMs: 10, + }); + let attempts = 0; +@@ -539,7 +617,7 @@ describe("Hunk session daemon client", () => { + }); + + test("does no daemon work when start follows terminal stop", async () => { +- const client = new SessionBrokerClient(createRegistration(), createSnapshot()); ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot()); + let attempts = 0; + (client as any).ensureDaemonAndConnect = async () => { + attempts += 1; +@@ -550,13 +628,17 @@ describe("Hunk session daemon client", () => { + expect(attempts).toBe(0); + }); + +- test("does not schedule recovery after stop wins a startup race", async () => { ++ test("does not warn or schedule recovery after stop wins a foreign startup race", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let reconnectScheduled = false; +- const client = new SessionBrokerClient(createRegistration(), createSnapshot()); ++ const messages: string[] = []; ++ console.error = (...args: unknown[]) => { ++ messages.push(args.map((value) => String(value)).join(" ")); ++ }; ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot()); + (client as any).ensureDaemonAndConnect = async () => { + await gate; + throw new Error("late startup failure"); +@@ -570,6 +652,7 @@ describe("Hunk session daemon client", () => { + release(); + await startup; + expect(reconnectScheduled).toBe(false); ++ expect(messages).toEqual([]); + }); + + test("logs one actionable warning when a non-Hunk listener owns the session daemon port", async () => { +@@ -593,7 +676,7 @@ describe("Hunk session daemon client", () => { + messages.push(args.map((value) => String(value)).join(" ")); + }; + +- const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { ++ const client = new TestSessionBrokerClient(createRegistration(), createSnapshot(), { + daemonStartupTimeoutMs: 100, + reconnectDelayMs: 10_000, + }); +diff --git a/src/session/broker/brokerClient.ts b/src/session/broker/brokerClient.ts +index 3c68f385..e29b476b 100644 +--- a/src/session/broker/brokerClient.ts ++++ b/src/session/broker/brokerClient.ts +@@ -2,7 +2,10 @@ import { + createSessionBrokerConnection, + type SessionBrokerConnection as GenericSessionBrokerConnection, + type SessionBrokerConnectionBridge, ++ type SessionBrokerProcessLifecycle, + type SessionBrokerSocketLike, ++ type SessionBrokerStartupAttempt, ++ type SessionBrokerStartupSupervisor, + } from "@hunk/session-broker"; + import type { SessionRegistration, SessionSnapshot } from "@hunk/session-broker-core"; + import { +@@ -74,52 +77,39 @@ export class SessionBrokerClient { + HunkSessionCommandResult + > | null = null; + private bridge: SessionAppBridge | null = null; +- private reconnectTimer: ReturnType | null = null; + private stopped = false; +- private startupPromise: Promise | null = null; + private lastConnectionWarning: string | null = null; + private credentials: HunkSessionBrokerCredentials | null = null; + private waitingForIncumbentExit = false; + private incumbentLaunchFingerprint: string | null = null; ++ private readonly startupSupervisor: SessionBrokerStartupSupervisor; + + constructor( + private registration: SessionRegistration, + private snapshot: SessionSnapshot, + private timing: SessionBrokerClientTiming = {}, +- ) {} ++ lifecycle: SessionBrokerProcessLifecycle, ++ ) { ++ this.startupSupervisor = lifecycle.createStartupSupervisor({ ++ attempt: (attemptTiming) => this.ensureDaemonAndConnect(lifecycle, attemptTiming), ++ retryDelayMs: timing.reconnectDelayMs ?? RECONNECT_DELAY_MS, ++ onFailure: (error) => { ++ if (!this.stopped) this.warnUnavailable(error); ++ }, ++ }); ++ } + + start() { + if (this.stopped || process.env.HUNK_MCP_DISABLE === "1") { + return; + } + +- if (this.startupPromise) { +- return this.startupPromise; +- } +- +- this.startupPromise = this.ensureDaemonAndConnect() +- .catch((error) => { +- if (this.stopped) { +- return; +- } +- +- this.warnUnavailable(error); +- this.scheduleReconnect(); +- }) +- .finally(() => { +- this.startupPromise = null; +- }); +- +- return this.startupPromise; ++ return this.startupSupervisor.start(); + } + + stop() { + this.stopped = true; +- if (this.reconnectTimer) { +- clearTimeout(this.reconnectTimer); +- this.reconnectTimer = null; +- } +- ++ this.startupSupervisor.stop(); + this.connection?.stop(); + this.connection = null; + } +@@ -143,17 +133,26 @@ export class SessionBrokerClient { + return resolveSessionBrokerConfig(); + } + +- private async ensureDaemonAndConnect() { ++ private async ensureDaemonAndConnect( ++ lifecycle: SessionBrokerProcessLifecycle, ++ attemptTiming: SessionBrokerStartupAttempt, ++ ) { + const config = this.resolveConfig(); +- await this.ensureDaemonAvailable(config); +- this.credentials ??= await loadOrCreateHunkSessionBrokerCredentials(); +- this.connect(config); ++ await this.ensureDaemonAvailable(config, attemptTiming); ++ const credentials = this.credentials ?? (await loadOrCreateHunkSessionBrokerCredentials()); ++ if (this.stopped) return; ++ this.credentials = credentials; ++ this.connect(config, lifecycle); + } + +- private async ensureDaemonAvailable(config: ResolvedSessionBrokerConfig) { ++ private async ensureDaemonAvailable( ++ config: ResolvedSessionBrokerConfig, ++ attemptTiming: SessionBrokerStartupAttempt, ++ ) { + await ensureSessionBrokerAvailable({ + config, + timeoutMs: this.timing.daemonStartupTimeoutMs ?? DAEMON_STARTUP_TIMEOUT_MS, ++ timing: attemptTiming, + }); + + // Minimal health proves only liveness. Compatibility and identity are established by the +@@ -170,7 +169,7 @@ export class SessionBrokerClient { + this.connection?.updateSnapshot(snapshot); + } + +- private connect(config: ResolvedSessionBrokerConfig) { ++ private connect(config: ResolvedSessionBrokerConfig, lifecycle: SessionBrokerProcessLifecycle) { + if (this.stopped || this.connection) { + return; + } +@@ -189,6 +188,7 @@ export class SessionBrokerClient { + snapshot: this.snapshot, + bridge: this.bridge, + protocolParsers: hunkSessionProtocolParsers, ++ lifecycle, + producerAuthentication: { + appId: HUNK_SESSION_BROKER_APP_ID, + appRevision: HUNK_SESSION_BROKER_APP_REVISION, +@@ -200,7 +200,7 @@ export class SessionBrokerClient { + }, + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + reconnectDelayMs: this.timing.reconnectDelayMs ?? RECONNECT_DELAY_MS, +- prepareReconnect: async () => { ++ prepareReconnect: async (attemptTiming) => { + if (this.waitingForIncumbentExit) { + const healthy = await isSessionBrokerHealthy(config); + if (healthy) { +@@ -214,7 +214,7 @@ export class SessionBrokerClient { + } + this.waitingForIncumbentExit = false; + } +- await this.ensureDaemonAvailable(config); ++ await this.ensureDaemonAvailable(config, attemptTiming); + }, + resolveClose: (event) => { + const preAuthenticationRefusal = isQuiescentUpgradeRefusal(event); +@@ -239,18 +239,6 @@ export class SessionBrokerClient { + connection.start(); + } + +- private scheduleReconnect(delayMs = this.timing.reconnectDelayMs ?? RECONNECT_DELAY_MS) { +- if (this.reconnectTimer || this.stopped) { +- return; +- } +- +- this.reconnectTimer = setTimeout(() => { +- this.reconnectTimer = null; +- this.start(); +- }, delayMs); +- this.reconnectTimer.unref?.(); +- } +- + private warnUnavailable(error: unknown) { + const message = + error instanceof Error +diff --git a/src/session/broker/brokerLauncher.test.ts b/src/session/broker/brokerLauncher.test.ts +index c544d268..9919a3f2 100644 +--- a/src/session/broker/brokerLauncher.test.ts ++++ b/src/session/broker/brokerLauncher.test.ts +@@ -205,6 +205,66 @@ describe("session daemon launcher", () => { + }); + }); + ++ test("releases an acquired launch lock when the protected health check rejects", async () => { ++ const runtimeDir = createRuntimeDir(); ++ const env = { ...process.env, XDG_RUNTIME_DIR: runtimeDir }; ++ const failure = new Error("health fixture failed"); ++ let healthCalls = 0; ++ ++ await expect( ++ ensureSessionBrokerAvailable({ ++ config: testConfig, ++ env, ++ timeoutMs: 100, ++ intervalMs: 5, ++ isHealthy: async () => { ++ healthCalls += 1; ++ if (healthCalls === 1) return false; ++ throw failure; ++ }, ++ isPortReachable: async () => false, ++ launchDaemon: () => { ++ throw new Error("launch must not run"); ++ }, ++ }), ++ ).rejects.toBe(failure); ++ ++ const paths = resolveSessionBrokerRuntimePaths(testConfig, env); ++ expect(healthCalls).toBe(2); ++ expect(existsSync(paths.lockPath)).toBe(false); ++ }); ++ ++ test("gives a lock owner one fresh full health-poll timeout after the outer deadline", async () => { ++ const runtimeDir = createRuntimeDir(); ++ const env = { ...process.env, XDG_RUNTIME_DIR: runtimeDir }; ++ let healthCalls = 0; ++ const startedAt = performance.now(); ++ ++ await expect( ++ ensureSessionBrokerAvailable({ ++ config: testConfig, ++ env, ++ timeoutMs: 10, ++ intervalMs: 1, ++ isHealthy: async () => { ++ healthCalls += 1; ++ await Bun.sleep(12); ++ return false; ++ }, ++ isPortReachable: async () => false, ++ launchDaemon: () => ({ pid: process.pid }) as ChildProcess, ++ }), ++ ).rejects.toThrow("Timed out waiting for the session broker daemon"); ++ ++ const elapsedMs = performance.now() - startedAt; ++ const paths = resolveSessionBrokerRuntimePaths(testConfig, env); ++ // Initial health plus the protected recheck already exhaust the outer 10ms deadline. A third ++ // call proves the lock owner still receives its separate full polling window. ++ expect(healthCalls).toBeGreaterThanOrEqual(3); ++ expect(elapsedMs).toBeGreaterThanOrEqual(30); ++ expect(existsSync(paths.lockPath)).toBe(false); ++ }); ++ + test("recovers a stale launch lock from a dead launcher and overwrites stale metadata", async () => { + const runtimeDir = createRuntimeDir(); + const env = { ...process.env, XDG_RUNTIME_DIR: runtimeDir }; +diff --git a/src/session/broker/brokerLauncher.ts b/src/session/broker/brokerLauncher.ts +index 5149ea22..19ab2f55 100644 +--- a/src/session/broker/brokerLauncher.ts ++++ b/src/session/broker/brokerLauncher.ts +@@ -50,6 +50,11 @@ interface SessionBrokerLaunchLock { + release: () => void; + } + ++export interface SessionBrokerAttemptTiming { ++ now(): number; ++ sleep(delayMs: number): Promise; ++} ++ + export interface EnsureSessionBrokerAvailableOptions { + config?: ResolvedSessionBrokerConfig; + cwd?: string; +@@ -60,6 +65,7 @@ export interface EnsureSessionBrokerAvailableOptions { + intervalMs?: number; + lockStaleMs?: number; + timeoutMessage?: string; ++ timing?: SessionBrokerAttemptTiming; + isHealthy?: (config: ResolvedSessionBrokerConfig) => Promise; + isPortReachable?: ( + config: Pick, +@@ -277,20 +283,22 @@ async function waitForDaemonHealthWithCheck({ + timeoutMs, + intervalMs, + isHealthy, ++ timing, + }: { + config: ResolvedSessionBrokerConfig; + timeoutMs: number; + intervalMs: number; + isHealthy: (config: ResolvedSessionBrokerConfig) => Promise; ++ timing: SessionBrokerAttemptTiming; + }) { +- const deadline = Date.now() + timeoutMs; ++ const deadline = timing.now() + timeoutMs; + +- while (Date.now() < deadline) { ++ while (timing.now() < deadline) { + if (await isHealthy(config)) { + return true; + } + +- await Bun.sleep(intervalMs); ++ await timing.sleep(intervalMs); + } + + return false; +@@ -535,6 +543,14 @@ export async function ensureSessionBrokerAvailable({ + intervalMs = DEFAULT_DAEMON_HEALTH_POLL_INTERVAL_MS, + lockStaleMs = DEFAULT_DAEMON_LOCK_STALE_MS, + timeoutMessage, ++ timing = { ++ now: () => Date.now(), ++ sleep: (delayMs) => ++ new Promise((resolve) => { ++ const timer = setTimeout(resolve, delayMs); ++ timer.unref?.(); ++ }), ++ }, + isHealthy = (resolvedConfig) => isSessionBrokerHealthy(resolvedConfig), + isPortReachable = isLoopbackPortReachable, + launchDaemon = launchSessionBrokerDaemon, +@@ -546,9 +562,9 @@ export async function ensureSessionBrokerAvailable({ + return; + } + +- const deadline = Date.now() + timeoutMs; ++ const deadline = timing.now() + timeoutMs; + +- while (Date.now() < deadline) { ++ while (timing.now() < deadline) { + const lock = tryAcquireDaemonLaunchLock({ + config, + env, +@@ -580,6 +596,7 @@ export async function ensureSessionBrokerAvailable({ + timeoutMs, + intervalMs, + isHealthy, ++ timing, + }); + if (ready) { + return; +@@ -589,7 +606,7 @@ export async function ensureSessionBrokerAvailable({ + } + } + +- const remainingMs = deadline - Date.now(); ++ const remainingMs = deadline - timing.now(); + if (remainingMs <= 0) { + break; + } +@@ -599,6 +616,7 @@ export async function ensureSessionBrokerAvailable({ + timeoutMs: Math.min(remainingMs, intervalMs), + intervalMs, + isHealthy, ++ timing, + }); + if (ready) { + return; +diff --git a/src/ui/runInteractiveApp.tsx b/src/ui/runInteractiveApp.tsx +index 18f2f66c..24c44496 100644 +--- a/src/ui/runInteractiveApp.tsx ++++ b/src/ui/runInteractiveApp.tsx +@@ -6,6 +6,7 @@ import { + type JobControlInterruptSupport, + type JobControlSuspendSupport, + } from "../core/process/jobControl"; ++import { createSessionBrokerProcessLifecycle } from "@hunk/session-broker"; + import { shutdownSession } from "../core/process/shutdown"; + import { + installTerminalDisconnectSupport, +@@ -50,11 +51,24 @@ export async function runInteractiveApp({ + sourceLabel: bootstrap.changeset.sourceLabel, + }); + const publication = reviewProducer.getPublication(); +- const hostClient = new SessionBrokerClient( +- createSessionRegistration(bootstrap, publication), +- createInitialSessionSnapshot(bootstrap, publication), +- ); +- hostClient.start(); ++ if (!createSessionBrokerProcessLifecycle) { ++ throw new Error("Session broker process lifecycle is unavailable."); ++ } ++ const processLifecycle = createSessionBrokerProcessLifecycle(); ++ let hostClient: SessionBrokerClient; ++ try { ++ hostClient = new SessionBrokerClient( ++ createSessionRegistration(bootstrap, publication), ++ createInitialSessionSnapshot(bootstrap, publication), ++ {}, ++ processLifecycle, ++ ); ++ hostClient.start(); ++ } catch (error) { ++ processLifecycle.close(); ++ await retireExtensionLoadResult(bootstrap.extensions); ++ throw error; ++ } + + // Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux). + const rendererStdin = controllingTerminal?.stdin ?? process.stdin; +@@ -75,6 +89,7 @@ export async function runInteractiveApp({ + }); + } catch (error) { + hostClient.stop(); ++ processLifecycle.close(); + controllingTerminal?.close(); + await retireExtensionLoadResult(bootstrap.extensions); + throw error; +@@ -86,6 +101,7 @@ export async function runInteractiveApp({ + root = createRoot(appRenderer); + } catch (error) { + hostClient.stop(); ++ processLifecycle.close(); + appRenderer.destroy(); + controllingTerminal?.close(); + await retireExtensionLoadResult(bootstrap.extensions); +@@ -116,6 +132,7 @@ export async function runInteractiveApp({ + jobControlSuspendSupport.dispose(); + terminalDisconnectSupport.dispose(); + hostClient.stop(); ++ processLifecycle.close(); + // Release the syntax worker here rather than from the executable entrypoint: this function + // returns once the app is mounted, so an entrypoint-side dispose would fire before the first + // eligible diff ever asked for the worker. +diff --git a/test/session-broker-node/adapter.test.mjs b/test/session-broker-node/adapter.test.mjs +index 90f8d943..024d5502 100644 +--- a/test/session-broker-node/adapter.test.mjs ++++ b/test/session-broker-node/adapter.test.mjs +@@ -1,4 +1,5 @@ + import assert from "node:assert/strict"; ++import { spawn } from "node:child_process"; + import { readFile } from "node:fs/promises"; + import { connect, createServer } from "node:net"; + import { createRequire } from "node:module"; +@@ -19,6 +20,32 @@ const corpus = JSON.parse( + const bundlePath = process.env.HUNK_NODE_ADAPTER_BUNDLE; + if (!bundlePath) throw new Error("HUNK_NODE_ADAPTER_BUNDLE must name the built Node adapter."); + const { serveSessionBrokerDaemon } = await import(pathToFileURL(bundlePath).href); ++const connectionFixture = process.env.HUNK_NODE_CONNECTION_FIXTURE; ++if (!connectionFixture) { ++ throw new Error("HUNK_NODE_CONNECTION_FIXTURE must name the built producer fixture."); ++} ++ ++async function runConnectionFixture(args) { ++ return new Promise((resolve, reject) => { ++ const child = spawn(process.execPath, [connectionFixture, ...args], { ++ env: process.env, ++ stdio: ["ignore", "pipe", "pipe"], ++ }); ++ let stdout = ""; ++ let stderr = ""; ++ child.stdout.on("data", (chunk) => (stdout += chunk)); ++ child.stderr.on("data", (chunk) => (stderr += chunk)); ++ const timeout = setTimeout(() => { ++ child.kill(); ++ reject(new Error(`Timed out waiting for connection fixture: ${args.join(" ")}`)); ++ }, 2_000); ++ child.once("error", reject); ++ child.once("exit", (exitCode) => { ++ clearTimeout(timeout); ++ resolve({ exitCode, stdout, stderr }); ++ }); ++ }); ++} + + async function reservePort() { + const server = createServer(); +@@ -117,6 +144,25 @@ function fakeDaemon(overrides = {}, behavior = {}) { + }; + } + ++test("Node producer authenticates, registers, and leaves maintenance time unref'ed", async () => { ++ const real = await runConnectionFixture(["real"]); ++ assert.deepEqual(real, { ++ exitCode: 0, ++ stdout: "signed-producer-register-observed\n", ++ stderr: "", ++ }); ++ ++ const ready = await runConnectionFixture(["lifecycle-ready"]); ++ assert.equal(ready.exitCode, 0); ++ assert.equal(ready.stderr, ""); ++ assert.match(ready.stdout, /^lifecycle-ready:(?:control|treatment)\n$/); ++ ++ for (const mode of ["unref-handshake", "unref-heartbeat", "unref-reconnect"]) { ++ const result = await runConnectionFixture([mode]); ++ assert.deepEqual(result, { exitCode: 0, stdout: `${mode}\n`, stderr: "" }); ++ } ++}); ++ + test("Node WebCrypto Ed25519 and base64url work without Bun globals", async () => { + assert.equal(typeof globalThis.Bun, "undefined"); + const keys = await crypto.subtle.generateKey("Ed25519", false, ["sign", "verify"]); +diff --git a/test/session-broker-node/connection-fixture.ts b/test/session-broker-node/connection-fixture.ts +new file mode 100644 +index 00000000..86911f2d +--- /dev/null ++++ b/test/session-broker-node/connection-fixture.ts +@@ -0,0 +1,22 @@ ++import { createRequire } from "node:module"; ++import type { SessionBrokerDaemon, SessionBrokerSocketLike } from "@hunk/session-broker"; ++import { serveSessionBrokerDaemon } from "@hunk/session-broker-node"; ++import { ++ runConnectionFixture, ++ type ConnectionFixtureMode, ++} from "../session-broker-runtime/connectionFixture"; ++ ++const mode = process.argv[2] as ConnectionFixtureMode; ++const packageJson = process.env.HUNK_SESSION_BROKER_NODE_PACKAGE_JSON; ++if (!packageJson) throw new TypeError("HUNK_SESSION_BROKER_NODE_PACKAGE_JSON is required."); ++const require = createRequire(packageJson); ++const WebSocket = require("ws") as new (url: string) => SessionBrokerSocketLike; ++ ++await runConnectionFixture({ ++ mode, ++ createSocket: (target) => new WebSocket(target), ++ startDaemon: async (daemon: SessionBrokerDaemon, port: number) => { ++ const running = await serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); ++ return running; ++ }, ++}); +diff --git a/test/session-broker-runtime/bun-connection-fixture.ts b/test/session-broker-runtime/bun-connection-fixture.ts +new file mode 100644 +index 00000000..404819ed +--- /dev/null ++++ b/test/session-broker-runtime/bun-connection-fixture.ts +@@ -0,0 +1,17 @@ ++import type { SessionBrokerDaemon, SessionBrokerSocketLike } from "@hunk/session-broker"; ++import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun"; ++import { runConnectionFixture, type ConnectionFixtureMode } from "./connectionFixture"; ++ ++const mode = process.argv[2] as ConnectionFixtureMode; ++ ++await runConnectionFixture({ ++ mode, ++ createSocket: (target) => new WebSocket(target) as unknown as SessionBrokerSocketLike, ++ startDaemon: async (daemon: SessionBrokerDaemon, port: number) => { ++ const running = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); ++ return { ++ stop: () => running.stop(true), ++ stopped: running.stopped, ++ }; ++ }, ++}); +diff --git a/test/session-broker-runtime/connectionFixture.ts b/test/session-broker-runtime/connectionFixture.ts +new file mode 100644 +index 00000000..f1862e4c +--- /dev/null ++++ b/test/session-broker-runtime/connectionFixture.ts +@@ -0,0 +1,295 @@ ++import { createServer } from "node:net"; ++import * as sessionBroker from "@hunk/session-broker"; ++import { ++ SESSION_BROKER_REGISTRATION_VERSION, ++ SESSION_BROKER_SIGNATURE_ALGORITHM, ++ SessionBroker, ++ SessionBrokerAuthenticator, ++ createSessionBrokerConnection, ++ createSessionBrokerDaemon, ++ createSessionBrokerProtocolParsers, ++ type ProducerGrant, ++ type SessionBrokerDaemon, ++ type SessionBrokerSocketLike, ++ type SessionRegistration, ++ type SessionSnapshot, ++} from "@hunk/session-broker"; ++ ++interface ProcessLifecycle { ++ close(): void; ++ readonly settled?: Promise; ++} ++ ++interface RunningDaemon { ++ stop(): void | Promise; ++ readonly stopped: Promise; ++} ++ ++interface LifecycleFactoryOptions { ++ readonly onDefect?: (message: string) => void; ++} ++ ++class FixtureSocket implements SessionBrokerSocketLike { ++ readyState = 0; ++ onopen: (() => void) | null = null; ++ onmessage: ((event: { data: unknown }) => void) | null = null; ++ onclose: ((event: { code: number; reason: string }) => void) | null = null; ++ onerror: (() => void) | null = null; ++ ++ send(_data: string) {} ++ ++ close(code = 1000, reason = "") { ++ this.readyState = 3; ++ this.onclose?.({ code, reason }); ++ } ++ ++ open() { ++ this.readyState = 1; ++ this.onopen?.(); ++ } ++} ++ ++const protocolParsers = createSessionBrokerProtocolParsers({ ++ appRevision: 1, ++ features: [], ++ parseRegistration: (value) => value as SessionRegistration, ++ parseSnapshot: (value) => value as SessionSnapshot, ++ commands: [], ++}); ++ ++/** Call the treatment factory when exported and reject a present-but-invalid treatment façade. */ ++function createDetectedProcessLifecycle(): ProcessLifecycle | undefined { ++ const factory = ( ++ sessionBroker as typeof sessionBroker & { ++ createSessionBrokerProcessLifecycle?: ( ++ options?: LifecycleFactoryOptions, ++ ) => ProcessLifecycle | undefined; ++ } ++ ).createSessionBrokerProcessLifecycle; ++ if (!factory) return undefined; ++ const lifecycle = factory({ ++ onDefect: (message) => console.error(message), ++ }); ++ if (!lifecycle || typeof lifecycle.close !== "function") { ++ throw new TypeError("The treatment lifecycle factory did not return a lifecycle."); ++ } ++ return lifecycle; ++} ++ ++/** Localize the control/treatment option difference without weakening production declarations. */ ++function createFixtureConnection(options: Record) { ++ const factory = createSessionBrokerConnection as unknown as ( ++ value: Record, ++ ) => ReturnType; ++ return factory(options); ++} ++ ++function createOptions( ++ createSocket: (url: string) => SessionBrokerSocketLike, ++ lifecycle?: ProcessLifecycle, ++) { ++ return { ++ url: "ws://127.0.0.1/session", ++ createSocket, ++ registration: { ++ registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, ++ sessionId: "runtime-fixture", ++ pid: process.pid, ++ cwd: process.cwd(), ++ launchedAt: new Date(0).toISOString(), ++ info: {}, ++ }, ++ snapshot: { ++ updatedAt: new Date(0).toISOString(), ++ state: {}, ++ }, ++ protocolParsers, ++ heartbeatIntervalMs: 60_000, ++ reconnectDelayMs: 60_000, ++ ...(lifecycle ? { lifecycle } : {}), ++ }; ++} ++ ++/** Reserve one loopback port without passing credentials across a process boundary. */ ++async function reservePort() { ++ const server = createServer(); ++ await new Promise((resolve, reject) => { ++ server.once("error", reject); ++ server.listen(0, "127.0.0.1", resolve); ++ }); ++ const address = server.address(); ++ const port = typeof address === "object" && address ? address.port : 0; ++ await new Promise((resolve) => server.close(() => resolve())); ++ return port; ++} ++ ++/** Generate one daemon identity and one independently signed producer grant in-process. */ ++async function createSignedProducerAuthentication(endpoint: string) { ++ const producerPair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const daemonPair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const grant: ProducerGrant = { ++ kind: "producer", ++ appId: "dev.example", ++ principalId: "runtime-fixture", ++ keyId: "runtime-fixture-key", ++ grantId: "runtime-fixture-grant", ++ algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, ++ issuedAt: Date.now() - 1_000, ++ expiresAt: Date.now() + 60_000, ++ revocationId: "runtime-fixture-revocation", ++ mayDelegate: false, ++ operations: ["register", "reconnect"], ++ }; ++ const authenticator = new SessionBrokerAuthenticator({ ++ appId: grant.appId, ++ appRevision: 1, ++ generation: "runtime-fixture-generation", ++ daemonIdentity: { ++ keyId: "runtime-fixture-daemon", ++ privateKey: daemonPair.privateKey, ++ }, ++ credentials: [{ grant, publicKey: producerPair.publicKey }], ++ }); ++ return { ++ authenticator, ++ connection: { ++ appId: grant.appId, ++ appRevision: 1, ++ credential: { grant, privateKey: producerPair.privateKey }, ++ daemon: { ++ keyId: "runtime-fixture-daemon", ++ publicKey: daemonPair.publicKey, ++ }, ++ }, ++ endpoint, ++ }; ++} ++ ++/** Wait until the actual broker has accepted the register message after signed hello. */ ++async function waitForRegistration(broker: { getSessionIds(): readonly string[] }) { ++ const deadline = Date.now() + 2_000; ++ while (Date.now() < deadline) { ++ if (broker.getSessionIds().includes("runtime-fixture")) return; ++ await new Promise((resolve) => setTimeout(resolve, 5)); ++ } ++ throw new Error("Timed out waiting for the signed producer register message."); ++} ++ ++/** Exercise signed hello and registration through one runtime's native daemon and socket adapters. */ ++async function runSignedConnection({ ++ createSocket, ++ startDaemon, ++}: { ++ createSocket: (url: string) => SessionBrokerSocketLike; ++ startDaemon: (daemon: SessionBrokerDaemon, port: number) => Promise; ++}) { ++ const lifecycle = createDetectedProcessLifecycle(); ++ const port = await reservePort(); ++ const endpoint = `ws://127.0.0.1:${port}/session`; ++ const authentication = await createSignedProducerAuthentication(endpoint); ++ const broker = new SessionBroker({ protocolParsers }); ++ const daemon = createSessionBrokerDaemon({ ++ broker, ++ appId: "dev.example", ++ producerEndpoint: endpoint, ++ helloAuthenticator: authentication.authenticator, ++ idleTimeoutMs: 0, ++ }); ++ const running = await startDaemon(daemon, port); ++ const connection = createFixtureConnection({ ++ ...createOptions(createSocket, lifecycle), ++ url: endpoint, ++ producerAuthentication: authentication.connection, ++ }); ++ try { ++ connection.start(); ++ await waitForRegistration(broker); ++ console.log("signed-producer-register-observed"); ++ } finally { ++ connection.stop(); ++ lifecycle?.close(); ++ await lifecycle?.settled; ++ await running.stop(); ++ await running.stopped; ++ } ++} ++ ++/** Create a valid producer credential that intentionally leaves its handshake incomplete. */ ++async function createPendingProducerAuthentication() { ++ const pair = (await crypto.subtle.generateKey("Ed25519", false, [ ++ "sign", ++ "verify", ++ ])) as CryptoKeyPair; ++ const grant: ProducerGrant = { ++ kind: "producer", ++ appId: "dev.example", ++ principalId: "runtime-fixture", ++ keyId: "runtime-fixture-key", ++ grantId: "runtime-fixture-grant", ++ algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, ++ issuedAt: Date.now() - 1_000, ++ expiresAt: Date.now() + 60_000, ++ revocationId: "runtime-fixture-revocation", ++ mayDelegate: false, ++ operations: ["register"], ++ }; ++ return { ++ appId: grant.appId, ++ appRevision: 1, ++ credential: { grant, privateKey: pair.privateKey }, ++ daemon: { keyId: "runtime-fixture-daemon", publicKey: pair.publicKey }, ++ }; ++} ++ ++export type ConnectionFixtureMode = ++ | "real" ++ | "lifecycle-ready" ++ | "unref-handshake" ++ | "unref-heartbeat" ++ | "unref-reconnect"; ++ ++/** Exercise producer lifecycle behavior in a standalone runtime process. */ ++export async function runConnectionFixture({ ++ mode, ++ createSocket, ++ startDaemon, ++}: { ++ mode: ConnectionFixtureMode; ++ createSocket: (url: string) => SessionBrokerSocketLike; ++ startDaemon: (daemon: SessionBrokerDaemon, port: number) => Promise; ++}) { ++ if (mode === "real") { ++ await runSignedConnection({ createSocket, startDaemon }); ++ return; ++ } ++ ++ if (mode === "lifecycle-ready") { ++ const lifecycle = createDetectedProcessLifecycle(); ++ console.log(lifecycle ? "lifecycle-ready:treatment" : "lifecycle-ready:control"); ++ lifecycle?.close(); ++ await lifecycle?.settled; ++ return; ++ } ++ ++ const lifecycle = createDetectedProcessLifecycle(); ++ const socket = new FixtureSocket(); ++ const options = createOptions(() => socket, lifecycle); ++ const connection = ++ mode === "unref-handshake" ++ ? createFixtureConnection({ ++ ...options, ++ producerAuthentication: await createPendingProducerAuthentication(), ++ }) ++ : createFixtureConnection(options); ++ connection.start(); ++ socket.open(); ++ if (mode === "unref-reconnect") socket.close(1006, "retry"); ++ console.log(mode); ++ // Do not stop: the process proves that maintenance sleeps and the treatment root are unref-aware. ++} diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/original-MANIFEST.attestation.sha256 b/research/session-broker-effect-lifecycle/artifacts/treatment/original-MANIFEST.attestation.sha256 new file mode 100644 index 000000000..0f0dd83e7 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/original-MANIFEST.attestation.sha256 @@ -0,0 +1 @@ +0c31e4128daef210dcbcd4e0d49d4187ea44cbd98e8fe1c5f137faec8080e22c MANIFEST.sha256 diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/original-MANIFEST.sha256 b/research/session-broker-effect-lifecycle/artifacts/treatment/original-MANIFEST.sha256 new file mode 100644 index 000000000..0ca311682 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/original-MANIFEST.sha256 @@ -0,0 +1,50 @@ +01db95a9367dbe9f2b8b43bbc7bba630df1d45e59b42a19dd0334491097e1f53 declarations/processLifecycle.d.ts +039a791a279fadbb4268a3dc06c4c481c89df5905bac824859c7b5d832faca3a bun-runtime-fixture/bun-connection-fixture.js +044c6954a840f2c36e6a2e9f04aa94db7843401c41b7c8e8f13ae6ff4b09820d declarations/index.d.ts +09ecfba2f4587c0290e0a7a2f94eb478d5199d4872a15c8acd963dabb6be34cf declarations/authentication.d.ts +0a32cd16e7af75d0664249183f2afe51f95b062e23527b614c0a3ddd36dc5751 declarations/clientAuthentication.d.ts +1555e1dcbb9789ef95781eab3cb28ac11a78256a2b400146add32986578486a0 validation/focused.stdout.log +2227069a83141c9d8a22b91e778f6c7c9a233ae8aff114b3d0db8d141b7d79cb complete-experiment.patch +3ecd3363b3da37a0fdee720de9031b0d2ea02eff8311dee6506a5fe66bcfec40 validation/pty-integration.stderr.log +3ee72e4e8fcaa8f070a2ec7844483ed52fdf79aa2d7aca604ebcfabb105d8653 semantics.json +49ef2e254b1afa2ad13e64a564ed4ab7c85b6537fea0343f75a434bd76595d26 validation/full-unit.stdout.log +50956a2c27b1a442408131f86103ff1df8a7f4a29502df81d216ba19e2c2f4a6 commands/system-node-version.stdout.log +548d62736227d9114524efa538acc5297e9121d78f98839fb2d1a8476b13df58 commands/compiled-hunk-3.stdout.log +55d4b062345736d3d10cb6fcde722a19a1bfbcccd3b1594bf8f08164fc9a9c9d declarations/broker.d.ts +586f6ea32683073c513a90b149ce50a694cd009d72a9e2be0d65b3577c6af75e commands/compiled-hunk-2.stdout.log +5a2c7eff57808daef35c98eb9ad7e8c25b9d763877148fa9671fcfa26fb9f2c0 declarations/crypto.d.ts +66f12c5702c0b10f114a47bc3fd2ee5bfc8140e74a49af39364d1d0ecc1cd452 source-identity.json +6b0c84346bfabf45490860f39089f4d427f9366e439fc2129edcd9fef9ac0d0c declarations/daemon.d.ts +70a9db88f4d0962ba72ae7ef8d5999b0ea4355ccda2e816a84ecd4ba4d6e454e validation/real-node.stderr.log +7897b9858b1edd6497b3feef5da75dee36fc6e88a84080b73a78fc96b85aef0f commands/dependency-tree.stdout.log +78c9ba4b5461c552fe43e52904d0eaf6f5ddef47588cf477ea2a697c44182351 validation/dependencies.stderr.log +80589dd842557f2005b62002168c4e46ad6e31a6f6080bc8aa5e622e0b4845dc validation/focused.stderr.log +8366207267355d3e3d5bf3bf6e8c94c5f93f6078c34f08973fa2b38cdda6cc92 validation/typecheck.stderr.log +8408475635514aa71ab3b0c39b4ae2ce7721cf092cac5696e9c46bc3fc87fb28 validation/lint.stdout.log +88716e0bd251b262103e6158eb04a60cf5196ddc49a743fd3fcd84f55d0961b1 validation/real-node.stdout.log +8b8f0d3e2b8c077f64dafcc4623383ffb1a10c34caccee1436e751aea8ac1e89 builds/session-broker-node-1.js +8b8f0d3e2b8c077f64dafcc4623383ffb1a10c34caccee1436e751aea8ac1e89 builds/session-broker-node-2.js +8b8f0d3e2b8c077f64dafcc4623383ffb1a10c34caccee1436e751aea8ac1e89 builds/session-broker-node-3.js +915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc commands/compiled-hunk-1.stderr.log +915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc commands/compiled-hunk-2.stderr.log +915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc commands/compiled-hunk-3.stderr.log +98324699be60fe37a6f3f3269d3eaf8b32351feebe87fe7fdda34ce3cc58b0a8 validation/full-unit.stderr.log +98cda74fd30bb3e44aa61b2c4b332bfc8bfbca2f99095694345e027d603987eb commands/compiled-hunk-1.stdout.log +9bb9cafd557a7e2fe8ab9dff24f39f4d9b1f57074a256d136a28141efddc077f validation/dependencies.stdout.log +b2af7d61f8c0e7a541eae44addf8b36400d7c954ca6bf70bdf9b0d048eb7331f declarations/connection.d.ts +b2dff80d5fca1f1286fa77107660bc4380492aa10e2be93e185856f21c3993fd validation/lint.stderr.log +c2e44b51e0b6ebc3fe9d15b4380d73e964972b13de1b0d03914e51568c350e55 canary.json +c300abdcdd3b2f6bfc44abee4412913e8262f30f91300005724a19f34ee444d6 builds/session-broker-bun-1.js +c300abdcdd3b2f6bfc44abee4412913e8262f30f91300005724a19f34ee444d6 builds/session-broker-bun-2.js +c300abdcdd3b2f6bfc44abee4412913e8262f30f91300005724a19f34ee444d6 builds/session-broker-bun-3.js +c409137e7750f2b7251cf020aaef22abcfc4ff4d6af043a3f49397362536c5cc result.json +c412adae08de072313bd2dc70bbd65661becc5b7b15b7b20da875bb2757fe7f6 declarations/protocolParsers.d.ts +d6684989b8dd63b37d2f1954270826fbe1bd89a8debd12bcacf03ed1c6140ef6 validation/pty-integration.stdout.log +d8a03ae57031ed4d217449aa220f275d57d346b8d6a8f4b42f364b261b48ccbb ownership-inventory.json +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 commands/dependency-tree.stderr.log +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 commands/public-declarations.stderr.log +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 commands/public-declarations.stdout.log +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 commands/system-node-version.stderr.log +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 validation/typecheck.stdout.log +f15cd14853e4816dceeca82bf6b0512a0f6d9ee4bd4353c9f55b7f5119bf6e5f validation/manifest.json +fd559cb4cafe71a7ffc3766a551f71ddc075674ae0c7073b6cad4db7ada9987f declarations/types.d.ts diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/ownership-inventory.json b/research/session-broker-effect-lifecycle/artifacts/treatment/ownership-inventory.json new file mode 100644 index 000000000..493355888 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/ownership-inventory.json @@ -0,0 +1,290 @@ +{ + "schemaVersion": 3, + "label": "treatment", + "treatmentPresent": true, + "locations": { + "connectionNativeTiming": [], + "clientNativeTiming": [], + "launcherBunSleep": [], + "connectionProperties": [ + { + "file": "packages/session-broker/src/connection.ts", + "line": 121, + "column": 3, + "kind": "property:socket", + "text": "private socket: Socket | null = null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 122, + "column": 3, + "kind": "property:activeSocket", + "text": "private activeSocket: Socket | null = null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 123, + "column": 3, + "kind": "property:bridge", + "text": "private bridge: SessionBrokerConnectionBridge | null;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 124, + "column": 3, + "kind": "property:limits", + "text": "readonly limits: Readonly;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 126, + "column": 3, + "kind": "property:queuedMessages", + "text": "private queuedMessages: Array> = [];" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 127, + "column": 3, + "kind": "property:executingMessages", + "text": "private executingMessages = new Set>();" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 128, + "column": 3, + "kind": "property:queuedCommandCountBudget", + "text": "private readonly queuedCommandCountBudget: ResourceBudget;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 129, + "column": 3, + "kind": "property:queuedCommandByteBudget", + "text": "private readonly queuedCommandByteBudget: ResourceBudget;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 130, + "column": 3, + "kind": "property:draining", + "text": "private draining = false;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 131, + "column": 3, + "kind": "property:stopped", + "text": "private stopped = false;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 132, + "column": 3, + "kind": "property:registration", + "text": "private registration: SessionRegistration;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 133, + "column": 3, + "kind": "property:snapshot", + "text": "private snapshot: SessionSnapshot;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 134, + "column": 3, + "kind": "property:producerHellos", + "text": "private readonly producerHellos = new WeakMap<\n Socket,\n {\n request: SessionBrokerHelloChallengeRequest;\n pending?: PendingSessionBrokerHello | null;\n }\n >();" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 141, + "column": 3, + "kind": "property:connectionSupervisor", + "text": "private readonly connectionSupervisor: SessionBrokerConnectionSupervisor;" + }, + { + "file": "packages/session-broker/src/connection.ts", + "line": 144, + "column": 5, + "kind": "property:options", + "text": "private readonly options: SessionBrokerConnectionOptions<\n Info,\n State,\n Socket,\n ServerMessage,\n Result\n >" + } + ], + "clientProperties": [ + { + "file": "src/session/broker/brokerClient.ts", + "line": 72, + "column": 3, + "kind": "property:connection", + "text": "private connection: GenericSessionBrokerConnection<\n HunkSessionInfo,\n HunkSessionState,\n SessionBrokerSocketLike,\n HunkSessionServerMessage,\n HunkSessionCommandResult\n > | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 79, + "column": 3, + "kind": "property:bridge", + "text": "private bridge: SessionAppBridge | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 80, + "column": 3, + "kind": "property:stopped", + "text": "private stopped = false;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 81, + "column": 3, + "kind": "property:lastConnectionWarning", + "text": "private lastConnectionWarning: string | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 82, + "column": 3, + "kind": "property:credentials", + "text": "private credentials: HunkSessionBrokerCredentials | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 83, + "column": 3, + "kind": "property:waitingForIncumbentExit", + "text": "private waitingForIncumbentExit = false;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 84, + "column": 3, + "kind": "property:incumbentLaunchFingerprint", + "text": "private incumbentLaunchFingerprint: string | null = null;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 85, + "column": 3, + "kind": "property:startupSupervisor", + "text": "private readonly startupSupervisor: SessionBrokerStartupSupervisor;" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 88, + "column": 5, + "kind": "property:registration", + "text": "private registration: SessionRegistration" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 89, + "column": 5, + "kind": "property:snapshot", + "text": "private snapshot: SessionSnapshot" + }, + { + "file": "src/session/broker/brokerClient.ts", + "line": 90, + "column": 5, + "kind": "property:timing", + "text": "private timing: SessionBrokerClientTiming = {}" + } + ], + "connectionSupervisorProperties": [ + { + "file": "packages/session-broker/src/connection.ts", + "line": 141, + "column": 3, + "kind": "property:connectionSupervisor", + "text": "private readonly connectionSupervisor: SessionBrokerConnectionSupervisor;" + } + ], + "clientSupervisorProperties": [ + { + "file": "src/session/broker/brokerClient.ts", + "line": 85, + "column": 3, + "kind": "property:startupSupervisor", + "text": "private readonly startupSupervisor: SessionBrokerStartupSupervisor;" + } + ], + "connectionDelayOwnerProperties": [], + "clientDelayOwnerProperties": [], + "clientPromiseOwnerProperties": [], + "processTaskRegistries": [], + "processImplementationClasses": [ + { + "file": "packages/session-broker/src/processLifecycle.ts", + "line": 135, + "column": 1, + "kind": "class:EffectSessionBrokerStartupSupervisor", + "text": "class EffectSessionBrokerStartupSupervisor implements SessionBrokerStartupSupervisor {\n private stopped = false;\n private started = false;\n private waiting = false;\n private activeAttempt: Promise | undefined;\n private manualWake" + }, + { + "file": "packages/session-broker/src/processLifecycle.ts", + "line": 264, + "column": 1, + "kind": "class:EffectSessionBrokerConnectionSupervisor", + "text": "class EffectSessionBrokerConnectionSupervisor<\n Socket,\n Event,\n> implements SessionBrokerConnectionSupervisor {\n private stopped = false;\n private started = false;\n private program: Fiber.RuntimeFiber | undefined;\n priva" + }, + { + "file": "packages/session-broker/src/processLifecycle.ts", + "line": 385, + "column": 1, + "kind": "class:EffectSessionBrokerProcessLifecycle", + "text": "class EffectSessionBrokerProcessLifecycle implements SessionBrokerProcessLifecycle {\n private closed = false;\n private closeStarted = false;\n private readonly runtime;\n private readonly scope;\n private resolveSettled!: () => void;\n re" + } + ], + "processDelayClasses": [], + "processSetInterval": [], + "processTimersOutsideClock": [], + "managedRuntimeConstruction": [ + { + "file": "packages/session-broker/src/processLifecycle.ts", + "line": 396, + "column": 20, + "kind": "ManagedRuntime.make", + "text": "ManagedRuntime.make(lifecycleOptions.layer)" + } + ], + "customClockConstruction": [ + { + "file": "packages/session-broker/src/processLifecycle.ts", + "line": 492, + "column": 17, + "kind": "createUnrefClock", + "text": "createUnrefClock()" + } + ], + "fixedHeartbeatSchedule": [ + { + "file": "packages/session-broker/src/processLifecycle.ts", + "line": 129, + "column": 49, + "kind": "Schedule.fixed", + "text": "Schedule.fixed(Duration.millis(intervalMs))" + } + ], + "delayedHeartbeatSleep": [ + { + "file": "packages/session-broker/src/processLifecycle.ts", + "line": 127, + "column": 10, + "kind": "Effect.sleep", + "text": "Effect.sleep(Duration.millis(intervalMs))" + } + ], + "runtimeFactoryCalls": [ + { + "file": "src/ui/runInteractiveApp.tsx", + "line": 57, + "column": 28, + "kind": "process-lifecycle-factory-call", + "text": "createSessionBrokerProcessLifecycle()" + } + ] + }, + "violations": [] +} diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/result.json b/research/session-broker-effect-lifecycle/artifacts/treatment/result.json new file mode 100644 index 000000000..f793701ee --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/result.json @@ -0,0 +1,5423 @@ +{ + "schemaVersion": 3, + "label": "treatment", + "recordedAt": "2026-08-31T05:56:32.027Z", + "environment": { + "head": "42d2b9dd2f3144e33080f159f4cc9c2824ad1708", + "bun": "1.3.14", + "embeddedNode": "24.3.0", + "systemNodeTranscript": "tmp/session-lifecycle-effect/treatment/commands/system-node-version.stdout.log", + "os": "linux", + "release": "7.1.8-arch1-3", + "arch": "x64", + "cpu": "AMD RYZEN AI MAX+ 395 w/ Radeon 8060S" + }, + "identities": { + "harnessSha256": "ab809ae95145a41fa9e3d4f4de1173582b50f425531c88d33be097c44c6f9d85", + "planSha256": "9f9ffdd2068e2a02f6f4380d8928105e1cd592b2b43f955855b149ed42633cf5", + "lockfileSha256": "3e37dd4a97f293ad571c5e6192eedc08846170c0ee095791310ab2ce5dfc52bb", + "firstExperimentPatchSha256": "f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56" + }, + "builds": [ + { + "target": "bun", + "index": 1, + "retained": true, + "durationMs": 23.602677000017138, + "bytes": 331670, + "sha256": "c300abdcdd3b2f6bfc44abee4412913e8262f30f91300005724a19f34ee444d6" + }, + { + "target": "node", + "index": 1, + "retained": true, + "durationMs": 12.653218000021297, + "bytes": 331672, + "sha256": "8b8f0d3e2b8c077f64dafcc4623383ffb1a10c34caccee1436e751aea8ac1e89" + }, + { + "target": "bun", + "index": 2, + "retained": true, + "durationMs": 13.029967000009492, + "bytes": 331670, + "sha256": "c300abdcdd3b2f6bfc44abee4412913e8262f30f91300005724a19f34ee444d6" + }, + { + "target": "node", + "index": 2, + "retained": true, + "durationMs": 12.210676999995485, + "bytes": 331672, + "sha256": "8b8f0d3e2b8c077f64dafcc4623383ffb1a10c34caccee1436e751aea8ac1e89" + }, + { + "target": "bun", + "index": 3, + "retained": true, + "durationMs": 12.363965999975335, + "bytes": 331670, + "sha256": "c300abdcdd3b2f6bfc44abee4412913e8262f30f91300005724a19f34ee444d6" + }, + { + "target": "node", + "index": 3, + "retained": true, + "durationMs": 13.769055000011576, + "bytes": 331672, + "sha256": "8b8f0d3e2b8c077f64dafcc4623383ffb1a10c34caccee1436e751aea8ac1e89" + }, + { + "target": "compiled-hunk", + "index": 1, + "retained": false, + "retentionNote": "Identity measured in dist and recorded; binary is not copied into arm evidence.", + "durationMs": 358.9128820000042, + "bytes": 177891456, + "sha256": "ef9ebe8a41096e91c7c9b9d0b87eb23ea193d19539bcbbce80ecbca3309bc40b" + }, + { + "target": "compiled-hunk", + "index": 2, + "retained": false, + "retentionNote": "Identity measured in dist and recorded; binary is not copied into arm evidence.", + "durationMs": 328.633662999986, + "bytes": 177891456, + "sha256": "ef9ebe8a41096e91c7c9b9d0b87eb23ea193d19539bcbbce80ecbca3309bc40b" + }, + { + "target": "compiled-hunk", + "index": 3, + "retained": false, + "retentionNote": "Identity measured in dist and recorded; binary is not copied into arm evidence.", + "durationMs": 326.56623999998556, + "bytes": 177891456, + "sha256": "ef9ebe8a41096e91c7c9b9d0b87eb23ea193d19539bcbbce80ecbca3309bc40b" + } + ], + "measurements": { + "processToLifecycleReadyCold": { + "status": "measured", + "unit": "ms", + "warmups": [ + 41.62224699999206, + 41.19186800002353, + 36.6295880000107, + 39.65919900001609, + 40.859032999986084 + ], + "samples": [ + 38.58003199999803, + 36.343170000007376, + 40.35273199999938, + 39.35594899998978, + 37.97172900001169, + 37.87479699999676, + 38.135215999995125, + 37.79903500000364, + 39.040376999997534, + 38.95201099998667, + 39.42001900001196, + 39.706458000000566, + 38.096633000008296, + 36.42988300000434, + 39.24321700001019, + 36.40619899999001, + 37.82775900000706, + 39.27621999999974, + 37.87029900000198, + 37.69879699999001, + 38.28557800000999, + 37.281501999998, + 38.49345899999025, + 36.50839000000269, + 35.844573999987915, + 36.6143190000148, + 37.53478799998993, + 38.05912400002126, + 36.774379999988014, + 37.1381939999992 + ], + "median": 37.87479699999676, + "p95": 39.706458000000566 + }, + "synchronousConnectionStop": { + "status": "measured", + "unit": "ms", + "warmups": [ + 0.188945000001695, + 0.010580000001937151, + 0.013705999997910112, + 0.00542999999015592, + 0.005330999993020669 + ], + "samples": [ + 0.005890999978873879, + 0.0040880000160541385, + 0.00789399998029694, + 0.003968000004533678, + 0.009828000009292737, + 0.00366700001177378, + 0.0053699999989476055, + 0.00350700001581572, + 0.002745000005234033, + 0.0024649999977555126, + 0.002515000000130385, + 0.002334000018890947, + 0.0025739999837242067, + 0.0026149999757763, + 0.0027049999916926026, + 0.0026150000048801303, + 0.004278000007616356, + 0.002505000011296943, + 0.002143999998224899, + 0.002665000007255003, + 0.0027950000076089054, + 0.0022849999950267375, + 0.0022539999918080866, + 0.0022440000029746443, + 0.008525999990524724, + 0.004629000002751127, + 0.012022999988403171, + 0.0029159999976400286, + 0.010248999984469265, + 0.005250000016530976 + ], + "median": 0.0027950000076089054, + "p95": 0.010248999984469265 + }, + "unauthenticatedInMemoryRegistration": { + "status": "measured", + "unit": "ms", + "warmups": [ + 0.33613100001821294, + 0.12175900000147521, + 0.08820600001490675, + 0.08289499999955297, + 0.08750399999553338 + ], + "samples": [ + 0.08117200000560842, + 0.08805500000016764, + 0.0865019999910146, + 0.07894900001701899, + 0.07783600001130253, + 0.06944099999964237, + 0.08240499999374151, + 0.07829799997853115, + 0.07133400000748225, + 0.07237599999643862, + 0.09692199999699369, + 0.08766499999910593, + 0.0708829999784939, + 0.07846700001391582, + 0.06697600000188686, + 0.07725499998196028, + 0.07315700000617653, + 0.0760430000082124, + 0.08060200000181794, + 0.0774049999890849, + 0.0601629999873694, + 0.07707400000072084, + 0.06785700001637451, + 0.07872799999313429, + 0.06705599999986589, + 0.0660840000200551, + 0.0689600000041537, + 0.07154499998432584, + 0.0710129999788478, + 0.07678400000440888 + ], + "median": 0.07678400000440888, + "p95": 0.08805500000016764 + }, + "authenticatedRegistration": { + "status": "not-measured", + "reason": "The signed Bun/Node fixtures prove hello and broker-observed register, but do not expose a stable isolated registration timestamp in both arms." + }, + "commandRoundTrip": { + "status": "measured", + "unit": "ms", + "warmups": [ + 1.0349639999913052, + 0.1930419999989681, + 0.10197099999641068, + 0.09415600000647828, + 0.09166199999162927 + ], + "samples": [ + 0.0895089999830816, + 0.09670200000982732, + 0.08721399999922141, + 0.08276600000681356, + 0.08705400000326335, + 0.0841480000235606, + 0.08223500000895001, + 0.07700499999918975, + 0.08120200000121258, + 0.09111099998699501, + 0.08688300001085736, + 0.09644100000150502, + 0.1392819999891799, + 0.1116500000061933, + 0.09241399998427369, + 0.07345799999893643, + 0.06772700001602061, + 0.07644400000572205, + 0.08600200002547354, + 0.07856699998956174, + 0.08272500001476146, + 0.09752300000400282, + 0.09467699998640455, + 0.09623100000317208, + 0.09945700000389479, + 0.1474469999957364, + 0.09452800001599826, + 0.0739890000259038, + 0.1250949999957811, + 0.09395699997548945 + ], + "median": 0.08721399999922141, + "p95": 0.1392819999891799 + }, + "reconnectGeneration": { + "status": "measured", + "unit": "ms", + "warmups": [ + 3.326197999995202, + 4.266944999981206, + 1.3125360000121873, + 1.2021379999932833, + 1.2273350000032224 + ], + "samples": [ + 1.2115859999903478, + 1.1626840000099037, + 1.1440190000284929, + 1.1484369999961928, + 2.541113999992376, + 1.221765000023879, + 1.1624940000183415, + 1.1471149999997579, + 1.128991000005044, + 1.1156949999858625, + 1.1208459999761544, + 1.1534169999940787, + 1.1700479999999516, + 1.126376000000164, + 1.128179999999702, + 1.1387499999837019, + 1.114503999997396, + 2.0728239999734797, + 1.159458000009181, + 1.1219979999877978, + 1.1161260000080802, + 1.1231799999950454, + 1.101529000006849, + 1.1101449999841861, + 1.0959379999840166, + 1.0953580000204965, + 1.1042949999973644, + 1.1094239999947604, + 1.154158000019379, + 1.1319969999894965 + ], + "median": 1.128991000005044, + "p95": 2.0728239999734797 + }, + "transportListenerAcquisitionContext": { + "status": "measured", + "unit": "ms", + "warmups": [ + 17.394425000005867, + 1.2735519999987446, + 0.980602000025101, + 0.9533709999814164, + 0.8891100000182632 + ], + "samples": [ + 0.8555869999981951, + 0.8417909999843687, + 0.8802430000214372, + 0.8667680000071414, + 0.028091999993193895, + 0.01932600000873208, + 0.8392370000074152, + 0.02697099998476915, + 1.3938189999898896, + 0.025246999983210117, + 0.015759000001708046, + 0.7603679999883752, + 0.024796999990940094, + 0.8069560000149067, + 0.030778000014834106, + 0.7635439999867231, + 0.025899000000208616, + 0.01533900000504218, + 0.686970000009751, + 0.02309400000376627, + 0.6967189999995753, + 0.023354000004474074, + 0.7059569999983069, + 0.020659000001614913, + 0.6818709999788553, + 0.021631000010529533, + 0.6971699999994598, + 0.024014999973587692, + 0.7084909999975935, + 0.5284720000054222 + ], + "median": 0.5284720000054222, + "p95": 0.8802430000214372 + }, + "transportDaemonOrderedStopContext": { + "status": "measured", + "unit": "ms", + "warmups": [ + 1.0436099999933504, + 0.1006690000067465, + 0.03426399998716079, + 1.2288990000088234, + 0.0524779999977909 + ], + "samples": [ + 1.2677309999999125, + 0.04652700002770871, + 0.02599900000495836, + 0.02451600000495091, + 1.3286059999954887, + 0.04453399998601526, + 0.7395590000087395, + 0.04234000001451932, + 0.02721100000781007, + 1.2515499999863096, + 0.044873999984702095, + 0.03078800000366755, + 1.3592639999988023, + 0.0772350000042934, + 1.2697960000077728, + 0.05028399999719113, + 0.025206999998772517, + 0.0280229999916628, + 0.022221999999601394, + 1.3563980000035372, + 0.043150999990757555, + 1.2997320000140462, + 0.04292100001475774, + 0.024815999990096316, + 0.019697000010637566, + 0.020748999988427386, + 1.3274939999973867, + 0.044904999987920746, + 1.3015549999836367, + 0.048661999986507 + ], + "median": 0.044873999984702095, + "p95": 1.3563980000035372 + }, + "hunkStartupPolling": { + "status": "not-measured", + "reason": "Hunk startup polling is a Bun product path with no honest portable real-Node equivalent; characterization tests freeze behavior instead of timing a synthetic timer." + } + }, + "coarseObservations": { + "cpu": { + "classification": "coarse-non-isolated-observation", + "conclusionPolicy": "Do not infer lifecycle performance or security conclusions from this sample.", + "operations": 100, + "wallMs": 7.686870000005001, + "user": 17380, + "system": 2030 + }, + "rss": { + "classification": "coarse-non-isolated-observation", + "conclusionPolicy": "Do not infer lifecycle performance or security conclusions from this sample.", + "unit": "bytes", + "samples": [ + 266625024, + 266625024, + 266625024, + 266625024, + 266625024 + ], + "median": 266625024, + "p95": 266625024 + } + }, + "processExit": { + "bun": { + "status": "passed", + "evidence": { + "name": "focused", + "command": [ + "bun", + "test", + "packages/session-broker-core", + "packages/session-broker", + "packages/session-broker-bun", + "src/session/broker", + "scripts/source-boundaries.test.ts" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:15.306Z", + "finishedAt": "2026-08-31T05:52:21.029Z", + "durationMs": 5723.474007, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/focused.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/focused.stderr.log", + "stdoutSha256": "1555e1dcbb9789ef95781eab3cb28ac11a78256a2b400146add32986578486a0", + "stderrSha256": "80589dd842557f2005b62002168c4e46ad6e31a6f6080bc8aa5e622e0b4845dc", + "parsedCounts": { + "passed": 385, + "failed": 0 + } + }, + "modes": [ + "signed-register", + "lifecycle-ready", + "pending-handshake", + "heartbeat", + "reconnect" + ] + }, + "node": { + "status": "passed", + "evidence": { + "name": "real-node", + "command": [ + "bun", + "run", + "test:session-broker-node" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:21.030Z", + "finishedAt": "2026-08-31T05:52:21.555Z", + "durationMs": 524.5388590000002, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/real-node.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/real-node.stderr.log", + "stdoutSha256": "88716e0bd251b262103e6158eb04a60cf5196ddc49a743fd3fcd84f55d0961b1", + "stderrSha256": "70a9db88f4d0962ba72ae7ef8d5999b0ea4355ccda2e816a84ecd4ba4d6e454e", + "parsedCounts": { + "passed": 4, + "failed": 0, + "skipped": 0 + } + }, + "modes": [ + "signed-register", + "lifecycle-ready", + "pending-handshake", + "heartbeat", + "reconnect" + ] + } + }, + "declarations": { + "files": [ + "authentication.d.ts", + "broker.d.ts", + "clientAuthentication.d.ts", + "connection.d.ts", + "crypto.d.ts", + "daemon.d.ts", + "index.d.ts", + "processLifecycle.d.ts", + "protocolParsers.d.ts", + "types.d.ts" + ], + "forbiddenPattern": "(?:from\\s+[\"']effect[\"']|\\b(?:Effect|ManagedRuntime|Runtime|Layer|Scope|Fiber|Cause|Exit|Duration|Schedule|Clock)\\b)", + "forbiddenMatches": [] + }, + "dependencyPlacement": { + "effectVersions": [ + "3.22.1" + ], + "declarations": [ + { + "path": "packages/session-broker/package.json", + "section": "dependencies", + "value": "3.22.1" + } + ], + "commandExitCode": 0, + "commandExpectation": "Treatment requires exit 0 with exact effect@3.22.1." + }, + "moduleBoundaries": { + "effectImports": [ + { + "specifier": "effect", + "file": "packages/session-broker/src/processLifecycle.test.ts", + "line": 2, + "column": 1, + "kind": "import", + "text": "import { Effect, TestClock, TestContext } from \"effect\";" + }, + { + "specifier": "effect", + "file": "packages/session-broker/src/processLifecycle.ts", + "line": 1, + "column": 1, + "kind": "import", + "text": "import {\n Cause,\n Clock,\n Duration,\n Effect,\n Exit,\n Fiber,\n FiberId,\n Layer,\n ManagedRuntime,\n Schedule,\n Scope,\n} from \"effect\";" + } + ], + "misplacedEffectImports": [], + "forbiddenSupportImports": [], + "bunWsImports": [], + "bunBuildGate": "The Bun runtime fixture is executed only from a bundle built with a ws/ws/* rejecting onResolve plugin; a direct ws import probe must fail under the same plugin.", + "bunBuildGraph": { + "buildTarget": "node package-bundling graph; produced bundle executed by Bun", + "pluginFilter": "^ws(?:\\/|$)", + "directProbeRejected": [ + "ws" + ], + "fixtureRejected": [], + "bundleSha256": "039a791a279fadbb4268a3dc06c4c481c89df5905bac824859c7b5d832faca3a" + } + }, + "behavioralTestContinuity": { + "contracts": [ + { + "path": "packages/session-broker/src/connection.test.ts", + "tests": [ + { + "name": "throws an initial socket construction failure synchronously from start", + "line": 124, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 137, + "column": 5, + "kind": "expect", + "text": "expect(() => connection.start())" + } + ] + }, + { + "name": "arms delayed fixed-rate heartbeats through one cleared native interval", + "line": 141, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 155, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 157, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 159, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 164, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.filter((message) => JSON.parse(message).type === \"heartbeat\"))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 171, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + } + ] + }, + { + "name": "clears each producer handshake timeout on close and terminal stop", + "line": 174, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 236, + "column": 7, + "kind": "expect", + "text": "expect(handles)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 237, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears the producer handshake timeout after successful signed activation", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 318, + "column": 7, + "kind": "expect", + "text": "expect(broker.getSessionIds())" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 319, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "registers on open and sends later snapshot updates", + "line": 329, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 356, + "column": 5, + "kind": "expect", + "text": "expect(registerMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 367, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 368, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.snapshot)" + } + ] + }, + { + "name": "withholds registration and replacement updates until producer authentication completes", + "line": 374, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 422, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 423, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent[0]!))" + } + ] + }, + { + "name": "keeps the previous registration when replacement send throws", + "line": 427, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 448, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n connection.replaceSession(\n { ...registration, sessionId: \"session-2\" },\n { ...createSnapshot(), state: { selectedIndex: 2 } },\n ),\n )" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 454, + "column": 5, + "kind": "expect", + "text": "expect(connection.getRegistration())" + } + ] + }, + { + "name": "queues broker commands until the app bridge is ready", + "line": 458, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 495, + "column": 5, + "kind": "expect", + "text": "expect(resultMessage)" + } + ] + }, + { + "name": "parses and transforms each server command once before bridge dispatch", + "line": 498, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 562, + "column": 5, + "kind": "expect", + "text": "expect(bridgedInput)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 563, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, resultCalls })" + } + ] + }, + { + "name": "closes malformed and mismatched commands without invoking the bridge", + "line": 570, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 615, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 621, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "enforces text framing and the exact websocket message ceiling before app parsing", + "line": 625, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 690, + "column": 5, + "kind": "expect", + "text": "expect(new TextEncoder().encode(exact).byteLength)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 693, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 729, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(`${exact} `))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 730, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(\"{\".repeat(maxBytes + 1)))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 731, + "column": 5, + "kind": "expect", + "text": "expect(\n emitRejected(\n JSON.stringify({\n type: \"command\",\n requestId: \"request-extra\",\n command: \"annotate\",\n input: { summary: \"not parsed\" },\n extra: true,\n }),\n ),\n " + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 742, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(new Uint8Array([123, 125])))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 743, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "ignores a late socket callback after stop before parsing or reserving", + "line": 746, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 806, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "does not migrate a late command result onto a replacement socket", + "line": 809, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 853, + "column": 5, + "kind": "expect", + "text": "expect(sockets[0]!.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 854, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "discards queued commands when their source socket disconnects", + "line": 858, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 903, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 904, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "stops replaying a queued batch when its source socket disconnects", + "line": 908, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 957, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps 32 missing-bridge commands FIFO and explicitly rejects the 33rd", + "line": 961, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 991, + "column": 5, + "kind": "expect", + "text": "expect(overflow)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1000, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps a hung bridge plus queued commands within the same 32-command budget", + "line": 1004, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1035, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent.at(-1)!))" + } + ] + }, + { + "name": "retains a hung command reservation across disconnect and reconnect", + "line": 1042, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1090, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(sockets[1]!.sent.at(-1)!))" + } + ] + }, + { + "name": "serializes bridge execution while preserving arrival order", + "line": 1097, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1138, + "column": 5, + "kind": "expect", + "text": "expect(started)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1141, + "column": 5, + "kind": "expect", + "text": "expect(started)" + } + ] + }, + { + "name": "rejects producer hello wrappers with unknown or dangerous keys", + "line": 1145, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1188, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + } + ] + }, + { + "name": "prepares reconnect once per attempt and stops after awaited preparation", + "line": 1196, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1227, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1228, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1229, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1245, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + }, + { + "name": "reconnects after socket close unless a close directive disables it", + "line": 1248, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1280, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1284, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1285, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + } + ], + "forbiddenModes": [] + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "tests": [ + { + "name": "only treats exact pre-authentication compatibility closes as quiescent refusals", + "line": 122, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 124, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 125, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Malformed session broker protocol.\",\n authenticated: false,\n }),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 132, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 133, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 134, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Session broker authentication failed.\",\n authenticated: false,\n }),\n )" + } + ] + }, + { + "name": "keeps its previous registration when the live connection rejects replacement", + "line": 143, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 152, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n client.replaceSession(\n { ...registration, sessionId: \"replacement-session\" },\n createSnapshot(),\n ),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 158, + "column": 5, + "kind": "expect", + "text": "expect(client.getRegistration())" + } + ] + }, + { + "name": "shares one active first-attempt promise across concurrent starts", + "line": 161, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 175, + "column": 5, + "kind": "expect", + "text": "expect(first)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 176, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "an explicit start bypasses but does not displace the scheduled automatic retry", + "line": 183, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 200, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 201, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 202, + "column": 7, + "kind": "expect", + "text": "expect(supervisor)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 203, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 206, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 207, + "column": 7, + "kind": "expect", + "text": "expect((client as any).startupSupervisor)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 208, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 211, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 212, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + } + ] + }, + { + "name": "logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", + "line": 218, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 235, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 238, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + }, + { + "name": "does not retain the legacy PID-based incompatible-daemon replacement path", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 246, + "column": 5, + "kind": "expect", + "text": "expect((client as any).restartIncompatibleDaemon)" + } + ] + }, + { + "name": "logs one actionable warning when a refreshed daemon rejects an older Hunk window", + "line": 250, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 356, + "column": 7, + "kind": "expect", + "text": "expect(messages.every((message) => message.includes(\"Close older Hunk windows\")))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 358, + "column": 7, + "kind": "expect", + "text": "expect(websocketOpens)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 359, + "column": 7, + "kind": "expect", + "text": "expect((client as any).waitingForIncumbentExit)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 360, + "column": 7, + "kind": "expect", + "text": "expect((skewedClient as any).waitingForIncumbentExit)" + } + ] + }, + { + "name": "authenticates after a successor becomes healthy before the waiter observes absence", + "line": 368, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 465, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 466, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "waits out an incompatible incumbent and registers on its successor with one connection", + "line": 477, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 537, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 557, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 583, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "retries the complete startup cycle and recovers without restarting the client", + "line": 596, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 613, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "does no daemon work when start follows terminal stop", + "line": 619, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 628, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "does not warn or schedule recovery after stop wins a foreign startup race", + "line": 631, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 654, + "column": 5, + "kind": "expect", + "text": "expect(reconnectScheduled)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 655, + "column": 5, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "logs one actionable warning when a non-Hunk listener owns the session daemon port", + "line": 658, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 686, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 690, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 691, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 694, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + } + ], + "forbiddenModes": [] + } + ], + "treatmentComparison": { + "control": [ + { + "path": "packages/session-broker/src/connection.test.ts", + "tests": [ + { + "name": "throws an initial socket construction failure synchronously from start", + "line": 117, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 129, + "column": 5, + "kind": "expect", + "text": "expect(() => connection.start())" + } + ] + }, + { + "name": "arms delayed fixed-rate heartbeats through one cleared native interval", + "line": 133, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 162, + "column": 7, + "kind": "expect", + "text": "expect(intervalMs)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 163, + "column": 7, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 164, + "column": 7, + "kind": "expect", + "text": "expect(intervalCallback)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 166, + "column": 7, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 172, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears each producer handshake timeout on close and terminal stop", + "line": 179, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 239, + "column": 7, + "kind": "expect", + "text": "expect(handles)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 240, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears the producer handshake timeout after successful signed activation", + "line": 247, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 320, + "column": 7, + "kind": "expect", + "text": "expect(broker.getSessionIds())" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 321, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "registers on open and sends later snapshot updates", + "line": 331, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 357, + "column": 5, + "kind": "expect", + "text": "expect(registerMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 368, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 369, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.snapshot)" + } + ] + }, + { + "name": "withholds registration and replacement updates until producer authentication completes", + "line": 375, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 422, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 423, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent[0]!))" + } + ] + }, + { + "name": "keeps the previous registration when replacement send throws", + "line": 427, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 447, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n connection.replaceSession(\n { ...registration, sessionId: \"session-2\" },\n { ...createSnapshot(), state: { selectedIndex: 2 } },\n ),\n )" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 453, + "column": 5, + "kind": "expect", + "text": "expect(connection.getRegistration())" + } + ] + }, + { + "name": "queues broker commands until the app bridge is ready", + "line": 457, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 493, + "column": 5, + "kind": "expect", + "text": "expect(resultMessage)" + } + ] + }, + { + "name": "parses and transforms each server command once before bridge dispatch", + "line": 496, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 559, + "column": 5, + "kind": "expect", + "text": "expect(bridgedInput)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 560, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, resultCalls })" + } + ] + }, + { + "name": "closes malformed and mismatched commands without invoking the bridge", + "line": 567, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 611, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 617, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "enforces text framing and the exact websocket message ceiling before app parsing", + "line": 621, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 685, + "column": 5, + "kind": "expect", + "text": "expect(new TextEncoder().encode(exact).byteLength)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 688, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 723, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(`${exact} `))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 724, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(\"{\".repeat(maxBytes + 1)))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 725, + "column": 5, + "kind": "expect", + "text": "expect(\n emitRejected(\n JSON.stringify({\n type: \"command\",\n requestId: \"request-extra\",\n command: \"annotate\",\n input: { summary: \"not parsed\" },\n extra: true,\n }),\n ),\n " + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 736, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(new Uint8Array([123, 125])))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 737, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "ignores a late socket callback after stop before parsing or reserving", + "line": 740, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 799, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "does not migrate a late command result onto a replacement socket", + "line": 802, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 845, + "column": 5, + "kind": "expect", + "text": "expect(sockets[0]!.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 846, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "discards queued commands when their source socket disconnects", + "line": 850, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 894, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 895, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "stops replaying a queued batch when its source socket disconnects", + "line": 899, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 947, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps 32 missing-bridge commands FIFO and explicitly rejects the 33rd", + "line": 951, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 980, + "column": 5, + "kind": "expect", + "text": "expect(overflow)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 989, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps a hung bridge plus queued commands within the same 32-command budget", + "line": 993, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1023, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent.at(-1)!))" + } + ] + }, + { + "name": "retains a hung command reservation across disconnect and reconnect", + "line": 1030, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1077, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(sockets[1]!.sent.at(-1)!))" + } + ] + }, + { + "name": "serializes bridge execution while preserving arrival order", + "line": 1084, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1124, + "column": 5, + "kind": "expect", + "text": "expect(started)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1127, + "column": 5, + "kind": "expect", + "text": "expect(started)" + } + ] + }, + { + "name": "rejects producer hello wrappers with unknown or dangerous keys", + "line": 1131, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1173, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + } + ] + }, + { + "name": "prepares reconnect once per attempt and stops after awaited preparation", + "line": 1181, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1211, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1212, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1213, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1229, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + }, + { + "name": "reconnects after socket close unless a close directive disables it", + "line": 1232, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1263, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1267, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1268, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + } + ], + "forbiddenModes": [] + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "tests": [ + { + "name": "only treats exact pre-authentication compatibility closes as quiescent refusals", + "line": 101, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 103, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 104, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Malformed session broker protocol.\",\n authenticated: false,\n }),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 111, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 112, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 113, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Session broker authentication failed.\",\n authenticated: false,\n }),\n )" + } + ] + }, + { + "name": "keeps its previous registration when the live connection rejects replacement", + "line": 122, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 131, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n client.replaceSession(\n { ...registration, sessionId: \"replacement-session\" },\n createSnapshot(),\n ),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 137, + "column": 5, + "kind": "expect", + "text": "expect(client.getRegistration())" + } + ] + }, + { + "name": "shares one active first-attempt promise across concurrent starts", + "line": 140, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 154, + "column": 5, + "kind": "expect", + "text": "expect(first)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 155, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "an explicit start bypasses but does not displace the scheduled automatic retry", + "line": 162, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 191, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 192, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 193, + "column": 7, + "kind": "expect", + "text": "expect(retryCallback)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 194, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 197, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 198, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 199, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 203, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 204, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + } + ] + }, + { + "name": "logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", + "line": 212, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 229, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 232, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + }, + { + "name": "does not retain the legacy PID-based incompatible-daemon replacement path", + "line": 238, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 240, + "column": 5, + "kind": "expect", + "text": "expect((client as any).restartIncompatibleDaemon)" + } + ] + }, + { + "name": "logs one actionable warning when a refreshed daemon rejects an older Hunk window", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 350, + "column": 7, + "kind": "expect", + "text": "expect(messages.every((message) => message.includes(\"Close older Hunk windows\")))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 352, + "column": 7, + "kind": "expect", + "text": "expect(websocketOpens)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 353, + "column": 7, + "kind": "expect", + "text": "expect((client as any).waitingForIncumbentExit)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 354, + "column": 7, + "kind": "expect", + "text": "expect((skewedClient as any).waitingForIncumbentExit)" + } + ] + }, + { + "name": "authenticates after a successor becomes healthy before the waiter observes absence", + "line": 362, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 459, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 460, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "waits out an incompatible incumbent and registers on its successor with one connection", + "line": 471, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 531, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 551, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 577, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "retries the complete startup cycle and recovers without restarting the client", + "line": 590, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 607, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "does no daemon work when start follows terminal stop", + "line": 613, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 622, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "does not warn or schedule recovery after stop wins a foreign startup race", + "line": 625, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 648, + "column": 5, + "kind": "expect", + "text": "expect(reconnectScheduled)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 649, + "column": 5, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "logs one actionable warning when a non-Hunk listener owns the session daemon port", + "line": 652, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 680, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 684, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 685, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 688, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + } + ], + "forbiddenModes": [] + } + ], + "treatment": [ + { + "path": "packages/session-broker/src/connection.test.ts", + "tests": [ + { + "name": "throws an initial socket construction failure synchronously from start", + "line": 124, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 137, + "column": 5, + "kind": "expect", + "text": "expect(() => connection.start())" + } + ] + }, + { + "name": "arms delayed fixed-rate heartbeats through one cleared native interval", + "line": 141, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 155, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 157, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 159, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 164, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.filter((message) => JSON.parse(message).type === \"heartbeat\"))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 171, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + } + ] + }, + { + "name": "clears each producer handshake timeout on close and terminal stop", + "line": 174, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 236, + "column": 7, + "kind": "expect", + "text": "expect(handles)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 237, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears the producer handshake timeout after successful signed activation", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 318, + "column": 7, + "kind": "expect", + "text": "expect(broker.getSessionIds())" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 319, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "registers on open and sends later snapshot updates", + "line": 329, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 356, + "column": 5, + "kind": "expect", + "text": "expect(registerMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 367, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 368, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.snapshot)" + } + ] + }, + { + "name": "withholds registration and replacement updates until producer authentication completes", + "line": 374, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 422, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 423, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent[0]!))" + } + ] + }, + { + "name": "keeps the previous registration when replacement send throws", + "line": 427, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 448, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n connection.replaceSession(\n { ...registration, sessionId: \"session-2\" },\n { ...createSnapshot(), state: { selectedIndex: 2 } },\n ),\n )" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 454, + "column": 5, + "kind": "expect", + "text": "expect(connection.getRegistration())" + } + ] + }, + { + "name": "queues broker commands until the app bridge is ready", + "line": 458, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 495, + "column": 5, + "kind": "expect", + "text": "expect(resultMessage)" + } + ] + }, + { + "name": "parses and transforms each server command once before bridge dispatch", + "line": 498, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 562, + "column": 5, + "kind": "expect", + "text": "expect(bridgedInput)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 563, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, resultCalls })" + } + ] + }, + { + "name": "closes malformed and mismatched commands without invoking the bridge", + "line": 570, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 615, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 621, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "enforces text framing and the exact websocket message ceiling before app parsing", + "line": 625, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 690, + "column": 5, + "kind": "expect", + "text": "expect(new TextEncoder().encode(exact).byteLength)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 693, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 729, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(`${exact} `))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 730, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(\"{\".repeat(maxBytes + 1)))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 731, + "column": 5, + "kind": "expect", + "text": "expect(\n emitRejected(\n JSON.stringify({\n type: \"command\",\n requestId: \"request-extra\",\n command: \"annotate\",\n input: { summary: \"not parsed\" },\n extra: true,\n }),\n ),\n " + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 742, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(new Uint8Array([123, 125])))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 743, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "ignores a late socket callback after stop before parsing or reserving", + "line": 746, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 806, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "does not migrate a late command result onto a replacement socket", + "line": 809, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 853, + "column": 5, + "kind": "expect", + "text": "expect(sockets[0]!.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 854, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "discards queued commands when their source socket disconnects", + "line": 858, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 903, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 904, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "stops replaying a queued batch when its source socket disconnects", + "line": 908, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 957, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps 32 missing-bridge commands FIFO and explicitly rejects the 33rd", + "line": 961, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 991, + "column": 5, + "kind": "expect", + "text": "expect(overflow)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1000, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps a hung bridge plus queued commands within the same 32-command budget", + "line": 1004, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1035, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent.at(-1)!))" + } + ] + }, + { + "name": "retains a hung command reservation across disconnect and reconnect", + "line": 1042, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1090, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(sockets[1]!.sent.at(-1)!))" + } + ] + }, + { + "name": "serializes bridge execution while preserving arrival order", + "line": 1097, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1138, + "column": 5, + "kind": "expect", + "text": "expect(started)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1141, + "column": 5, + "kind": "expect", + "text": "expect(started)" + } + ] + }, + { + "name": "rejects producer hello wrappers with unknown or dangerous keys", + "line": 1145, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1188, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + } + ] + }, + { + "name": "prepares reconnect once per attempt and stops after awaited preparation", + "line": 1196, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1227, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1228, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1229, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1245, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + }, + { + "name": "reconnects after socket close unless a close directive disables it", + "line": 1248, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1280, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1284, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1285, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + } + ], + "forbiddenModes": [] + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "tests": [ + { + "name": "only treats exact pre-authentication compatibility closes as quiescent refusals", + "line": 122, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 124, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 125, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Malformed session broker protocol.\",\n authenticated: false,\n }),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 132, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 133, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 134, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Session broker authentication failed.\",\n authenticated: false,\n }),\n )" + } + ] + }, + { + "name": "keeps its previous registration when the live connection rejects replacement", + "line": 143, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 152, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n client.replaceSession(\n { ...registration, sessionId: \"replacement-session\" },\n createSnapshot(),\n ),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 158, + "column": 5, + "kind": "expect", + "text": "expect(client.getRegistration())" + } + ] + }, + { + "name": "shares one active first-attempt promise across concurrent starts", + "line": 161, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 175, + "column": 5, + "kind": "expect", + "text": "expect(first)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 176, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "an explicit start bypasses but does not displace the scheduled automatic retry", + "line": 183, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 200, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 201, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 202, + "column": 7, + "kind": "expect", + "text": "expect(supervisor)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 203, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 206, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 207, + "column": 7, + "kind": "expect", + "text": "expect((client as any).startupSupervisor)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 208, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 211, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 212, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + } + ] + }, + { + "name": "logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", + "line": 218, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 235, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 238, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + }, + { + "name": "does not retain the legacy PID-based incompatible-daemon replacement path", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 246, + "column": 5, + "kind": "expect", + "text": "expect((client as any).restartIncompatibleDaemon)" + } + ] + }, + { + "name": "logs one actionable warning when a refreshed daemon rejects an older Hunk window", + "line": 250, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 356, + "column": 7, + "kind": "expect", + "text": "expect(messages.every((message) => message.includes(\"Close older Hunk windows\")))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 358, + "column": 7, + "kind": "expect", + "text": "expect(websocketOpens)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 359, + "column": 7, + "kind": "expect", + "text": "expect((client as any).waitingForIncumbentExit)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 360, + "column": 7, + "kind": "expect", + "text": "expect((skewedClient as any).waitingForIncumbentExit)" + } + ] + }, + { + "name": "authenticates after a successor becomes healthy before the waiter observes absence", + "line": 368, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 465, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 466, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "waits out an incompatible incumbent and registers on its successor with one connection", + "line": 477, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 537, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 557, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 583, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "retries the complete startup cycle and recovers without restarting the client", + "line": 596, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 613, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "does no daemon work when start follows terminal stop", + "line": 619, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 628, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "does not warn or schedule recovery after stop wins a foreign startup race", + "line": 631, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 654, + "column": 5, + "kind": "expect", + "text": "expect(reconnectScheduled)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 655, + "column": 5, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "logs one actionable warning when a non-Hunk listener owns the session daemon port", + "line": 658, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 686, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 690, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 691, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 694, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + } + ], + "forbiddenModes": [] + } + ], + "note": "Mechanical anti-weakening evidence only; independent review must establish behavioral preservation." + }, + "note": "Mechanical anti-weakening evidence only; independent review must establish behavioral preservation." + }, + "verifiedControlAttestation": { + "attestationFileSha256": "d284decdee198773a3bff2e9ae6bca6164ef6b23286f8a1fd74db26636361fe7", + "manifestSha256": "7b0ecea5b2d133424c0fbf680a58914af73de09c5ac602a148f66520b2ff52b2", + "verifiedEntries": 49, + "note": "Local integrity/reproducibility evidence only; this is not a cryptographic signer or timestamp." + }, + "frozenControlInputs": { + "count": 11, + "files": [ + { + "path": "docs/session-broker-effect-lifecycle-experiment.md", + "sha256": "9f9ffdd2068e2a02f6f4380d8928105e1cd592b2b43f955855b149ed42633cf5" + }, + { + "path": "packages/session-broker-bun/src/connectionRuntime.test.ts", + "sha256": "eabb3ff9d2e06c27a600ce6bd6d13f6ea0ff2574eb247cfc5b07e7901c7a371c" + }, + { + "path": "scripts/session-broker-effect-lifecycle-experiment.ts", + "sha256": "ab809ae95145a41fa9e3d4f4de1173582b50f425531c88d33be097c44c6f9d85" + }, + { + "path": "scripts/session-lifecycle-ownership-analysis.ts", + "sha256": "78bce16eaa7c757ffd79f7f0118b93089543b9df77b5ad1506e0eb7e29b060cd" + }, + { + "path": "scripts/source-boundaries.test.ts", + "sha256": "7b61f489bf87ac8d9d4960b65852f3c8829b605f6989865f8f3aa2ffc994378e" + }, + { + "path": "scripts/test-session-broker-node.ts", + "sha256": "e5a966843b6970538deebcb746eab0e7c1efc70f74398cb8bd473611adf107bf" + }, + { + "path": "src/session/broker/brokerLauncher.test.ts", + "sha256": "79b4f1cb2fb43f6caa9199c5700db07aa652fddf891384b48bfc44c3e71ce41c" + }, + { + "path": "test/session-broker-node/adapter.test.mjs", + "sha256": "21dca2de748bf1ddd8a9ba0c6578006d85974e111fa4a7daa9b0f58e745fdfeb" + }, + { + "path": "test/session-broker-node/connection-fixture.ts", + "sha256": "91c4cea63bdcf8188bb4688d3cf6fceeb6e18cbbbd12233593bf77abc7e007b2" + }, + { + "path": "test/session-broker-runtime/bun-connection-fixture.ts", + "sha256": "05e0710382840a5a4b171e42157ed4211b1f993d46e5b0e3b4c0ad0dbd6a6a95" + }, + { + "path": "test/session-broker-runtime/connectionFixture.ts", + "sha256": "d4162f1bc31791b7d285ed5d39457eff421832fd18225b7fd77b20f9055765f3" + } + ], + "behavioralTestContinuity": { + "control": [ + { + "path": "packages/session-broker/src/connection.test.ts", + "tests": [ + { + "name": "throws an initial socket construction failure synchronously from start", + "line": 117, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 129, + "column": 5, + "kind": "expect", + "text": "expect(() => connection.start())" + } + ] + }, + { + "name": "arms delayed fixed-rate heartbeats through one cleared native interval", + "line": 133, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 162, + "column": 7, + "kind": "expect", + "text": "expect(intervalMs)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 163, + "column": 7, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 164, + "column": 7, + "kind": "expect", + "text": "expect(intervalCallback)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 166, + "column": 7, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 172, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears each producer handshake timeout on close and terminal stop", + "line": 179, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 239, + "column": 7, + "kind": "expect", + "text": "expect(handles)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 240, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears the producer handshake timeout after successful signed activation", + "line": 247, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 320, + "column": 7, + "kind": "expect", + "text": "expect(broker.getSessionIds())" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 321, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "registers on open and sends later snapshot updates", + "line": 331, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 357, + "column": 5, + "kind": "expect", + "text": "expect(registerMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 368, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 369, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.snapshot)" + } + ] + }, + { + "name": "withholds registration and replacement updates until producer authentication completes", + "line": 375, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 422, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 423, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent[0]!))" + } + ] + }, + { + "name": "keeps the previous registration when replacement send throws", + "line": 427, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 447, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n connection.replaceSession(\n { ...registration, sessionId: \"session-2\" },\n { ...createSnapshot(), state: { selectedIndex: 2 } },\n ),\n )" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 453, + "column": 5, + "kind": "expect", + "text": "expect(connection.getRegistration())" + } + ] + }, + { + "name": "queues broker commands until the app bridge is ready", + "line": 457, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 493, + "column": 5, + "kind": "expect", + "text": "expect(resultMessage)" + } + ] + }, + { + "name": "parses and transforms each server command once before bridge dispatch", + "line": 496, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 559, + "column": 5, + "kind": "expect", + "text": "expect(bridgedInput)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 560, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, resultCalls })" + } + ] + }, + { + "name": "closes malformed and mismatched commands without invoking the bridge", + "line": 567, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 611, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 617, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "enforces text framing and the exact websocket message ceiling before app parsing", + "line": 621, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 685, + "column": 5, + "kind": "expect", + "text": "expect(new TextEncoder().encode(exact).byteLength)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 688, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 723, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(`${exact} `))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 724, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(\"{\".repeat(maxBytes + 1)))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 725, + "column": 5, + "kind": "expect", + "text": "expect(\n emitRejected(\n JSON.stringify({\n type: \"command\",\n requestId: \"request-extra\",\n command: \"annotate\",\n input: { summary: \"not parsed\" },\n extra: true,\n }),\n ),\n " + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 736, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(new Uint8Array([123, 125])))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 737, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "ignores a late socket callback after stop before parsing or reserving", + "line": 740, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 799, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "does not migrate a late command result onto a replacement socket", + "line": 802, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 845, + "column": 5, + "kind": "expect", + "text": "expect(sockets[0]!.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 846, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "discards queued commands when their source socket disconnects", + "line": 850, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 894, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 895, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "stops replaying a queued batch when its source socket disconnects", + "line": 899, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 947, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps 32 missing-bridge commands FIFO and explicitly rejects the 33rd", + "line": 951, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 980, + "column": 5, + "kind": "expect", + "text": "expect(overflow)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 989, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps a hung bridge plus queued commands within the same 32-command budget", + "line": 993, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1023, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent.at(-1)!))" + } + ] + }, + { + "name": "retains a hung command reservation across disconnect and reconnect", + "line": 1030, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1077, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(sockets[1]!.sent.at(-1)!))" + } + ] + }, + { + "name": "serializes bridge execution while preserving arrival order", + "line": 1084, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1124, + "column": 5, + "kind": "expect", + "text": "expect(started)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1127, + "column": 5, + "kind": "expect", + "text": "expect(started)" + } + ] + }, + { + "name": "rejects producer hello wrappers with unknown or dangerous keys", + "line": 1131, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1173, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + } + ] + }, + { + "name": "prepares reconnect once per attempt and stops after awaited preparation", + "line": 1181, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1211, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1212, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1213, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1229, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + }, + { + "name": "reconnects after socket close unless a close directive disables it", + "line": 1232, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1263, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1267, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1268, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + } + ], + "forbiddenModes": [] + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "tests": [ + { + "name": "only treats exact pre-authentication compatibility closes as quiescent refusals", + "line": 101, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 103, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 104, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Malformed session broker protocol.\",\n authenticated: false,\n }),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 111, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 112, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 113, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Session broker authentication failed.\",\n authenticated: false,\n }),\n )" + } + ] + }, + { + "name": "keeps its previous registration when the live connection rejects replacement", + "line": 122, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 131, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n client.replaceSession(\n { ...registration, sessionId: \"replacement-session\" },\n createSnapshot(),\n ),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 137, + "column": 5, + "kind": "expect", + "text": "expect(client.getRegistration())" + } + ] + }, + { + "name": "shares one active first-attempt promise across concurrent starts", + "line": 140, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 154, + "column": 5, + "kind": "expect", + "text": "expect(first)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 155, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "an explicit start bypasses but does not displace the scheduled automatic retry", + "line": 162, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 191, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 192, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 193, + "column": 7, + "kind": "expect", + "text": "expect(retryCallback)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 194, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 197, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 198, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 199, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 203, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 204, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + } + ] + }, + { + "name": "logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", + "line": 212, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 229, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 232, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + }, + { + "name": "does not retain the legacy PID-based incompatible-daemon replacement path", + "line": 238, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 240, + "column": 5, + "kind": "expect", + "text": "expect((client as any).restartIncompatibleDaemon)" + } + ] + }, + { + "name": "logs one actionable warning when a refreshed daemon rejects an older Hunk window", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 350, + "column": 7, + "kind": "expect", + "text": "expect(messages.every((message) => message.includes(\"Close older Hunk windows\")))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 352, + "column": 7, + "kind": "expect", + "text": "expect(websocketOpens)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 353, + "column": 7, + "kind": "expect", + "text": "expect((client as any).waitingForIncumbentExit)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 354, + "column": 7, + "kind": "expect", + "text": "expect((skewedClient as any).waitingForIncumbentExit)" + } + ] + }, + { + "name": "authenticates after a successor becomes healthy before the waiter observes absence", + "line": 362, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 459, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 460, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "waits out an incompatible incumbent and registers on its successor with one connection", + "line": 471, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 531, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 551, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 577, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "retries the complete startup cycle and recovers without restarting the client", + "line": 590, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 607, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "does no daemon work when start follows terminal stop", + "line": 613, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 622, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "does not warn or schedule recovery after stop wins a foreign startup race", + "line": 625, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 648, + "column": 5, + "kind": "expect", + "text": "expect(reconnectScheduled)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 649, + "column": 5, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "logs one actionable warning when a non-Hunk listener owns the session daemon port", + "line": 652, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 680, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 684, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 685, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 688, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + } + ], + "forbiddenModes": [] + } + ], + "treatment": [ + { + "path": "packages/session-broker/src/connection.test.ts", + "tests": [ + { + "name": "throws an initial socket construction failure synchronously from start", + "line": 124, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 137, + "column": 5, + "kind": "expect", + "text": "expect(() => connection.start())" + } + ] + }, + { + "name": "arms delayed fixed-rate heartbeats through one cleared native interval", + "line": 141, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 155, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 157, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 159, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 164, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.filter((message) => JSON.parse(message).type === \"heartbeat\"))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 171, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + } + ] + }, + { + "name": "clears each producer handshake timeout on close and terminal stop", + "line": 174, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 236, + "column": 7, + "kind": "expect", + "text": "expect(handles)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 237, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears the producer handshake timeout after successful signed activation", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 318, + "column": 7, + "kind": "expect", + "text": "expect(broker.getSessionIds())" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 319, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "registers on open and sends later snapshot updates", + "line": 329, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 356, + "column": 5, + "kind": "expect", + "text": "expect(registerMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 367, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 368, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.snapshot)" + } + ] + }, + { + "name": "withholds registration and replacement updates until producer authentication completes", + "line": 374, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 422, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 423, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent[0]!))" + } + ] + }, + { + "name": "keeps the previous registration when replacement send throws", + "line": 427, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 448, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n connection.replaceSession(\n { ...registration, sessionId: \"session-2\" },\n { ...createSnapshot(), state: { selectedIndex: 2 } },\n ),\n )" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 454, + "column": 5, + "kind": "expect", + "text": "expect(connection.getRegistration())" + } + ] + }, + { + "name": "queues broker commands until the app bridge is ready", + "line": 458, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 495, + "column": 5, + "kind": "expect", + "text": "expect(resultMessage)" + } + ] + }, + { + "name": "parses and transforms each server command once before bridge dispatch", + "line": 498, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 562, + "column": 5, + "kind": "expect", + "text": "expect(bridgedInput)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 563, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, resultCalls })" + } + ] + }, + { + "name": "closes malformed and mismatched commands without invoking the bridge", + "line": 570, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 615, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 621, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "enforces text framing and the exact websocket message ceiling before app parsing", + "line": 625, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 690, + "column": 5, + "kind": "expect", + "text": "expect(new TextEncoder().encode(exact).byteLength)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 693, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 729, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(`${exact} `))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 730, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(\"{\".repeat(maxBytes + 1)))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 731, + "column": 5, + "kind": "expect", + "text": "expect(\n emitRejected(\n JSON.stringify({\n type: \"command\",\n requestId: \"request-extra\",\n command: \"annotate\",\n input: { summary: \"not parsed\" },\n extra: true,\n }),\n ),\n " + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 742, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(new Uint8Array([123, 125])))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 743, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "ignores a late socket callback after stop before parsing or reserving", + "line": 746, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 806, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "does not migrate a late command result onto a replacement socket", + "line": 809, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 853, + "column": 5, + "kind": "expect", + "text": "expect(sockets[0]!.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 854, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "discards queued commands when their source socket disconnects", + "line": 858, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 903, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 904, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "stops replaying a queued batch when its source socket disconnects", + "line": 908, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 957, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps 32 missing-bridge commands FIFO and explicitly rejects the 33rd", + "line": 961, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 991, + "column": 5, + "kind": "expect", + "text": "expect(overflow)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1000, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps a hung bridge plus queued commands within the same 32-command budget", + "line": 1004, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1035, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent.at(-1)!))" + } + ] + }, + { + "name": "retains a hung command reservation across disconnect and reconnect", + "line": 1042, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1090, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(sockets[1]!.sent.at(-1)!))" + } + ] + }, + { + "name": "serializes bridge execution while preserving arrival order", + "line": 1097, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1138, + "column": 5, + "kind": "expect", + "text": "expect(started)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1141, + "column": 5, + "kind": "expect", + "text": "expect(started)" + } + ] + }, + { + "name": "rejects producer hello wrappers with unknown or dangerous keys", + "line": 1145, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1188, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + } + ] + }, + { + "name": "prepares reconnect once per attempt and stops after awaited preparation", + "line": 1196, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1227, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1228, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1229, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1245, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + }, + { + "name": "reconnects after socket close unless a close directive disables it", + "line": 1248, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1280, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1284, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1285, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + } + ], + "forbiddenModes": [] + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "tests": [ + { + "name": "only treats exact pre-authentication compatibility closes as quiescent refusals", + "line": 122, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 124, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 125, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Malformed session broker protocol.\",\n authenticated: false,\n }),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 132, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 133, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 134, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Session broker authentication failed.\",\n authenticated: false,\n }),\n )" + } + ] + }, + { + "name": "keeps its previous registration when the live connection rejects replacement", + "line": 143, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 152, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n client.replaceSession(\n { ...registration, sessionId: \"replacement-session\" },\n createSnapshot(),\n ),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 158, + "column": 5, + "kind": "expect", + "text": "expect(client.getRegistration())" + } + ] + }, + { + "name": "shares one active first-attempt promise across concurrent starts", + "line": 161, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 175, + "column": 5, + "kind": "expect", + "text": "expect(first)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 176, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "an explicit start bypasses but does not displace the scheduled automatic retry", + "line": 183, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 200, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 201, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 202, + "column": 7, + "kind": "expect", + "text": "expect(supervisor)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 203, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 206, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 207, + "column": 7, + "kind": "expect", + "text": "expect((client as any).startupSupervisor)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 208, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 211, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 212, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + } + ] + }, + { + "name": "logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", + "line": 218, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 235, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 238, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + }, + { + "name": "does not retain the legacy PID-based incompatible-daemon replacement path", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 246, + "column": 5, + "kind": "expect", + "text": "expect((client as any).restartIncompatibleDaemon)" + } + ] + }, + { + "name": "logs one actionable warning when a refreshed daemon rejects an older Hunk window", + "line": 250, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 356, + "column": 7, + "kind": "expect", + "text": "expect(messages.every((message) => message.includes(\"Close older Hunk windows\")))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 358, + "column": 7, + "kind": "expect", + "text": "expect(websocketOpens)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 359, + "column": 7, + "kind": "expect", + "text": "expect((client as any).waitingForIncumbentExit)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 360, + "column": 7, + "kind": "expect", + "text": "expect((skewedClient as any).waitingForIncumbentExit)" + } + ] + }, + { + "name": "authenticates after a successor becomes healthy before the waiter observes absence", + "line": 368, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 465, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 466, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "waits out an incompatible incumbent and registers on its successor with one connection", + "line": 477, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 537, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 557, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 583, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "retries the complete startup cycle and recovers without restarting the client", + "line": 596, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 613, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "does no daemon work when start follows terminal stop", + "line": 619, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 628, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "does not warn or schedule recovery after stop wins a foreign startup race", + "line": 631, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 654, + "column": 5, + "kind": "expect", + "text": "expect(reconnectScheduled)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 655, + "column": 5, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "logs one actionable warning when a non-Hunk listener owns the session daemon port", + "line": 658, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 686, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 690, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 691, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 694, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + } + ], + "forbiddenModes": [] + } + ], + "note": "Mechanical anti-weakening evidence only; independent review must establish behavioral preservation." + }, + "excluded": [ + "docs/session-broker-effect-lifecycle-experiment-results.md", + "packages/session-broker/src/connection.test.ts", + "src/session/broker/brokerClient.test.ts", + "raw arm evidence" + ] + }, + "validation": [ + { + "name": "focused", + "command": [ + "bun", + "test", + "packages/session-broker-core", + "packages/session-broker", + "packages/session-broker-bun", + "src/session/broker", + "scripts/source-boundaries.test.ts" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:15.306Z", + "finishedAt": "2026-08-31T05:52:21.029Z", + "durationMs": 5723.474007, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/focused.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/focused.stderr.log", + "stdoutSha256": "1555e1dcbb9789ef95781eab3cb28ac11a78256a2b400146add32986578486a0", + "stderrSha256": "80589dd842557f2005b62002168c4e46ad6e31a6f6080bc8aa5e622e0b4845dc", + "parsedCounts": { + "passed": 385, + "failed": 0 + } + }, + { + "name": "real-node", + "command": [ + "bun", + "run", + "test:session-broker-node" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:21.030Z", + "finishedAt": "2026-08-31T05:52:21.555Z", + "durationMs": 524.5388590000002, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/real-node.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/real-node.stderr.log", + "stdoutSha256": "88716e0bd251b262103e6158eb04a60cf5196ddc49a743fd3fcd84f55d0961b1", + "stderrSha256": "70a9db88f4d0962ba72ae7ef8d5999b0ea4355ccda2e816a84ecd4ba4d6e454e", + "parsedCounts": { + "passed": 4, + "failed": 0, + "skipped": 0 + } + }, + { + "name": "typecheck", + "command": [ + "bun", + "run", + "typecheck" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:21.555Z", + "finishedAt": "2026-08-31T05:52:27.097Z", + "durationMs": 5541.736657999999, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/typecheck.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/typecheck.stderr.log", + "stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stderrSha256": "8366207267355d3e3d5bf3bf6e8c94c5f93f6078c34f08973fa2b38cdda6cc92" + }, + { + "name": "lint", + "command": [ + "bun", + "run", + "lint" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:27.097Z", + "finishedAt": "2026-08-31T05:52:27.161Z", + "durationMs": 63.74190399999861, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/lint.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/lint.stderr.log", + "stdoutSha256": "8408475635514aa71ab3b0c39b4ae2ce7721cf092cac5696e9c46bc3fc87fb28", + "stderrSha256": "b2dff80d5fca1f1286fa77107660bc4380492aa10e2be93e185856f21c3993fd" + }, + { + "name": "dependencies", + "command": [ + "bun", + "run", + "deps:check" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:27.161Z", + "finishedAt": "2026-08-31T05:52:27.817Z", + "durationMs": 656.4568789999994, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/dependencies.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/dependencies.stderr.log", + "stdoutSha256": "9bb9cafd557a7e2fe8ab9dff24f39f4d9b1f57074a256d136a28141efddc077f", + "stderrSha256": "78c9ba4b5461c552fe43e52904d0eaf6f5ddef47588cf477ea2a697c44182351" + }, + { + "name": "full-unit", + "command": [ + "bun", + "run", + "test" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:27.817Z", + "finishedAt": "2026-08-31T05:53:06.907Z", + "durationMs": 39089.603013, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/full-unit.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/full-unit.stderr.log", + "stdoutSha256": "49ef2e254b1afa2ad13e64a564ed4ab7c85b6537fea0343f75a434bd76595d26", + "stderrSha256": "98324699be60fe37a6f3f3269d3eaf8b32351feebe87fe7fdda34ce3cc58b0a8" + }, + { + "name": "pty-integration", + "command": [ + "bun", + "run", + "test:integration" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:53:06.911Z", + "finishedAt": "2026-08-31T05:56:26.896Z", + "durationMs": 199984.642445, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/pty-integration.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/pty-integration.stderr.log", + "stdoutSha256": "d6684989b8dd63b37d2f1954270826fbe1bd89a8debd12bcacf03ed1c6140ef6", + "stderrSha256": "3ecd3363b3da37a0fdee720de9031b0d2ea02eff8311dee6506a5fe66bcfec40", + "parsedCounts": { + "passed": 139, + "failed": 0, + "skipped": 1 + } + } + ], + "commands": [ + { + "name": "system-node-version", + "command": [ + "node", + "--version" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:56:26.897Z", + "finishedAt": "2026-08-31T05:56:26.900Z", + "durationMs": 3.5780509999895003, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/commands/system-node-version.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/commands/system-node-version.stderr.log", + "stdoutSha256": "50956a2c27b1a442408131f86103ff1df8a7f4a29502df81d216ba19e2c2f4a6", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "dependency-tree", + "command": [ + "npm", + "ls", + "effect", + "--all", + "--json" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:56:26.900Z", + "finishedAt": "2026-08-31T05:56:27.172Z", + "durationMs": 271.9647910000058, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/commands/dependency-tree.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/commands/dependency-tree.stderr.log", + "stdoutSha256": "7897b9858b1edd6497b3feef5da75dee36fc6e88a84080b73a78fc96b85aef0f", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "compiled-hunk-1", + "command": [ + "bun", + "run", + "build:bin" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:56:27.967Z", + "finishedAt": "2026-08-31T05:56:28.326Z", + "durationMs": 358.9128820000042, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/commands/compiled-hunk-1.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/commands/compiled-hunk-1.stderr.log", + "stdoutSha256": "98cda74fd30bb3e44aa61b2c4b332bfc8bfbca2f99095694345e027d603987eb", + "stderrSha256": "915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc" + }, + { + "name": "compiled-hunk-2", + "command": [ + "bun", + "run", + "build:bin" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:56:28.441Z", + "finishedAt": "2026-08-31T05:56:28.771Z", + "durationMs": 328.633662999986, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/commands/compiled-hunk-2.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/commands/compiled-hunk-2.stderr.log", + "stdoutSha256": "586f6ea32683073c513a90b149ce50a694cd009d72a9e2be0d65b3577c6af75e", + "stderrSha256": "915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc" + }, + { + "name": "compiled-hunk-3", + "command": [ + "bun", + "run", + "build:bin" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:56:28.868Z", + "finishedAt": "2026-08-31T05:56:29.194Z", + "durationMs": 326.56623999998556, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/commands/compiled-hunk-3.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/commands/compiled-hunk-3.stderr.log", + "stdoutSha256": "548d62736227d9114524efa538acc5297e9121d78f98839fb2d1a8476b13df58", + "stderrSha256": "915c93c7dc199f079890d9d4bec2269c9472fb8686f525a8816250f6fbcbb2bc" + }, + { + "name": "public-declarations", + "command": [ + "./node_modules/.bin/tsc", + "packages/session-broker/src/index.ts", + "--declaration", + "--emitDeclarationOnly", + "--outDir", + "tmp/session-lifecycle-effect/treatment/declarations", + "--module", + "ESNext", + "--moduleResolution", + "Bundler", + "--target", + "ES2022", + "--lib", + "ES2022,DOM", + "--skipLibCheck", + "--strict", + "--types", + "bun" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:56:30.843Z", + "finishedAt": "2026-08-31T05:56:31.746Z", + "durationMs": 903.8829499999993, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/commands/public-declarations.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/commands/public-declarations.stderr.log", + "stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] +} diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/semantics.json b/research/session-broker-effect-lifecycle/artifacts/treatment/semantics.json new file mode 100644 index 000000000..950978748 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/semantics.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 3, + "label": "treatment", + "heartbeat": "delayed-Effect-Schedule.fixed-mechanical-match", + "connectionOwner": "one-frozen-supervisor-property", + "clientStartupOwner": "one-frozen-supervisor-property", + "staticAnalysisLimit": "AST gates prove only named mechanical source shapes; independent review must establish lifecycle semantics and correctness.", + "cancellationBoundary": "foreign native promises and detached processes are not claimed cancelled", + "intentionalTreatmentDifferences": [ + "Schedule.fixed catch-up may differ from native setInterval.", + "Unexpected lifecycle defects use the fixed message: Session broker lifecycle failed unexpectedly." + ] +} diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/source-identity.json b/research/session-broker-effect-lifecycle/artifacts/treatment/source-identity.json new file mode 100644 index 000000000..b81fed070 --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/source-identity.json @@ -0,0 +1,1216 @@ +{ + "schemaVersion": 2, + "baseHead": "42d2b9dd2f3144e33080f159f4cc9c2824ad1708", + "currentHead": "42d2b9dd2f3144e33080f159f4cc9c2824ad1708", + "changedFiles": [ + { + "path": "bun.lock", + "classification": "production", + "exists": true, + "bytes": 84384, + "sha256": "3e37dd4a97f293ad571c5e6192eedc08846170c0ee095791310ab2ce5dfc52bb" + }, + { + "path": "docs/session-broker-effect-lifecycle-experiment-results.md", + "classification": "experiment-support", + "exists": true, + "bytes": 5418, + "sha256": "7b0a270659e22f56904333700d7495400d1944a96a695febb8145fd845f84250" + }, + { + "path": "docs/session-broker-effect-lifecycle-experiment.md", + "classification": "experiment-support", + "exists": true, + "bytes": 15732, + "sha256": "9f9ffdd2068e2a02f6f4380d8928105e1cd592b2b43f955855b149ed42633cf5" + }, + { + "path": "packages/session-broker-bun/src/connectionRuntime.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 3305, + "sha256": "eabb3ff9d2e06c27a600ce6bd6d13f6ea0ff2574eb247cfc5b07e7901c7a371c" + }, + { + "path": "packages/session-broker/package.json", + "classification": "production", + "exists": true, + "bytes": 555, + "sha256": "67871a2d120dc4eff9b4b3f6aa02c253fe37189e51c720474faa035b5d7a705a" + }, + { + "path": "packages/session-broker/src/connection.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 38737, + "sha256": "ccb959bfba5ff51706a0c6a695b2b4d5d5839cf745845b99caa27c9b217cb253" + }, + { + "path": "packages/session-broker/src/connection.ts", + "classification": "production", + "exists": true, + "bytes": 18404, + "sha256": "fafa2a3e798bb8e7a4969c217b3ee5085e1bc4530662da0f813b4989f2f0bc26" + }, + { + "path": "packages/session-broker/src/index.ts", + "classification": "production", + "exists": true, + "bytes": 930, + "sha256": "79cb895c57f669895f8a75761f18b560a2c7829717a36f9a05f0d2010e9c1e6c" + }, + { + "path": "packages/session-broker/src/processLifecycle.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 7590, + "sha256": "5bef08ab65a06c81ddd303a24934156e127bd640f3ca8233e1be083302946597" + }, + { + "path": "packages/session-broker/src/processLifecycle.ts", + "classification": "production", + "exists": true, + "bytes": 17827, + "sha256": "ebe3609bcf8667c4cead3b64486c5ae6e6941f535b0e71097bb798acdbe778f1" + }, + { + "path": "scripts/session-broker-effect-lifecycle-experiment.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 56839, + "sha256": "ab809ae95145a41fa9e3d4f4de1173582b50f425531c88d33be097c44c6f9d85" + }, + { + "path": "scripts/session-lifecycle-ownership-analysis.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 25164, + "sha256": "78bce16eaa7c757ffd79f7f0118b93089543b9df77b5ad1506e0eb7e29b060cd" + }, + { + "path": "scripts/source-boundaries.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 20485, + "sha256": "7b61f489bf87ac8d9d4960b65852f3c8829b605f6989865f8f3aa2ffc994378e" + }, + { + "path": "scripts/test-session-broker-node.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 1921, + "sha256": "e5a966843b6970538deebcb746eab0e7c1efc70f74398cb8bd473611adf107bf" + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 23692, + "sha256": "bdfb5f4a68f29f7afc389387f94ba53d2f6b7f65ba8dce043083fb019bf36198" + }, + { + "path": "src/session/broker/brokerClient.ts", + "classification": "production", + "exists": true, + "bytes": 8632, + "sha256": "ae9e76c7710df1c173325aa4cdf64953e577f9153dac0d68cde10f1f04464bfa" + }, + { + "path": "src/session/broker/brokerLauncher.test.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 11327, + "sha256": "79b4f1cb2fb43f6caa9199c5700db07aa652fddf891384b48bfc44c3e71ce41c" + }, + { + "path": "src/session/broker/brokerLauncher.ts", + "classification": "production", + "exists": true, + "bytes": 18150, + "sha256": "f423bbd92dfb1dc01c4ab3e412e6adae53550bd77d81785916d01dac395226b5" + }, + { + "path": "src/ui/runInteractiveApp.tsx", + "classification": "production", + "exists": true, + "bytes": 6376, + "sha256": "03b85c41a2d2d593f08f548793f94b99fb1e8dfce96e5b6069654fdba78a0c7c" + }, + { + "path": "test/session-broker-node/adapter.test.mjs", + "classification": "experiment-support", + "exists": true, + "bytes": 12165, + "sha256": "21dca2de748bf1ddd8a9ba0c6578006d85974e111fa4a7daa9b0f58e745fdfeb" + }, + { + "path": "test/session-broker-node/connection-fixture.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 948, + "sha256": "91c4cea63bdcf8188bb4688d3cf6fceeb6e18cbbbd12233593bf77abc7e007b2" + }, + { + "path": "test/session-broker-runtime/bun-connection-fixture.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 686, + "sha256": "05e0710382840a5a4b171e42157ed4211b1f993d46e5b0e3b4c0ad0dbd6a6a95" + }, + { + "path": "test/session-broker-runtime/connectionFixture.ts", + "classification": "experiment-support", + "exists": true, + "bytes": 9263, + "sha256": "d4162f1bc31791b7d285ed5d39457eff421832fd18225b7fd77b20f9055765f3" + } + ], + "behavioralTestContracts": [ + { + "path": "packages/session-broker/src/connection.test.ts", + "tests": [ + { + "name": "throws an initial socket construction failure synchronously from start", + "line": 124, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 137, + "column": 5, + "kind": "expect", + "text": "expect(() => connection.start())" + } + ] + }, + { + "name": "arms delayed fixed-rate heartbeats through one cleared native interval", + "line": 141, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 155, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 157, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 159, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 164, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent.filter((message) => JSON.parse(message).type === \"heartbeat\"))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 171, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + } + ] + }, + { + "name": "clears each producer handshake timeout on close and terminal stop", + "line": 174, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 236, + "column": 7, + "kind": "expect", + "text": "expect(handles)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 237, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "clears the producer handshake timeout after successful signed activation", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 318, + "column": 7, + "kind": "expect", + "text": "expect(broker.getSessionIds())" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 319, + "column": 7, + "kind": "expect", + "text": "expect(cleared)" + } + ] + }, + { + "name": "registers on open and sends later snapshot updates", + "line": 329, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 356, + "column": 5, + "kind": "expect", + "text": "expect(registerMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 367, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.type)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 368, + "column": 5, + "kind": "expect", + "text": "expect(snapshotMessage.snapshot)" + } + ] + }, + { + "name": "withholds registration and replacement updates until producer authentication completes", + "line": 374, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 422, + "column": 5, + "kind": "expect", + "text": "expect(socket.sent)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 423, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent[0]!))" + } + ] + }, + { + "name": "keeps the previous registration when replacement send throws", + "line": 427, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 448, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n connection.replaceSession(\n { ...registration, sessionId: \"session-2\" },\n { ...createSnapshot(), state: { selectedIndex: 2 } },\n ),\n )" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 454, + "column": 5, + "kind": "expect", + "text": "expect(connection.getRegistration())" + } + ] + }, + { + "name": "queues broker commands until the app bridge is ready", + "line": 458, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 495, + "column": 5, + "kind": "expect", + "text": "expect(resultMessage)" + } + ] + }, + { + "name": "parses and transforms each server command once before bridge dispatch", + "line": 498, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 562, + "column": 5, + "kind": "expect", + "text": "expect(bridgedInput)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 563, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, resultCalls })" + } + ] + }, + { + "name": "closes malformed and mismatched commands without invoking the bridge", + "line": 570, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 615, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 621, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "enforces text framing and the exact websocket message ceiling before app parsing", + "line": 625, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 690, + "column": 5, + "kind": "expect", + "text": "expect(new TextEncoder().encode(exact).byteLength)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 693, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 729, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(`${exact} `))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 730, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(\"{\".repeat(maxBytes + 1)))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 731, + "column": 5, + "kind": "expect", + "text": "expect(\n emitRejected(\n JSON.stringify({\n type: \"command\",\n requestId: \"request-extra\",\n command: \"annotate\",\n input: { summary: \"not parsed\" },\n extra: true,\n }),\n ),\n " + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 742, + "column": 5, + "kind": "expect", + "text": "expect(emitRejected(new Uint8Array([123, 125])))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 743, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "ignores a late socket callback after stop before parsing or reserving", + "line": 746, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 806, + "column": 5, + "kind": "expect", + "text": "expect({ inputCalls, bridgeCalls })" + } + ] + }, + { + "name": "does not migrate a late command result onto a replacement socket", + "line": 809, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 853, + "column": 5, + "kind": "expect", + "text": "expect(sockets[0]!.sent.map((message) => JSON.parse(message).type))" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 854, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "discards queued commands when their source socket disconnects", + "line": 858, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 903, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 904, + "column": 5, + "kind": "expect", + "text": "expect(sockets[1]!.sent.map((message) => JSON.parse(message).type))" + } + ] + }, + { + "name": "stops replaying a queued batch when its source socket disconnects", + "line": 908, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 957, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps 32 missing-bridge commands FIFO and explicitly rejects the 33rd", + "line": 961, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 991, + "column": 5, + "kind": "expect", + "text": "expect(overflow)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1000, + "column": 5, + "kind": "expect", + "text": "expect(dispatched)" + } + ] + }, + { + "name": "keeps a hung bridge plus queued commands within the same 32-command budget", + "line": 1004, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1035, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(socket.sent.at(-1)!))" + } + ] + }, + { + "name": "retains a hung command reservation across disconnect and reconnect", + "line": 1042, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1090, + "column": 5, + "kind": "expect", + "text": "expect(JSON.parse(sockets[1]!.sent.at(-1)!))" + } + ] + }, + { + "name": "serializes bridge execution while preserving arrival order", + "line": 1097, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1138, + "column": 5, + "kind": "expect", + "text": "expect(started)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1141, + "column": 5, + "kind": "expect", + "text": "expect(started)" + } + ] + }, + { + "name": "rejects producer hello wrappers with unknown or dangerous keys", + "line": 1145, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1188, + "column": 7, + "kind": "expect", + "text": "expect(socket.lastClose)" + } + ] + }, + { + "name": "prepares reconnect once per attempt and stops after awaited preparation", + "line": 1196, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1227, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1228, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1229, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1245, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + }, + { + "name": "reconnects after socket close unless a close directive disables it", + "line": 1248, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1280, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1284, + "column": 5, + "kind": "expect", + "text": "expect(warnings)" + }, + { + "file": "packages/session-broker/src/connection.test.ts", + "line": 1285, + "column": 5, + "kind": "expect", + "text": "expect(sockets)" + } + ] + } + ], + "forbiddenModes": [] + }, + { + "path": "src/session/broker/brokerClient.test.ts", + "tests": [ + { + "name": "only treats exact pre-authentication compatibility closes as quiescent refusals", + "line": 122, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 124, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 125, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Malformed session broker protocol.\",\n authenticated: false,\n }),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 132, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 133, + "column": 5, + "kind": "expect", + "text": "expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false }))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 134, + "column": 5, + "kind": "expect", + "text": "expect(\n isQuiescentUpgradeRefusal({\n code: 1008,\n reason: \"Session broker authentication failed.\",\n authenticated: false,\n }),\n )" + } + ] + }, + { + "name": "keeps its previous registration when the live connection rejects replacement", + "line": 143, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 152, + "column": 5, + "kind": "expect", + "text": "expect(() =>\n client.replaceSession(\n { ...registration, sessionId: \"replacement-session\" },\n createSnapshot(),\n ),\n )" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 158, + "column": 5, + "kind": "expect", + "text": "expect(client.getRegistration())" + } + ] + }, + { + "name": "shares one active first-attempt promise across concurrent starts", + "line": 161, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 175, + "column": 5, + "kind": "expect", + "text": "expect(first)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 176, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "an explicit start bypasses but does not displace the scheduled automatic retry", + "line": 183, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 200, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 201, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 202, + "column": 7, + "kind": "expect", + "text": "expect(supervisor)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 203, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 206, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 207, + "column": 7, + "kind": "expect", + "text": "expect((client as any).startupSupervisor)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 208, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 211, + "column": 7, + "kind": "expect", + "text": "expect(attempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 212, + "column": 7, + "kind": "expect", + "text": "expect((client as any).reconnectTimer)" + } + ] + }, + { + "name": "logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", + "line": 218, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 235, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 238, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + }, + { + "name": "does not retain the legacy PID-based incompatible-daemon replacement path", + "line": 244, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 246, + "column": 5, + "kind": "expect", + "text": "expect((client as any).restartIncompatibleDaemon)" + } + ] + }, + { + "name": "logs one actionable warning when a refreshed daemon rejects an older Hunk window", + "line": 250, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 356, + "column": 7, + "kind": "expect", + "text": "expect(messages.every((message) => message.includes(\"Close older Hunk windows\")))" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 358, + "column": 7, + "kind": "expect", + "text": "expect(websocketOpens)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 359, + "column": 7, + "kind": "expect", + "text": "expect((client as any).waitingForIncumbentExit)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 360, + "column": 7, + "kind": "expect", + "text": "expect((skewedClient as any).waitingForIncumbentExit)" + } + ] + }, + { + "name": "authenticates after a successor becomes healthy before the waiter observes absence", + "line": 368, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 465, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 466, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "waits out an incompatible incumbent and registers on its successor with one connection", + "line": 477, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 537, + "column": 7, + "kind": "expect", + "text": "expect(helloAttempts)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 557, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 583, + "column": 7, + "kind": "expect", + "text": "expect((client as any).connection)" + } + ] + }, + { + "name": "retries the complete startup cycle and recovers without restarting the client", + "line": 596, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 613, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "does no daemon work when start follows terminal stop", + "line": 619, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 628, + "column": 5, + "kind": "expect", + "text": "expect(attempts)" + } + ] + }, + { + "name": "does not warn or schedule recovery after stop wins a foreign startup race", + "line": 631, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 654, + "column": 5, + "kind": "expect", + "text": "expect(reconnectScheduled)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 655, + "column": 5, + "kind": "expect", + "text": "expect(messages)" + } + ] + }, + { + "name": "logs one actionable warning when a non-Hunk listener owns the session daemon port", + "line": 658, + "column": 3, + "mode": "normal", + "assertions": [ + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 686, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 690, + "column": 7, + "kind": "expect", + "text": "expect(messages)" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 691, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + }, + { + "file": "src/session/broker/brokerClient.test.ts", + "line": 694, + "column": 7, + "kind": "expect", + "text": "expect(messages[0])" + } + ] + } + ], + "forbiddenModes": [] + } + ], + "production": [ + { + "path": "bun.lock", + "currentExists": true, + "baseExists": true, + "currentSha256": "3e37dd4a97f293ad571c5e6192eedc08846170c0ee095791310ab2ce5dfc52bb", + "baseSha256": "b2b43bcbac57d09213916d9bec04f883f06e482074dd754fde5b8f8bbd379bd9", + "matchesBase": false + }, + { + "path": "packages/session-broker-bun/src/serve.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "336ae69a5d1ea336002a2bac9775c9233f5569a8e10a2e051fdd286999cb62f9", + "baseSha256": "336ae69a5d1ea336002a2bac9775c9233f5569a8e10a2e051fdd286999cb62f9", + "matchesBase": true + }, + { + "path": "packages/session-broker-node/src/serve.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "3f982a1df9727f7f9a03e6271f80bbea91138a94c7ab8dcd1c676cda11f8a46a", + "baseSha256": "3f982a1df9727f7f9a03e6271f80bbea91138a94c7ab8dcd1c676cda11f8a46a", + "matchesBase": true + }, + { + "path": "packages/session-broker/package.json", + "currentExists": true, + "baseExists": true, + "currentSha256": "67871a2d120dc4eff9b4b3f6aa02c253fe37189e51c720474faa035b5d7a705a", + "baseSha256": "5c9f0ee3a0da36098e0ab8160bb66ab13f3cf1be238d6d69123fa6d5040628ee", + "matchesBase": false + }, + { + "path": "packages/session-broker/src/connection.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "fafa2a3e798bb8e7a4969c217b3ee5085e1bc4530662da0f813b4989f2f0bc26", + "baseSha256": "d3fbeed7502a31d9d8be82da0f282e523227ab67cd495aa85a8b9208802d6c76", + "matchesBase": false + }, + { + "path": "packages/session-broker/src/daemon.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "f0fbd88158f2295c0ea21fc4b682b8af5306f76dbe7192da5dc3ea7a7642d82f", + "baseSha256": "f0fbd88158f2295c0ea21fc4b682b8af5306f76dbe7192da5dc3ea7a7642d82f", + "matchesBase": true + }, + { + "path": "packages/session-broker/src/index.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "79cb895c57f669895f8a75761f18b560a2c7829717a36f9a05f0d2010e9c1e6c", + "baseSha256": "b89de9f5fdce27371ecd9d8e92cc7be194a3daa49ecea45e4a235dbebd680264", + "matchesBase": false + }, + { + "path": "packages/session-broker/src/processLifecycle.ts", + "currentExists": true, + "baseExists": false, + "currentSha256": "ebe3609bcf8667c4cead3b64486c5ae6e6941f535b0e71097bb798acdbe778f1", + "baseSha256": null, + "matchesBase": false + }, + { + "path": "src/session/broker/brokerClient.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "ae9e76c7710df1c173325aa4cdf64953e577f9153dac0d68cde10f1f04464bfa", + "baseSha256": "996abced18f1e921753b566fa45577dd39650ac7a679d6fbdc447ee91ebb66c3", + "matchesBase": false + }, + { + "path": "src/session/broker/brokerLauncher.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "f423bbd92dfb1dc01c4ab3e412e6adae53550bd77d81785916d01dac395226b5", + "baseSha256": "05d9ad750fc9e0f737cd70eb1e444791a9c15e41a31680b3c7803bbba0a86300", + "matchesBase": false + }, + { + "path": "src/session/broker/brokerServer.ts", + "currentExists": true, + "baseExists": true, + "currentSha256": "b04e38987932851dbe5aedb98110be55bf86087163e6ce620f2bfcf0141f3b0a", + "baseSha256": "b04e38987932851dbe5aedb98110be55bf86087163e6ce620f2bfcf0141f3b0a", + "matchesBase": true + }, + { + "path": "src/ui/runInteractiveApp.tsx", + "currentExists": true, + "baseExists": true, + "currentSha256": "03b85c41a2d2d593f08f548793f94b99fb1e8dfce96e5b6069654fdba78a0c7c", + "baseSha256": "b57be57b9ee97a50267262090dea660c90cba8f84019d2fe9b03e05fa8e6c5a1", + "matchesBase": false + } + ], + "productionMatchesBase": false, + "productionChangePolicy": { + "changedProduction": [ + "bun.lock", + "packages/session-broker/package.json", + "packages/session-broker/src/connection.ts", + "packages/session-broker/src/index.ts", + "packages/session-broker/src/processLifecycle.ts", + "src/session/broker/brokerClient.ts", + "src/session/broker/brokerLauncher.ts", + "src/ui/runInteractiveApp.tsx" + ], + "allowed": [ + "bun.lock", + "packages/session-broker/package.json", + "packages/session-broker/src/connection.ts", + "packages/session-broker/src/index.ts", + "packages/session-broker/src/processLifecycle.ts", + "src/session/broker/brokerClient.ts", + "src/session/broker/brokerLauncher.ts", + "src/ui/runInteractiveApp.tsx" + ], + "required": [ + "bun.lock", + "packages/session-broker/package.json", + "packages/session-broker/src/index.ts", + "packages/session-broker/src/processLifecycle.ts", + "packages/session-broker/src/connection.ts", + "src/session/broker/brokerClient.ts", + "src/ui/runInteractiveApp.tsx" + ], + "violations": [] + }, + "reconstructablePatch": { + "path": "tmp/session-lifecycle-effect/treatment/complete-experiment.patch", + "bytes": 216709, + "sha256": "2227069a83141c9d8a22b91e778f6c7c9a233ae8aff114b3d0db8d141b7d79cb", + "includesUntracked": true + }, + "firstExperiment": { + "expectedPatchSha256": "f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56", + "observation": { + "suppliedPath": null, + "observedSha256": null, + "status": "expected-hash-only" + } + } +} diff --git a/research/session-broker-effect-lifecycle/artifacts/treatment/validation-manifest.json b/research/session-broker-effect-lifecycle/artifacts/treatment/validation-manifest.json new file mode 100644 index 000000000..cae2e05df --- /dev/null +++ b/research/session-broker-effect-lifecycle/artifacts/treatment/validation-manifest.json @@ -0,0 +1,144 @@ +{ + "schemaVersion": 2, + "recordedAt": "2026-08-31T05:56:32.027Z", + "freshness": "all required commands executed by this arm invocation after clearing output", + "commands": [ + { + "name": "focused", + "command": [ + "bun", + "test", + "packages/session-broker-core", + "packages/session-broker", + "packages/session-broker-bun", + "src/session/broker", + "scripts/source-boundaries.test.ts" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:15.306Z", + "finishedAt": "2026-08-31T05:52:21.029Z", + "durationMs": 5723.474007, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/focused.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/focused.stderr.log", + "stdoutSha256": "1555e1dcbb9789ef95781eab3cb28ac11a78256a2b400146add32986578486a0", + "stderrSha256": "80589dd842557f2005b62002168c4e46ad6e31a6f6080bc8aa5e622e0b4845dc", + "parsedCounts": { + "passed": 385, + "failed": 0 + } + }, + { + "name": "real-node", + "command": [ + "bun", + "run", + "test:session-broker-node" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:21.030Z", + "finishedAt": "2026-08-31T05:52:21.555Z", + "durationMs": 524.5388590000002, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/real-node.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/real-node.stderr.log", + "stdoutSha256": "88716e0bd251b262103e6158eb04a60cf5196ddc49a743fd3fcd84f55d0961b1", + "stderrSha256": "70a9db88f4d0962ba72ae7ef8d5999b0ea4355ccda2e816a84ecd4ba4d6e454e", + "parsedCounts": { + "passed": 4, + "failed": 0, + "skipped": 0 + } + }, + { + "name": "typecheck", + "command": [ + "bun", + "run", + "typecheck" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:21.555Z", + "finishedAt": "2026-08-31T05:52:27.097Z", + "durationMs": 5541.736657999999, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/typecheck.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/typecheck.stderr.log", + "stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stderrSha256": "8366207267355d3e3d5bf3bf6e8c94c5f93f6078c34f08973fa2b38cdda6cc92" + }, + { + "name": "lint", + "command": [ + "bun", + "run", + "lint" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:27.097Z", + "finishedAt": "2026-08-31T05:52:27.161Z", + "durationMs": 63.74190399999861, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/lint.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/lint.stderr.log", + "stdoutSha256": "8408475635514aa71ab3b0c39b4ae2ce7721cf092cac5696e9c46bc3fc87fb28", + "stderrSha256": "b2dff80d5fca1f1286fa77107660bc4380492aa10e2be93e185856f21c3993fd" + }, + { + "name": "dependencies", + "command": [ + "bun", + "run", + "deps:check" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:27.161Z", + "finishedAt": "2026-08-31T05:52:27.817Z", + "durationMs": 656.4568789999994, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/dependencies.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/dependencies.stderr.log", + "stdoutSha256": "9bb9cafd557a7e2fe8ab9dff24f39f4d9b1f57074a256d136a28141efddc077f", + "stderrSha256": "78c9ba4b5461c552fe43e52904d0eaf6f5ddef47588cf477ea2a697c44182351" + }, + { + "name": "full-unit", + "command": [ + "bun", + "run", + "test" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:52:27.817Z", + "finishedAt": "2026-08-31T05:53:06.907Z", + "durationMs": 39089.603013, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/full-unit.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/full-unit.stderr.log", + "stdoutSha256": "49ef2e254b1afa2ad13e64a564ed4ab7c85b6537fea0343f75a434bd76595d26", + "stderrSha256": "98324699be60fe37a6f3f3269d3eaf8b32351feebe87fe7fdda34ce3cc58b0a8" + }, + { + "name": "pty-integration", + "command": [ + "bun", + "run", + "test:integration" + ], + "cwd": "/home/bentlegen/Projects/hunk-effect-lifecycle-experiment", + "startedAt": "2026-08-31T05:53:06.911Z", + "finishedAt": "2026-08-31T05:56:26.896Z", + "durationMs": 199984.642445, + "exitCode": 0, + "stdoutTranscript": "tmp/session-lifecycle-effect/treatment/validation/pty-integration.stdout.log", + "stderrTranscript": "tmp/session-lifecycle-effect/treatment/validation/pty-integration.stderr.log", + "stdoutSha256": "d6684989b8dd63b37d2f1954270826fbe1bd89a8debd12bcacf03ed1c6140ef6", + "stderrSha256": "3ecd3363b3da37a0fdee720de9031b0d2ea02eff8311dee6506a5fe66bcfec40", + "parsedCounts": { + "passed": 139, + "failed": 0, + "skipped": 1 + } + } + ] +} diff --git a/research/session-broker-effect-lifecycle/plan.md b/research/session-broker-effect-lifecycle/plan.md new file mode 100644 index 000000000..d6881af43 --- /dev/null +++ b/research/session-broker-effect-lifecycle/plan.md @@ -0,0 +1,252 @@ +# Session broker Effect lifecycle experiment + +Status: Phase 0 control frozen; Phase 1 treatment not started. + +## Question + +The first experiment replaced only the generic connection's three native timers. It paid Effect's +fixed runtime and audit cost without removing lifecycle ownership. This follow-up asks a narrower, +falsifiable question: + +> Can one process-owned Effect lifecycle supervise Hunk's complete producer startup and connection +> path while deleting independent retry/timer ownership across both the generic connection and the +> Hunk client? + +This is a disposable experiment, not production migration approval. + +## Identities + +- Control source: `42d2b9dd2f3144e33080f159f4cc9c2824ad1708` +- Control source description: `fix(ui): restore review stream responsiveness (#942)` +- Required treatment dependency: exactly `effect@3.22.1`, only in + `packages/session-broker/package.json` +- Required Bun: `1.3.14` +- First experiment patch SHA-256: + `f02a469527d49034beb5172929b74aa4ad1f7106e5d66cb1cb259fb410f39e56` +- First experiment remains untouched in `/home/bentlegen/Projects/hunk-effect-experiment`. + +The control harness records the system Node version, Bun's embedded Node version, OS, kernel, +architecture, lockfile, fixtures, all changed-file identities, and fresh command transcripts. It +creates a complete reconstructable binary patch through a temporary Git index so untracked support +files are included. The first-experiment patch path is optional; when unavailable, evidence records +only its frozen expected hash. Raw evidence lives under ignored `tmp/session-lifecycle-effect/`. + +## Scope + +Phase 1 may change only the producer lifecycle path: + +```text +ensure daemon +-> signed hello/connect +-> register +-> heartbeat/reconnect +-> terminal stop +``` + +Candidate production modules are: + +- `packages/session-broker/src/connection.ts` +- a new private Effect-backed lifecycle module in `packages/session-broker/src/` +- `src/session/broker/brokerClient.ts` +- polling mechanics only in `src/session/broker/brokerLauncher.ts` +- the interactive-process composition root in `src/ui/runInteractiveApp.tsx` + +Phase 1 must not change the daemon engine, Node adapter, Bun adapter, authentication, +cryptography, exact validation, broker state, resource budgets, ownership policy, delivery +semantics, browser-review authority, or public Effect-free API style. Phase 2 is forbidden without a +new decision after the Phase 1 gate. + +## Frozen control semantics + +The treatment must preserve these observations unless this document names an explicit difference: + +1. `SessionBrokerConnection.start()` constructs its first socket synchronously, so a construction + throw escapes synchronously. +2. Heartbeat uses delayed fixed-rate native `setInterval`: activation arms one interval, no heartbeat + is sent before its first callback, and the runtime owns cadence/catch-up behavior. +3. Producer handshake timeout starts with socket creation and is cleared by activation, close, or + terminal stop. +4. Reconnect preparation is not cancellable. Stop may win while preparation is pending; the foreign + Promise still settles, but no new socket may be created afterward. +5. Connection stop is synchronous and terminal. It closes the current socket and refuses future + starts, but it does not await crypto, reconnect preparation, or app bridge work. +6. Concurrent first `SessionBrokerClient.start()` calls return the same active attempt Promise. +7. A first startup failure is warned once, resolves the public first-attempt Promise, and schedules a + complete retry. An explicit `start()` while that retry timer is waiting starts an immediate new + attempt; the existing retry timer remains owned until stop or callback. +8. Client stop is synchronous and terminal. It does not cancel already-started health, credential, + crypto, filesystem, or detached-spawn work, but late settlement cannot connect, warn, or schedule + another retry. +9. Launcher lock owners receive a fresh full health-poll timeout after the outer deadline was + established. This can exceed the nominal startup timeout. Lock release remains in `finally`. +10. Fetch abort timers and TCP socket timeouts are native transport controls, not lifecycle-loop + ownership targeted by Phase 1. +11. One generic connection object retains registration, snapshot, bridge queue, command budgets, + and reconnect ownership across socket generations. + +## Explicit treatment decisions + +These are decisions, not parity claims: + +- **Heartbeat:** use a delayed first tick followed by Effect `Schedule.fixed`. Any observable + catch-up difference from native `setInterval` is reported as a treatment difference. +- **Defects:** `createSessionBrokerProcessLifecycle` accepts the ordinary TypeScript option + `{ onDefect?: (message: string) => void }`. Unexpected lifecycle defects call it only with the + fixed text `Session broker lifecycle failed unexpectedly.` This is an intentional replacement for + today's mixture of uncaught timer exceptions and unhandled Promise rejections. Raw Effect Causes, + URLs, credentials, payloads, callback values, and defect objects must never reach this hook or the + console. +- **Shutdown:** public `start()`/`stop()` gates remain synchronous and terminal. A separate internal + settlement Promise describes eventual scope closure; synchronous stop never claims that async + finalization has completed. +- **Cancellation:** Effect interruption does not prove cancellation of foreign Promises or detached + processes. Phase 1 does not add `AbortSignal` to health, credential, crypto, filesystem, TCP, or + spawn operations. Synchronous terminal/epoch gates prevent late commits while native work settles. +- **Runtime ownership:** `runInteractiveApp` constructs exactly one process lifecycle, runtime, and + unref-aware Clock and injects it through an ordinary TypeScript façade. There is no package-global + singleton, fallback runtime, runtime per connection, or Effect import outside the private package + lifecycle implementation. + +## Frozen treatment source shape + +The disposable treatment uses these exact mechanical names so Phase 1 evidence is falsifiable: + +- implementation classes: `EffectSessionBrokerProcessLifecycle`, + `EffectSessionBrokerStartupSupervisor`, and `EffectSessionBrokerConnectionSupervisor`; +- `SessionBrokerConnection` owns exactly one + `connectionSupervisor: SessionBrokerConnectionSupervisor` property. Its complete treatment + property set is frozen as `socket`, `activeSocket`, `bridge`, `limits`, `queuedMessages`, + `executingMessages`, `queuedCommandCountBudget`, `queuedCommandByteBudget`, `draining`, `stopped`, + `registration`, `snapshot`, `producerHellos`, `options`, and `connectionSupervisor`; +- `SessionBrokerClient` owns exactly one + `startupSupervisor: SessionBrokerStartupSupervisor` property and retains its terminal `stopped` + commit gate. Its complete treatment property set is frozen as `connection`, `bridge`, `stopped`, + `lastConnectionWarning`, `credentials`, `waitingForIncumbentExit`, `incumbentLaunchFingerprint`, + `registration`, `snapshot`, `timing`, and `startupSupervisor`; +- `createUnrefClock()` is the only lexical region allowed to call raw `setTimeout` or + `clearTimeout`; the process lifecycle calls it at exactly one construction site; +- `runDelayedFixedHeartbeat()` performs the initial `Effect.sleep` and contains the only + `Schedule.fixed` call; +- `ManagedRuntime.make` occurs exactly once in `processLifecycle.ts`; +- `createSessionBrokerProcessLifecycle()` is called in production exactly once, from + `src/ui/runInteractiveApp.tsx`. + +Conditional AST gates reject native timing calls in converted connection/client paths, `Bun.sleep` +in the launcher, any property outside those exact domain sets, Promise/timer/task/fiber/handle +owners, task registries, per-delay classes, additional runtime/Clock construction, or factory calls +elsewhere. Existing connection queues and `producerHellos` remain allowed only by exact name. These +named mechanical gates do +not prove scheduling, interruption, shutdown, authentication, or resource semantics; independent +review remains mandatory. + +## Mechanical ownership inventory + +The control owns these independent lifecycle mechanisms: + +| Owner | Mechanism | Phase 1 disposition | +| ------------------ | ------------------------------------------- | ------------------------------------- | +| generic connection | reconnect timeout handle | delete into one generation supervisor | +| generic connection | heartbeat interval handle | delete into one generation supervisor | +| generic connection | handshake timeout WeakMap | delete into one generation supervisor | +| Hunk client | outer reconnect timeout handle | delete into one startup supervisor | +| Hunk client | active startup Promise field | delete into one startup supervisor | +| Hunk client | terminal `stopped` gate | retain as public commit fence | +| launcher | health deadline/poll loop and `Bun.sleep` | route through supervised attempt time | +| launcher | launch-lock ownership and `finally` release | retain as cross-process authority | +| launcher | fetch abort and TCP socket timeout | retain as native transport controls | + +Renaming native timer fields to task/fiber fields, adding equivalent task Sets/maps, or retaining +parallel native scheduling is not ownership reduction. + +## Phase 1 gate + +Phase 1 is promising only if all of the following hold: + +1. The connection's reconnect, heartbeat, and handshake timer ownership becomes one whole-generation + supervisor with no per-delay task handles or task registry. +2. The client's outer retry timer and startup Promise owner become one startup supervisor. +3. The reduction spans both modules and deletes at least three independent native ownership + mechanisms. +4. Exactly one runtime and one custom Clock are constructed at the interactive process root. +5. Security/domain state stays explicit and byte-for-byte protocol behavior remains unchanged. +6. Every frozen control characterization remains green, or a difference listed above is measured + and reported honestly. +7. Bun and real Node authenticate/register where supported and exit with handshake, heartbeat, and + reconnect maintenance pending. Hunk startup timing is a Bun product path; absence of a stable + real-Node Hunk startup fixture is recorded rather than simulated. +8. No Effect type appears in public declarations; core and `src/**` do not import Effect; Bun never + imports or executes `ws`. +9. The bounded reconnect-close-policy canary reaches the fixed defect hook in treatment, its random + marker appears in no generated text evidence, and the hook/console contain only the fixed message. + This canary does not claim coverage of credentials, application payloads, finalizers, diagnostics, + snapshots, or rendered Effect Causes. +10. Independent reviewers can find stop gates, retries, authority rechecks, and releases without + relying on Effect expertise. +11. Bundle, startup, registration, command, reconnect, stop, CPU, and RSS evidence is recorded. A + greater-than-25% standalone broker increase requires a material, independently reviewed + ownership win; the coarse 1 MiB ceiling alone is insufficient. + +Failure of any hard boundary, ownership, security, process-exit, or auditability item ends the +experiment as a null result. Phase 2 never starts automatically. + +## Evidence protocol + +One unchanged harness, `scripts/session-broker-effect-lifecycle-experiment.ts`, collects both arms. +It stores full command transcripts and hashes rather than truncated output. Each arm contains: + +- `result.json` +- `source-identity.json` +- `ownership-inventory.json` +- `semantics.json` +- `validation/manifest.json` +- three retained Bun broker bundles and three retained Node broker bundles +- identities and timings for three compiled Hunk builds whose binaries remain in `dist/` and are not + retained as arm artifacts +- raw warmups/samples for stable local fixtures +- process-exit results +- dependency/declaration/canary evidence +- `MANIFEST.sha256` and `MANIFEST.attestation.sha256` + +Every invocation clears the complete arm output first, then itself runs focused tests, the real Node +suite, typecheck, lint, dependency boundaries, the full unit suite, and PTY integration. Non-zero +exit fails the arm; counts are recorded only when a stable Bun/TAP summary can be parsed. A requested +metric without a stable fixture is recorded as `not-measured` with a reason. It is never replaced +with test-command duration or synthetic evidence. Listener acquisition and daemon ordered stop are +unchanged transport context, not producer lifecycle startup/stop. Cold Bun subprocess lifecycle +readiness and synchronous connection stop are the lifecycle measurements. CPU and RSS are coarse, +non-isolated observations from which no strong performance or security conclusion may be drawn. + +Treatment invocation additionally requires +`--control-attestation-sha256 `. The harness first verifies that external hash against +`control/MANIFEST.attestation.sha256`, verifies the attested `MANIFEST.sha256`, and then verifies +every manifest entry before trusting control identities. It freezes every existing control +experiment-support input hash except the results report, raw evidence, and exactly +`packages/session-broker/src/connection.test.ts` plus `src/session/broker/brokerClient.test.ts`. +Those two tests may change only to inject the process lifecycle: AST evidence requires every ordered +control test name to remain, each matching body to retain at least its control assertion-call count, +and no treatment test to use skip/todo/only. New treatment tests may be added. The plan, harness, +analyzer, source-boundary test, launcher tests, fixtures, and runners remain exact-frozen. This is +mechanical anti-weakening evidence plus independent review, not proof. The parent must anchor the final attestation-file hash outside the arm before +Phase 1. This is local integrity and reproducibility evidence, not a cryptographic signer or trusted +timestamp. + +The treatment production change set is exact: `bun.lock`, `packages/session-broker/package.json`, +`packages/session-broker/src/{index,processLifecycle,connection}.ts`, +`src/session/broker/{brokerClient,brokerLauncher}.ts`, and `src/ui/runInteractiveApp.tsx` are the only +allowed production changes. Every item except the optional launcher change is required. Daemon, +adapters, authentication, crypto, validation, core, budgets, and browser-review production files +remain forbidden. The Bun fixture is bundled by `Bun.build` through a `ws`/`ws/*` rejecting +`onResolve` plugin using a package-bundling target, a direct `ws` import probe must fail under that +plugin, and only the produced bundle is executed by Bun. AST specifier checks remain an independent +backstop. Production modules may not import/re-export/require/dynamically import `.test.ts`, `test/`, +`docs/`, `scripts/`, or any experiment-support module. Effect specifier scans include every tracked +or untracked TypeScript source in the repository; +only `processLifecycle.ts` and its unreachable dedicated `processLifecycle.test.ts` may import it. + +## Operating constraints + +- Use one writer and fresh independent reviewers. +- Do not edit or delete the first experiment. +- Do not commit, push, open a PR, merge, publish, or begin production migration. +- Do not begin daemon/adapters Phase 2 without separate approval. +- A well-attested null result is a successful experiment outcome. diff --git a/research/session-broker-effect-lifecycle/report.md b/research/session-broker-effect-lifecycle/report.md new file mode 100644 index 000000000..8174afaf7 --- /dev/null +++ b/research/session-broker-effect-lifecycle/report.md @@ -0,0 +1,184 @@ +# Session broker Effect lifecycle experiment results + +Status: Phase 1 treatment collected; **discard Phase 1 and do not begin Phase 2**. + +## Control + +The control is fresh `origin/main` at +`42d2b9dd2f3144e33080f159f4cc9c2824ad1708`. The experiment plan, characterization tests, +fixtures, and harness form a separate experiment-support patch. Every candidate production file, +package manifest, and lockfile remained byte-identical to the control commit during collection. + +Control evidence is stored under `tmp/session-lifecycle-effect/control/`. The treatment verified all +49 control-manifest entries against external attestation-file SHA-256 +`d284decdee198773a3bff2e9ae6bca6164ef6b23286f8a1fd74db26636361fe7` before using them. + +The recollected control established: + +- focused lifecycle/broker/source-boundary validation: 379 passed and 0 failed; +- real Node adapter/producer validation: 4 passed and 0 failed; +- PTY integration: 139 passed, 1 skipped, and 0 failed; +- typecheck, lint, dependency boundaries, declaration emission, Bun/Node process-exit modes, and + the bounded canary scan passed; +- minified standalone broker builds were deterministic at 90,116 bytes for Bun and 90,118 bytes + for Node; +- compiled Hunk was deterministic at 177,268,864 bytes; +- process-to-lifecycle-ready median/p95 was 12.093/13.041 ms; +- synchronous connection-stop median/p95 was 0.000911/0.009067 ms. + +Authenticated-registration latency remains `not-measured`: the signed runtime fixtures prove the +complete hello/register behavior but do not expose one stable isolated timestamp in both arms. Hunk +startup polling also remains `not-measured` because it has no honest portable real-Node equivalent. +Listener acquisition and daemon ordered stop are unchanged transport context. CPU and RSS remain +coarse, non-isolated observations and support no strong conclusion. + +## Treatment + +Phase 1 pins `effect@3.22.1` only in `packages/session-broker`. One process lifecycle created by +`runInteractiveApp` owns one `ManagedRuntime`, one unref-aware Clock, one client startup supervisor, +and one connection-generation supervisor. The security and domain core, daemon, Node/Bun adapters, +authentication, validation, budgets, browser review, and native transport controls remain +Effect-free and unchanged. + +### Ownership result + +The treatment mechanically removes these independent producer-side owners: + +- connection reconnect timeout; +- connection heartbeat interval; +- per-socket handshake timeout `WeakMap`; +- connection reconnect-preparation timer callback chain; +- Hunk client reconnect timeout; +- Hunk client `startupPromise` field; +- launcher `Bun.sleep` polling ownership on the production Hunk path. + +They are replaced by exactly one `connectionSupervisor` property and one `startupSupervisor` +property. The frozen AST analysis reports no native timing in connection/client, no launcher +`Bun.sleep`, no delay-owner properties, no Promise-owner property, no lifecycle task registry, no +per-delay class, and exactly one each of `ManagedRuntime.make`, `createUnrefClock()`, +`Schedule.fixed`, delayed heartbeat `Effect.sleep`, and the UI factory call. Registration, snapshot, +producer hello state, command queues, executing work, and resource budgets remain outside Effect. + +This removes the native timing owners from the connection and client rather than merely renaming +them there. However, the private lifecycle implementation now owns substantial manual state for +first-attempt Promise projection, manual retry wakeup, native callback-to-Promise bridging, terminal +fences, and one fiber handle per supervisor. The AST gate rejects duplicate owners on the integration +classes and collection-style task registries, but it does not claim those supervisor-local handles +vanished. The ownership graph is narrower, but it is not materially simpler for a reviewer unfamiliar +with Effect. + +### Behavior and validation + +The frozen treatment harness completed successfully: + +- focused validation: 385 passed, 0 failed; +- real Node validation: 4 passed, 0 failed; +- full unit command: exit 0; +- PTY integration: 139 passed, 1 skipped, 0 failed; +- typecheck, lint, dependency boundaries, and public declaration emission: exit 0; +- Bun and Node signed registration plus pending handshake, heartbeat, and reconnect process-exit + modes: passed; +- Bun build graph rejected a direct `ws` probe and reached no `ws` import from the fixture; +- public declarations contain no Effect, runtime, Scope, Fiber, Cause, Schedule, Duration, Layer, or + Clock type; +- the bounded defect canary emitted exactly the fixed redacted message and no captured secret; +- existing control test names remained ordered, no assertion count decreased, and no + skip/todo/only mode was added. + +Effect 3.22.1's generic service provision does not replace the default Clock used by +`Effect.sleep`. The treatment therefore uses the dedicated `Effect.withClock(customClock)` around +production supervisor and startup-timing programs. Real Node and Bun subprocesses prove that the +handshake, heartbeat, and reconnect sleeps are unref-aware after that correction. TestContext and TestClock exercise delayed fixed heartbeat catch-up, handshake expiry, reconnect, +one startup retry/manual-wake sequence, stop during foreign settlement, repeated stop/root close, +and defect redaction. Independent review found important startup and closure interleavings that this +coverage missed; those failures are recorded below. + +The existing first socket is still constructed synchronously. Public `start()`/`stop()` gates are +still synchronous and terminal, while `settled` separately describes eventual closure. Native +health, credentials, crypto, filesystem, WebSocket, and spawn Promises are not described as +cancelled; terminal gates reject their late commits. + +Intentional treatment differences remain: + +- delayed `Schedule.fixed` catch-up may differ from native `setInterval` under overrun; +- unexpected lifecycle defects use only `Session broker lifecycle failed unexpectedly.` rather + than the control's mixed uncaught exception/unhandled rejection channels. + +### Independent behavior review + +Fresh reviewers reproduced several behavior defects that the frozen characterization corpus did not +cover: + +- after a successful startup program finishes, a later `start()` returns a manual-attempt Promise + with no live program to consume or resolve it; +- when an explicit start wakes a retry wait, recursive waiting races the already-resolved original + wake signal, so the retained automatic retry can run immediately instead of at its original + deadline; +- if the client's first synchronous socket construction throws, it has already stored the failed + connection, so later startup attempts can incorrectly stop at the existing-connection guard. This + assignment ordering already existed in the control and is a production-relevant bug exposed by the + experiment rather than a treatment regression; +- closing the process lifecycle directly does not settle an unstarted supervisor and does not set a + started supervisor's terminal fence, allowing a foreign startup rejection to call `onFailure` + after lifecycle settlement. The current UI mitigates this path by stopping the client before + closing the process lifecycle; +- a connection supervisor cannot start again after natural completion, although the outer + connection API still appears restartable until terminal stop. + +These are behavior-gate failures, not merely missing performance evidence. They strengthen the null +result. The experiment stops here rather than repairing them because the independently decisive +footprint, startup, auditability, and ownership-complexity gates have already failed; further fixes +would add machinery without changing the adoption decision. + +### Cost + +The fixed Effect/runtime cost remains large: + +| Measurement | Control | Treatment | Change | +| ----------------------------- | ------------: | ------------: | -------------------: | +| minified Bun broker | 90,116 B | 331,670 B | +241,554 B / +268.0% | +| minified Node broker | 90,118 B | 331,672 B | +241,554 B / +268.0% | +| compiled Hunk | 177,268,864 B | 177,891,456 B | +622,592 B / +0.35% | +| lifecycle-ready median | 12.093 ms | 37.875 ms | +213.2% | +| synchronous stop median | 0.000911 ms | 0.002795 ms | +206.8% | +| in-memory registration median | 0.006532 ms | 0.076784 ms | +1,075.5% | +| command round-trip median | 0.022964 ms | 0.087214 ms | +279.8% | +| reconnect generation median | 1.011449 ms | 1.128991 ms | +11.6% | + +These microbenchmarks are not end-user latency claims, but their direction and the deterministic +bundle/build deltas do not justify the added lifecycle machinery. Treatment RSS was lower in this +single coarse non-isolated observation; the harness explicitly forbids drawing a conclusion from +that sample. + +Treatment evidence is stored under `tmp/session-lifecycle-effect/treatment/`: + +- `result.json` and `source-identity.json`; +- `ownership-inventory.json` and `semantics.json`; +- `complete-experiment.patch` (SHA-256 + `2227069a83141c9d8a22b91e778f6c7c9a233ae8aff114b3d0db8d141b7d79cb`); +- validation transcripts and `validation/manifest.json`; +- `MANIFEST.sha256` (file SHA-256 + `0c31e4128daef210dcbcd4e0d49d4187ea44cbd98e8fe1c5f137faec8080e22c`); +- `MANIFEST.attestation.sha256` (file SHA-256 + `b6b16e108dcaf23ac66c2feb38e069694f3f49695479830d4d165e2a6ea8b451`). + +The Bun 1.3.14 isolated workspace linker placed Effect only under +`packages/session-broker/node_modules`; the frozen `bun:test`-hosted `Bun.build` gate resolved it +only after an ignored root `node_modules/effect` link to the same lockfile store entry was present. +Direct root `Bun.build`, package tests, and real Node resolve the package correctly. This generated +layout dependency is not a production source change, but it weakens clean-install reproducibility +of that one frozen build gate and is an additional reason not to propose adoption. + +## Verdict + +**Discard Phase 1.** The broader treatment removes the targeted native owners across package +connection timing, Hunk startup/retry, and launcher polling without moving authentication or +resource policy into Effect. It nevertheless fails the behavior gate under independent review, keeps +substantial supervisor-local ownership machinery, remains harder to audit, carries large +deterministic bundle and cold-start costs, and regresses every isolated producer microbenchmark +retained by the harness. The clean-install Bun build-gate resolver caveat further weakens +reproducibility. + +Do not begin daemon/adapters Phase 2, production migration, commit, push, PR, merge, or publish. +Independent review should verify this null-result report and the retained artifacts before the +experiment worktree is retired.