Removes the test-only "legacy polling" path from the Windows runtime, after moving every test that depended on it onto the path production runs.
AgentRuntime no longer takes apiFactory. The legacy loops (PollAsync, PollOnceAsync, the control loop, the legacy heartbeat), PollControlsOnceAsync and eleven branches on apiFactory are gone: 435 lines removed across 13 files in src/Qqq.PrintAgent.Windows.
The remote-control, session, pairing and service-loop tests now drive the runtime through a fake session and the loopback realtime channel, with a manual clock in place of wall-clock budgets.
Two production fixes that were the real causes of timing-dependent test failures, each in its own commit:
perf(store): the schema lock is per journal directory, not per process. Every AgentStore open in the process used to queue on one static lock that also covered a DPAPI read.
fix(realtime): the logged reason for a dead connection is the reader's failure when the reader failed, not whichever task failed first in time.
New tests: the reliability boundary across a service restart on the realtime path (an interrupted submission becomes OUTCOME_UNKNOWN, is reported, and is not reprinted), failure-reason tests, and concurrent opens of one new journal.
Why
Part of the suite exercised code production never runs (ADR-0003, audit finding W13), and the runtime cannot be split into per-server connections while a second, test-only shape of it exists. The timing-dependent tests in that suite also failed at random: 3 of 5 full verification runs of unrelated changes on this machine today.
Tests removed or replaced, with the reason
Legacy404ExplicitlyAllowsNormalPrinting: removed; a 404 from the control poll meaning "server without remote control" no longer exists. SavedLegacySupportIsReplacedOnceTheServerSpeaksRealtime covers what is still observable.
DelayedOldSessionDenialCannotBlockNewSignIn: removed; it held an HTTP control poll open. AgentRealtimeGenerationTests.OldHandshakeDenialCannotBlockReplacementSignedInSession asserts the same two properties on the realtime handshake.
NativeAdvertisedControlsNeverFallBackOn404: removed; the runtime has no control poll whose 404 it could misread.
AuthDenialPersistsUntilNewEnrollmentScopeWithoutErasingJournal: replaced by two tests, one for authorization withdrawn over realtime and one for a saved authorization demand.
Every other legacy test keeps its name or has a renamed equivalent; the full table is in the branch's history and the review.
How it was tested
Full build/Verify.ps1 twice on this head, replayed onto the current release/2.0: 197 Core and 411 Windows tests both times, zero build warnings, coverage gate passed (Core 87.7%, Windows 69.5%, command line 38.26%).
The Windows suite now runs in 31 to 51 seconds here.
One conflict when replaying onto release/2.0: AgentNewPairingTests.cs, resolved by keeping the current tests and switching them from PollControlsOnceAsync to EnsureAuthorizationAsync.
Not done: no installer build, install or printing. --legacy-compat in the command line is untouched; it does not use the removed code.
Known and left for later
A possible race the author found and did not fix: ApplyControlCommandAsync reads the control state and writes it back after a disk save, so a DESIRED_STATE frame arriving between could be overwritten.
A failing claim is retried by each of sixteen drain lanes.
Some real-time waits remain in tests this change did not re-home.
Checklist
Commits are signed and signed off (git commit -S -s)
Zero build warnings; format and lint are clean
Tests added or updated; the coverage gate still passes
Docs updated for user-visible changes (docs/DEVELOPMENT.md gains "Testing the service runtime")
No secrets, credentials, or personal data
New dependencies are justified below, or none were added (none)
Substantial AI assistance, if any, is noted here: written with Claude Code (Claude Fable 5.2) under the owner's direction of 2026-11-10.
## What
Removes the test-only "legacy polling" path from the Windows runtime, after moving every test that depended on it onto the path production runs.
- `AgentRuntime` no longer takes `apiFactory`. The legacy loops (`PollAsync`, `PollOnceAsync`, the control loop, the legacy heartbeat), `PollControlsOnceAsync` and eleven branches on `apiFactory` are gone: 435 lines removed across 13 files in `src/Qqq.PrintAgent.Windows`.
- The remote-control, session, pairing and service-loop tests now drive the runtime through a fake session and the loopback realtime channel, with a manual clock in place of wall-clock budgets.
- Two production fixes that were the real causes of timing-dependent test failures, each in its own commit:
- `perf(store)`: the schema lock is per journal directory, not per process. Every `AgentStore` open in the process used to queue on one static lock that also covered a DPAPI read.
- `fix(realtime)`: the logged reason for a dead connection is the reader's failure when the reader failed, not whichever task failed first in time.
- New tests: the reliability boundary across a service restart on the realtime path (an interrupted submission becomes OUTCOME_UNKNOWN, is reported, and is not reprinted), failure-reason tests, and concurrent opens of one new journal.
## Why
Part of the suite exercised code production never runs (ADR-0003, audit finding W13), and the runtime cannot be split into per-server connections while a second, test-only shape of it exists. The timing-dependent tests in that suite also failed at random: 3 of 5 full verification runs of unrelated changes on this machine today.
## Tests removed or replaced, with the reason
- `Legacy404ExplicitlyAllowsNormalPrinting`: removed; a 404 from the control poll meaning "server without remote control" no longer exists. `SavedLegacySupportIsReplacedOnceTheServerSpeaksRealtime` covers what is still observable.
- `DelayedOldSessionDenialCannotBlockNewSignIn`: removed; it held an HTTP control poll open. `AgentRealtimeGenerationTests.OldHandshakeDenialCannotBlockReplacementSignedInSession` asserts the same two properties on the realtime handshake.
- `NativeAdvertisedControlsNeverFallBackOn404`: removed; the runtime has no control poll whose 404 it could misread.
- `AuthDenialPersistsUntilNewEnrollmentScopeWithoutErasingJournal`: replaced by two tests, one for authorization withdrawn over realtime and one for a saved authorization demand.
Every other legacy test keeps its name or has a renamed equivalent; the full table is in the branch's history and the review.
## How it was tested
- Full `build/Verify.ps1` twice on this head, replayed onto the current `release/2.0`: 197 Core and 411 Windows tests both times, zero build warnings, coverage gate passed (Core 87.7%, Windows 69.5%, command line 38.26%).
- The Windows suite now runs in 31 to 51 seconds here.
- One conflict when replaying onto `release/2.0`: `AgentNewPairingTests.cs`, resolved by keeping the current tests and switching them from `PollControlsOnceAsync` to `EnsureAuthorizationAsync`.
Not done: no installer build, install or printing. `--legacy-compat` in the command line is untouched; it does not use the removed code.
## Known and left for later
- A possible race the author found and did not fix: `ApplyControlCommandAsync` reads the control state and writes it back after a disk save, so a DESIRED_STATE frame arriving between could be overwritten.
- A failing claim is retried by each of sixteen drain lanes.
- Some real-time waits remain in tests this change did not re-home.
## Checklist
- [x] Commits are signed and signed off (`git commit -S -s`)
- [x] Zero build warnings; format and lint are clean
- [x] Tests added or updated; the coverage gate still passes
- [x] Docs updated for user-visible changes (`docs/DEVELOPMENT.md` gains "Testing the service runtime")
- [x] No secrets, credentials, or personal data
- [x] New dependencies are justified below, or none were added (none)
- [x] Substantial AI assistance, if any, is noted here: written with Claude Code (Claude Fable 5.2) under the owner's direction of 2026-11-10.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Every AgentStore open ran its schema setup, including the DPAPI read of the
saved connection, under one static lock. The service opens a store for most
operations, and the test fixtures open one each time they poll a snapshot,
so in a parallel test run every open in the process queued behind every
other one. Tests that take half a second alone took 10 to 20 seconds in the
full Windows suite, and the ones with a fixed budget timed out.
The lock is now one per journal directory, compared without regard to case.
Stores of the same journal still cannot race to add a column or a table;
stores of different journals no longer wait for each other. A service with
one state directory behaves as before.
Measured on the full Windows suite: the realtime tests fall from 10-20 s to
1-2 s each, and the two 10,000-row store tests from 76 s and 42 s to 24 s
and 26 s.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
AgentPrinterCatalogTests.MalformedPrintersFrame_EndsTheConnection failed
once because the log did not name the malformed frame. Reproduced in 1 of
150 runs; the log read "Realtime connection stopped: IOException: Realtime
connection closed before acknowledgement" instead.
When the reader rejects a frame, the channel fails every send that is
waiting for an acknowledgement before the reader's own task has finished.
On current .NET, awaiting Task.WhenAll rethrows the failure that completed
first in time, not the first in argument order, so a pending inventory
acknowledgement could win and the operator was told about the side effect
rather than the cause.
The connection now waits for all its tasks and rethrows the reader's
failure when the reader failed; otherwise it reports as before. Nothing
else about the connection changes: the same tasks are awaited and the same
reconnect follows.
The choice is tested directly with hand-completed tasks (the test fails on
the old code), and the catalog suite gains the scenario with an
acknowledgement held back.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The tests that still enter the runtime through the legacy polling path are
about to move onto the realtime path. They need three things from the
runtime, none of which changes what the service does:
- EnsureAuthorizationAsync is internal. It is the one place the reconnect
loop and the heartbeat decide whether the service may talk to QQQ, so a
test that needs authorization evaluated once calls what production calls.
- The heartbeat loop measures its interval on the runtime's time provider
(the system clock when none is injected), so a test can let an interval
pass instead of waiting for it.
- The number of lanes a claim wave starts has a name, DrainLanes, instead
of a literal 16.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The realtime fixture gains what the legacy-path tests need in order to
move onto the path production runs:
- a restart on the same store, pipe and fake server;
- the management pipe, so a test talks to the runtime as the companion does;
- a starting state the test seeds before the runtime starts, with helpers
for a spooled job and a control command;
- scripted answers to an agent message: refuse it with an ERROR frame, or
drop the connection without acknowledging it;
- the fake server's claim hold, failing empty claims, operator actions and
a request counter;
- a printer that can hold a submission open and script a spooler cancel;
- a connection to a device the server keeps disconnected, which waits for
the transport instead of for traffic that is deliberately not allowed;
- a manual clock (a hand-written TimeProvider) and a helper that lets one
heartbeat interval pass.
Connecting no longer gives the agent five seconds to open its socket; the
fixture's overall limit applies, as it does to every other wait. The
fixture is split into parts by concern; existing tests are unchanged.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
AgentRemoteControlTests drove the runtime through PollControlsOnceAsync
with an injected HTTP API, a path the service never takes. Each rule is now
asserted where production applies it: desired state arrives in WELCOME and
DESIRED_STATE frames, commands in COMMAND frames, and receipts leave as
COMMAND_RECEIPT messages that the server acknowledges, refuses or loses.
Same rule, new transport, for: a malformed or regressed desired state
cannot resume a paused device (it ends the connection); receipts carry the
UTC wire timestamp; a lost native cancel result stays unknown and is not
repeated; a receipt the server pruned is retired only after seven days; a
refused receipt of a current command stays pending and is redelivered; a
lost receipt plus a restart plus a duplicate delivery cancel once and
resend one identical receipt; an interrupted execution marker becomes
unknown without a native call; expiry and missing ownership never touch
Windows; a verified native refusal is reported as it is; a refresh held
while disconnected runs after the server reconnects the device, and the
restriction survives a restart with a failing first attempt.
Two tests are replaced because their trigger no longer exists:
- AuthDenialPersistsUntilNewEnrollmentScopeWithoutErasingJournal relied on
a 401 from the HTTP control poll setting the saved authorization demand.
Nothing on the realtime path writes that flag. It becomes two tests:
authorization withdrawn by a realtime ERROR frame blocks printing until
sign-in and keeps the journal, and a demand already saved by an older
release blocks traffic across a restart until a new enrollment scope.
- Legacy404ExplicitlyAllowsNormalPrinting relied on a 404 from the control
poll meaning "server without remote control". The realtime path has no
such probe. What remains observable is a saved LEGACY state from an older
release, which a realtime WELCOME replaces with SUPPORTED.
The fixture's accept skips a socket the agent abandoned while its upgrade
was in flight, which a restart leaves in the listener's queue.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The session and pairing tests called PollControlsOnceAsync only to have
authorization evaluated once; the HTTP control poll that followed is a
request the service never makes. They now call EnsureAuthorizationAsync,
the check the reconnect loop and the heartbeat make before talking to the
server, and assert its answer as well as the resulting state.
AgentNewPairingTests: nine calls replaced, assertions unchanged.
AgentSessionIntegrationTests:
- TransientResumeFailureDoesNotRequireNewSignIn counts renewals instead of
polls and also asserts that the failed renewal did not block authorization.
- RestoredSessionUsesNativeTransportAndPeriodicallyResumes becomes
RestoredSessionOpensTheRealtimeSocketAndRenewsItself: the native transport
is now the realtime socket, so a running service must ask the restored
session for it, with no HTTP printing request first.
- DelayedOldSessionDenialCannotBlockNewSignIn is removed. It held an HTTP
control poll open on the old session, which cannot happen any more. The
same two properties (sign-in does not wait for the old session's request,
and its late denial cannot block the new session) are asserted on the
realtime handshake by
AgentRealtimeGenerationTests.OldHandshakeDenialCannotBlockReplacementSignedInSession.
- NativeAdvertisedControlsNeverFallBackOn404 is removed. The runtime has no
control poll whose 404 it could misread as "legacy server"; LEGACY can no
longer be produced by the service at all.
The fake session loses its control-poll scripting, and its runtime gets a
private pipe so a test may run the service loop.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Seven tests started the runtime with an injected HTTP API, which made
RunAsync take the legacy polling loop instead of the realtime path. They
now run the path the service runs, wait on events instead of counting polls
inside a wall-clock budget, and use a manual clock where the code under
test waits for a heartbeat interval.
- Backlog (two cases): one claim drain prints three waiting jobs with no
further signal and stops at the first empty claim, or after each lane has
tried once when the server fails; nothing is claimed again until the
server announces a job. Claim counts are exact because every wake-up is
pinned before claims are released. Was: four claims inside ten seconds,
then 250 ms of silence.
- Heartbeat: three intervals pass on the manual clock while a submission
is held in the printer; each sends a heartbeat and contact stays fresh.
Was: three 50 ms heartbeats inside ten seconds.
- Profiles: the printer profile reaches the server in the inventory the
change triggers and the device profile in the next heartbeat.
- Queued jobs: with dispatch paused, an announced job is shown from the
server's queue, cancelled through the server and never claimed.
- Update: prepare-update applies the pause, answers only after the held
submission is recorded, and a job announced during the pause is not
claimed across a heartbeat interval; resume prints it. Was: a 100 ms
sleep, and a heartbeat at least 1.2 s into the pause.
- Reconnect: after a timed-out first request the loop sits in a 60-second
backoff; a reconnect request over the pipe connects at once. Was: within
three seconds of a wake-up of the poll loop.
- Control concurrency: disconnect and reconnect are applied while a print
is held; pings continue, heartbeats and claims stop, the job survives.
Was: about nine 30 ms control polls inside ten seconds.
ControlFixture loses its fake polling server and becomes
EnrolledStateFixture: the local state of an enrolled computer, which is all
the session tests used it for.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
AgentRuntime took an optional apiFactory. When a test passed one, RunAsync
ran an HTTP polling loop instead of the realtime path, and eleven places
branched on whether it was set. The service never passed it. Every test
that used it, or the internal PollControlsOnceAsync, now runs the realtime
path, so the legacy code has no caller left and is deleted:
- the apiFactory constructor parameter and all its branches;
- PollAsync and PollOnceAsync (the claim loop and one pass of it);
- ControlLoopAsync, PollControlsOnceAsync, FetchControlPollAsync and
ApplyControlPollAsync (the HTTP control poll);
- the legacy HeartbeatAsync loop;
- the test-only settings HeartbeatInterval and ControlIntervalOverride;
- the state only those loops wrote: the poll failure text, the control
failure text, the poll interval, the "poll started" flag and the history
maintenance time;
- EnrollAsync, a wrapper left from the bearer enrollment that only called
EnrollNativeAsync.
RunAsync now runs the realtime loop directly. CreateApi no longer takes the
configuration it stopped using. AgentRuntime.Heartbeat.cs holds only the
connection generation and is renamed to AgentRuntime.ConnectionGeneration.cs.
What the service does is unchanged: each removed branch already took the
realtime side whenever apiFactory was null. Three values can no longer
occur and their handling went with them: the transport protocol "HTTP",
the 30-second freshness window of the polling loop, and the status text
"Remote control could not be synchronized".
BREAKING CHANGE: AgentRuntime's constructor no longer has the apiFactory
parameter. Callers passing arguments by position after printerBackend must
drop it; the service host passes three arguments and is unaffected.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The legacy polling loop let the print worker recover unfinished jobs
itself, and that is where "an interrupted submission becomes
OUTCOME_UNKNOWN, never a reprint" was exercised. The realtime path switches
the worker's recovery off and does it once at service start, in code no
test reached.
Two tests on the realtime path:
- after a crash, a job left SUBMITTING is recorded OUTCOME_UNKNOWN and one
left RECEIVED is recorded FAILED before any connection; both are
reported once connected, and the uncertain job is not printed when the
server hands it out again;
- a submission held in the printer when the service is stopped had its
SUBMITTING receipt acknowledged before the printer was called, becomes
OUTCOME_UNKNOWN, is reported after the restart, and is not printed when
the server hands it out again.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The developer guide gains a section on testing AgentRuntime now that the
realtime path is the only way into it: what RealtimeRuntimeFixture and
SessionFixture provide, and the rule for waits (an event, never a length of
time; a manual clock where the runtime itself waits on one; an event that
can only follow, to show that something did not happen).
Also drops a local in SendOperatorActionAsync that was only read by the
removed API factory; the "connect this device first" check is kept.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
UnreadableCatalog_ReportsNotReportedWithTheReason failed in one full
verification run: the snapshot already showed "not reported" but the
warning was not in the log yet. The runtime stores a catalog reading under
its state lock and writes the log line after releasing it, so a test that
sees the stored reading can run its log assertion before the line exists.
That test and SharedPrinters_ShowApprovalFromTheConnectedServersCatalog,
which had the same order, now wait for the line. The "logged once" check
is unchanged.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The command worker copied the control state, saved the copy with the
command's result and then published the whole copy. A DESIRED_STATE frame
that the reader applied in between was overwritten in memory and on disk
by the older state. Depending on when the state applier ran, the agent
then reported the old revision as applied and went on claiming against a
pause until the server repeated it, or reported the new revision while
holding the old one. In the test below the old code ended with the old
state in force and the new revision reported, in all four cases.
The command path now changes only the three last-command fields, in the
state as it is at that moment, and saves that. It and the state applier
save under one small lock, each saving what it reads inside it, so the
journal ends with the newest state. The command path no longer marks
control ready or wakes the claim loop; a command result changes neither.
The connection gate is not taken there, because a transition takes it
before the control gate and update preparation can hold it for long.
The test holds the command's save open, delivers the frame, lets the save
through, and requires the new state in memory and on disk, only the new
revision reported as applied, and the command's result kept. It runs for a
pause and for a resume, against a server that repeats an unapplied state
and one that does not. The runtime gains an unset-by-default callback at
the save for this; nothing else can place a frame inside that window,
since every earlier write of the command takes the same journal lock.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A lane whose claim failed logged and stopped, but did not mark the wave
drained, so each of the other fifteen lanes took its turn and made the
same failing request. A server answering 503 got sixteen claims and the
log sixteen ERROR lines for every wake-up; six wake-ups pushed everything
else out of the 100-entry log shown to the operator.
A lane that fails before it holds a job now marks the wave drained and
signals the pipeline, and the failure is logged once for the wave. A
failure while a job is held still concerns that job only: it is logged and
the other lanes carry on. The lane's single pass moves into its own method
so the failure handling can tell the two apart.
The backlog test goes back to one failed claim and one log line per
wake-up; the previous commit had written the sixteen into it.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Update preparation waited only for submissions in progress. A job that was
claimed and still downloading was not counted, so the reply said ready at
once; the job then reached the submission gate, which the pause kept
closed, and the installer stopped the service under it. The job was
recorded FAILED: lost to the update. Only the legacy polling loop had made
preparation wait for a claim, by holding the connection gate around it.
Now the pause stops new claims and nothing else. A job claimed before it
passes the submission gate, is printed and reported. Preparation passes
the claim gate once, so a claim already on its way to the server has its
job registered, then waits until no job ticket remains, on the pipeline
signal. The wait is bounded at 25 seconds, inside the pipe's 30-second
command limit: when it is reached the reply and the log say how many jobs
were still in progress, the pause stays and the jobs carry on.
The pause, its expiry and the preparation limit are measured on the
runtime's time provider. A pause that runs out without a resume is now
noticed by the next heartbeat round, which wakes the claim loop; before,
jobs announced during the pause waited for the server's next announcement.
Tests: a download held during prepare-update is printed and reported
before the reply and no new claim is made; a claim in flight is waited
for; the limit is reached on the manual clock and reported; the pause
expires by itself and the waiting job prints. The existing test now counts
the claims after the resume exactly instead of only checking that none was
made during the pause.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A service whose authorization was withdrawn answered a reconnect request
with "Reconnect requested" and did nothing. ReconnectLocal checked the
saved control state's authorization flag, which only the removed HTTP
control poll ever set, instead of the block the realtime path uses. It now
says that a sign-in is required when authorization is blocked or no usable
sign-in is held, and retries nothing. ControlStatus reports the block in
red with the sign-in message from the same source.
2.0 does not honor control state written by the 0.2 polling prerelease, so
the reads that only such a file could satisfy are gone: the flag no longer
gates ordinary traffic, sign-in no longer clears it, and the "LEGACY"
support value has no wording of its own; any support value other than
SUPPORTED reads as still being checked. An old file still loads: the
members it carries that the record no longer has are ignored.
AgentControlState keeps AuthorizationRequired as a read-only member that
is always false, because the companion's footer reads it and that project
belongs to another branch. It can be removed with that read.
The two tests that reached the removed reads by seeding state are deleted
(SavedAuthorizationDemandPersistsUntilNewEnrollmentScopeWithoutErasingJournal,
SavedLegacySupportIsReplacedOnceTheServerSpeaksRealtime). One test replaces
them: a polling-era state file is read without failing and not honored.
The withdrawn-authorization test now requires the sign-in reply, the red
status and no reconnect attempt.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The earlier fix always preferred the reader's failure. That is wrong when
the server refuses a message with an ERROR frame and then closes the
socket: the worker fails with the refusal, the reader then reads the close
frame, and the log said "Server closed the realtime connection" where it
used to name the refusal. Failures other than the chosen one were also
dropped from the log entirely.
The cause now travels with the consequence. PrintingRealtimeChannel.RunAsync
keeps the task that ended the channel and passes its failure as the inner
exception of every "closed before acknowledgement" failure it hands to a
waiting send. That is the whole Core change: one local, one argument and a
sentence in the summary.
The runtime then picks the reason by kind. A worker that failed with
anything other than a plain I/O error has a reason of its own (a refusal,
a timeout, a session the server ended) and is reported. A worker's plain
I/O error only says the connection was gone when it tried to use it, so
the reader's failure is reported instead. Every other failure is logged at
DEBUG with its causes. Completion order is no longer consulted.
RealtimeConnectionAsync hands HELLO to a helper, which brings it back
under sixty lines.
Tests: the rule with hand-completed tasks in the misleading order (refusal
after closure; failed send before the reader; a session failure; a plain
I/O error with the reader only cancelled); a real channel showing the
failed send carries the cause; and on the fixture, which can now close
after refusing, a refused receipt followed by a close frame is logged as
the refusal.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
ApplyControlCommandAsync answers OUTCOME_UNKNOWN when it finds a recorded
execution intent without a receipt. No test reached that branch after the
move to the realtime path, because a restart writes the unknown receipt
first. It is reachable without one: when the spooler cancel throws
something outside the expected failures, the command worker faults, the
connection drops, and the server delivers the command again. That branch
is then the only thing between the redelivery and a second spooler call.
The fake printer can now fail a cancellation with a scripted exception.
The test requires one cancellation, no receipt before the redelivery, and
exactly one OUTCOME_UNKNOWN receipt after it.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
These tests used to call PollControlsOnceAsync, which sent a control poll
whenever authorization was wrongly granted, so asserting that no request
was recorded was a real check. After the move to EnsureAuthorizationAsync
it was not: that call never sends anything, and removing the
authorization check in front of the connection attempt failed none of them.
Each such case now runs the service loop for one whole pass of the
connection loop and then requires that no printing request went through
the session, that the session was not asked for the realtime socket, and
that no connection attempt was logged as failed. With the authorization
check removed, ten of them fail. The pass is known to be over when the
loop is waiting out its backoff, which is a timer on the fixture's clock.
On the realtime fixture, the withdrawn-authorization test lets three
backoffs run out and requires the HTTP request count to stay unchanged.
To make that observable, two waits move to the runtime's time provider:
the reconnect backoff and the session renewal. The session fixture runs on
a manual clock, so SessionResumeInterval is a constant again and tests
that need a renewal let the interval pass instead of setting it to zero.
The backoff wait cancels its wait on the reconnect request when the delay
ends, so a later request is not consumed by a wait that already gave up.
One behavior change came out of driving the loop by the clock: a sign-in
or enrollment woke the connection loop while its transition was still in
progress, the loop saw the transition and backed off, and nothing woke it
again. In real time that cost up to one backoff after signing in. The
transition now asks for a connection once more when it has finished.
Also tested: a reconnect request while no usable sign-in is held is
answered with "Sign-in is required" and attempts nothing.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The disconnect test could not see a claim whatever the runtime did: a
print held open keeps its claim wave open, and a wave that has found
the queue empty claims nothing more. It now runs with a free download
slot and a second job, and a new test announces a job to an idle,
disconnected device, where only the disconnect stands between the
announcement and a claim. Removing the desired-state check from the
claim condition fails that test.
The restart tests counted completed prints while the printer was held,
so they could not tell "not submitted again" from "submitted and held".
The synthetic printer now counts entries into the submission, and the
service-stop test lets a repeated submission complete.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The catalog test slept 1.5 seconds and then asserted that nothing had
been read, during which no synchronization pass ran at all. The
60-second refresh interval is now measured on the runtime's time
provider, and the test lets heartbeat rounds pass on a manual clock:
no read inside the minute, exactly one when it completes. Making every
pass read fails it.
A command's expiry compared the system clock with a server offset; both
readings now come from the same provider. The service passes none, so
it keeps using the system clock.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The lock was looked up by the full path as given, so the same folder
written with a trailing separator got a second lock. The trailing
separator is now trimmed from the key.
The concurrent-open test ran its sixteen opens as thread-pool tasks
behind an event, which does not make them overlap. Each open now has a
thread of its own and a barrier releases them together, in three
spellings of the folder.
A new test shows that stores on different folders do not wait for each
other: one journal is kept busy while a store opens it, and a store on
another folder opens and is used meanwhile. With a single lock for all
journals it fails.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The timer's due time and period and the fixture's folder and printer had
no summary.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
One 30-second source both limited every wait and stopped the runtime
and its connections, so a test whose waits added up to more than that
had its service stopped underneath it and failed with a misleading
"wait failed". The runtime and connections now live until the fixture
is disposed, and each wait (a condition, a signal, the handshake, a
pipe exchange) has its own 30-second limit.
The fixture's own properties and the server counters also get the
summaries they lacked.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The scripted server answered a claim whenever one arrived. A claim is
legitimate only between the server's acknowledgement of the agent's
STATE_APPLIED and the agent's next HELLO (or a restart). Outside that
span it is now counted with the forbidden polls and refused. The span is
not ended when the server drops the socket, because a claim already on
its way at that moment is not a fault of the agent.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The claim wake-up and the last-contact time still carried names from
the polling path that no longer exists: _pollRequested, WakePoll and
_lastSuccessfulPoll are now _claimsRequested, WakeClaims and
_lastServerContact. The snapshot's LastSuccessfulPoll property keeps
its name because the companion reads it.
The enrollment log reported the server's poll interval, which the
realtime runtime never uses; the line now names the device only. The
field stays on the saved record. A comment in the updater that spoke of
the service's "current poll" now says what the service does.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The section still said a test evaluates authorization by calling
EnsureAuthorizationAsync and that the heartbeat interval is the only
wait on the injected clock. It now names the helpers the tests use,
lists what the runtime reads from its time provider and what still uses
real time, and says what to do when "nothing happened" leaves no event
to wait for. The update paragraph states what prepare-update answers
and when.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Three full verification runs measured 70.32, 70.27 and 70.19 percent
line coverage for Qqq.PrintAgent.Windows. The minimum follows the rule
of one below the whole percent measured, so it rises from 68 to 69.
Core (87.82, minimum 86) and the command line (38.26, minimum 37) are
already at that level.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A runtime-suite test failed now and then with ObjectDisposedException:
'SQLitePCL.sqlite3' inside AgentStore.InitializeDatabase. Twelve test
files called SqliteConnection.ClearAllPools() during cleanup while
other classes ran in parallel. Microsoft.Data.Sqlite hands a pooled
connection out and marks it active before it records whose it is; a
clear that runs in that gap takes the connection for abandoned and
disposes it, and the store that had just taken it fails at its first
statement. A probe that cleared every pool while another thread opened
stores failed in six of eight runs.
Cleanup now goes through JournalFiles, which empties the pool of the
test's own journal (by the connection string the store exposes for it)
and deletes the folder. No test clears every pool any more. A stress
test keeps releasing one journal while stores open on another; with the
helper clearing every pool it fails in four of five runs.
The service never clears pools, so its behavior is unchanged; the store
only gained an internal method that names its connection string.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
prepare-update held the connection gate for its whole wait, up to 25
seconds. A DESIRED_STATE or SETTINGS frame arriving meanwhile was
applied in memory but could not be saved or acknowledged, the runtime
stayed not ready, the claimed job could not pass the submission gate,
and preparation answered "Not ready" for a delay it caused itself. The
pause is now set under the gate and the gate released before the wait.
When the limit ran out while the server had not yet answered a claim,
the reply read "0 job(s) were still in progress". It now says that a
claim was still in progress (or a claim and N jobs), and the log says
the same.
Both new tests fail on the previous code.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The rule for the logged reason asked whether a worker's failure was
"exactly IOException". That treated a full output queue, which is a
worker's own reason, like a worker that merely found the connection
gone, so it could end up logged only at DEBUG.
RealtimeChannelClosedException (Core, derives from IOException) now
marks the second kind. The channel uses it for a send that was waiting
for an acknowledgement when the channel closed and for a send attempted
after that; the runtime uses it where no channel exists. A worker
failure of any other type is the reason. The second use in the channel
is needed: between the channel closing and the runtime dropping it, a
worker's send fails there, and with a plain IOException it would now be
reported instead of the frame the reader rejected.
The channel test moves from the runtime suite to the Core tests, in a
new file, with two more cases.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The page said a drain "resumes on a committed notification or released
capacity". Released capacity lets an open wave go on claiming, but a
notification that arrives while a wave is open only starts the next
wave, and that starts after the open wave's in-flight jobs have
finished. A job announced meanwhile waits for them, even when its
printer is idle.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The test used a thread's sleep state as its signal and SQLite's busy
retry as its clock, so an early sleep could let a regression pass. It
now holds one journal's schema lock itself and opens a store on another
folder meanwhile; the open must finish while the lock is held. The
store exposes the lock for a folder (internal) for that purpose, and
the test also checks that upper case and a trailing separator find the
same lock. With one lock for all journals the test fails.
The lock's summary no longer claims to cover every spelling of a
folder: short names, junctions and substituted drives are separate
keys.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Releases paced by the opens never fell into the gap in which the pool
has handed a connection out but not yet marked it, so they could not
show the fault. The summary records that, the rate at which the unpaced
loop showed it, and why the loop is kept short.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
SessionResumeInterval had two blank lines before it where members take
three. DrainLanes is used only by the claim pipeline, so it no longer
needs to be internal.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Removes the test-only "legacy polling" path from the Windows runtime, after moving every test that depended on it onto the path production runs.
AgentRuntimeno longer takesapiFactory. The legacy loops (PollAsync,PollOnceAsync, the control loop, the legacy heartbeat),PollControlsOnceAsyncand eleven branches onapiFactoryare gone: 435 lines removed across 13 files insrc/Qqq.PrintAgent.Windows.perf(store): the schema lock is per journal directory, not per process. EveryAgentStoreopen in the process used to queue on one static lock that also covered a DPAPI read.fix(realtime): the logged reason for a dead connection is the reader's failure when the reader failed, not whichever task failed first in time.Why
Part of the suite exercised code production never runs (ADR-0003, audit finding W13), and the runtime cannot be split into per-server connections while a second, test-only shape of it exists. The timing-dependent tests in that suite also failed at random: 3 of 5 full verification runs of unrelated changes on this machine today.
Tests removed or replaced, with the reason
Legacy404ExplicitlyAllowsNormalPrinting: removed; a 404 from the control poll meaning "server without remote control" no longer exists.SavedLegacySupportIsReplacedOnceTheServerSpeaksRealtimecovers what is still observable.DelayedOldSessionDenialCannotBlockNewSignIn: removed; it held an HTTP control poll open.AgentRealtimeGenerationTests.OldHandshakeDenialCannotBlockReplacementSignedInSessionasserts the same two properties on the realtime handshake.NativeAdvertisedControlsNeverFallBackOn404: removed; the runtime has no control poll whose 404 it could misread.AuthDenialPersistsUntilNewEnrollmentScopeWithoutErasingJournal: replaced by two tests, one for authorization withdrawn over realtime and one for a saved authorization demand.Every other legacy test keeps its name or has a renamed equivalent; the full table is in the branch's history and the review.
How it was tested
build/Verify.ps1twice on this head, replayed onto the currentrelease/2.0: 197 Core and 411 Windows tests both times, zero build warnings, coverage gate passed (Core 87.7%, Windows 69.5%, command line 38.26%).release/2.0:AgentNewPairingTests.cs, resolved by keeping the current tests and switching them fromPollControlsOnceAsynctoEnsureAuthorizationAsync.Not done: no installer build, install or printing.
--legacy-compatin the command line is untouched; it does not use the removed code.Known and left for later
ApplyControlCommandAsyncreads the control state and writes it back after a disk save, so a DESIRED_STATE frame arriving between could be overwritten.Checklist
git commit -S -s)docs/DEVELOPMENT.mdgains "Testing the service runtime")🤖 Generated with Claude Code