Skip to content

fix(windows_event_log source): correct wevtapi error codes and re-subscribe on unusable handles - #26118

Open
pos-ei-don wants to merge 3 commits into
vectordotdev:masterfrom
pos-ei-don:fix/windows-event-log-error-codes
Open

fix(windows_event_log source): correct wevtapi error codes and re-subscribe on unusable handles#26118
pos-ei-don wants to merge 3 commits into
vectordotdev:masterfrom
pos-ei-don:fix/windows-event-log-error-codes

Conversation

@pos-ei-don

Copy link
Copy Markdown

Closes #26117. Very likely also fixes #26115 — same root cause, different symptom.

The problem

Four of the six hand-written Win32 error constants in src/sources/windows_event_log/subscription.rs
held wrong values. Verified against winerror.h / System Error Codes (12000-15999):

Constant Was Correct What the old value actually is
ERROR_EVT_QUERY_RESULT_STALE 4317 15011 4317 = ERROR_INVALID_OPERATION
ERROR_EVT_QUERY_RESULT_INVALID_POSITION 16953 15012 16953 is not a Win32 error code
ERROR_EVT_CHANNEL_NOT_FOUND 15009 15007 15009 = ERROR_EVT_SUBSCRIPTION_TO_DIRECT_CHANNEL
ERROR_EVT_INVALID_QUERY 15007 15001 15007 = ERROR_EVT_CHANNEL_NOT_FOUND

Two consequences:

1. The source goes silent and looks healthy (#26117). EvtNext on a fresh
EvtSubscribeToFutureEvents pull subscription returns ERROR_INVALID_OPERATION (4317) on a
perfectly healthy channel. Because the STALE constant happened to hold exactly 4317, this was
logged as Channel subscription ended. at debug level — invisible at the default log level —
and the channel was marked drained without re-subscribing.

2. Re-subscription was unreachable dead code. The only self-healing branch was gated on 16953,
a value that cannot occur. A real stale result (15011) therefore matched no branch at all and fell
into the generic recoverable-error path, where the same dead handle is retried forever — which is
exactly the behaviour and log wording reported in #26115.

Evidence

Measured on Vector 0.57.0 (x86_64-pc-windows-msvc), Security channel, pull mode, with
VECTOR_LOG=debug, while 4624 events were continuously being generated:

Events delivered 3, all at startup
Events delivered afterwards 0, for minutes, despite new matching events
Channel subscription ended.
Re-subscription attempts 0
ERROR_NO_MORE_ITEMS 0 — so the channel was not drained

ERROR_NO_MORE_ITEMS is handled one branch earlier, so reaching the mislabelled branch means
EvtNext genuinely returned 4317. Full log excerpts are in #26117.

The change

  • Correct the four constant values; add ERROR_INVALID_OPERATION as its own named constant.
  • Treat STALE, INVALID_POSITION and INVALID_OPERATION alike: all three mean the handle can no
    longer serve results and must be rebuilt, so all three route into resubscribe_channel.
  • Move that log line from debug to warn and include the error code, so a recurrence is visible
    instead of silent.

Test

test_read_existing_events_false_only_receives_future_events was vacuous: it seeded nothing and
only asserted a property of whatever events it happened to receive, so the for loop body never ran
and it passed with zero events — precisely the failure mode above. That is why CI never caught this.

It now subscribes first, seeds the Application log with eventcreate second, polls for up to 30s and
asserts the result is non-empty before checking timestamps. The ordering is the point: seeding before
subscribing would again be satisfied by an empty result. Marked #[serial] like the neighbouring
seeding test.

Notes for reviewers

  • I could not compile this locally — no Windows toolchain available on my side, so CI here is the
    first build. Happy to iterate quickly on anything that breaks.
  • I deliberately kept the constants as literals rather than importing them from the windows crate.
    Pulling them from windows::Win32::Foundation would be the more robust fix and I'd be glad to
    switch — I just could not verify those paths resolve under the crate's feature set without a build,
    and did not want to guess in a PR.
  • I have a reproducing environment (domain-joined Windows client where this occurs consistently)
    and am happy to verify a candidate build against it.

…scribe on unusable handles

Four of the six Win32 error constants in subscription.rs held wrong values,
verified against winerror.h:

  ERROR_EVT_QUERY_RESULT_STALE            4317  -> 15011  (4317 is ERROR_INVALID_OPERATION)
  ERROR_EVT_QUERY_RESULT_INVALID_POSITION 16953 -> 15012  (16953 is not a Win32 code)
  ERROR_EVT_CHANNEL_NOT_FOUND             15009 -> 15007  (15009 is SUBSCRIPTION_TO_DIRECT_CHANNEL)
  ERROR_EVT_INVALID_QUERY                 15007 -> 15001  (15007 is CHANNEL_NOT_FOUND)

Two consequences, both observed on a live Windows client:

1. `EvtNext` on a fresh EvtSubscribeToFutureEvents pull subscription returns
   ERROR_INVALID_OPERATION (4317) on a perfectly healthy channel. Because the
   STALE constant happened to hold exactly that value, it was logged as
   "Channel subscription ended." at debug level -- invisible at the default log
   level -- and the channel was marked drained without re-subscribing. The source
   then stayed silent for good while still looking healthy from the outside.

2. The only self-healing path was gated on 16953, a value that cannot occur, so
   re-subscription was unreachable dead code. A real stale result (15011) matched
   no branch at all and fell into the generic recoverable-error path, where the
   same dead subscription handle is retried forever.

Fix: correct the values, and treat STALE, INVALID_POSITION and INVALID_OPERATION
alike -- all three mean the handle can no longer serve results and must be rebuilt.
The log line moves from debug to warn and carries the error code, so a recurrence
is visible rather than silent.

The regression test for this is included: it previously seeded nothing and only
asserted a property of whatever events arrived, so it passed with zero events --
exactly the failure mode described here, which is why CI never caught it. It now
subscribes first, seeds the Application log second, polls, and asserts non-empty.

Measured on Vector 0.57.0 (x86_64-pc-windows-msvc), Security channel, pull mode:
3 events at startup, then zero for minutes while matching events kept arriving,
with "Channel subscription ended." firing 8 times and zero re-subscription
attempts. ERROR_NO_MORE_ITEMS never appeared, so the channel was not drained.

Refs: vectordotdev#26117, vectordotdev#26115
@pos-ei-don
pos-ei-don requested a review from a team as a code owner August 15, 2026 05:40
@github-actions github-actions Bot added the domain: sources Anything related to the Vector's sources label Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41c043ba3a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +592 to +595
if code == ERROR_EVT_QUERY_RESULT_STALE
|| code == ERROR_EVT_QUERY_RESULT_INVALID_POSITION
|| code == ERROR_INVALID_OPERATION
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve a retry path after failed re-subscription

When this newly expanded recovery branch is entered and EvtSubscribe fails transiently, resubscribe_channel has already closed the old handle before attempting to create its replacement, but the failure branch returns Ok with that closed handle still stored. Subsequent speculative pulls therefore receive ERROR_INVALID_HANDLE, which does not match this condition, so the logged promise to “retry next cycle” is never fulfilled and the channel remains permanently inactive; because that error aborts pull_events_inner, it can also prevent other configured channels from being drained. Keep the old handle until replacement succeeds, or retain explicit state that retries subscription creation on later cycles.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in ce4ba16 — thanks, this is the more important of the three.

You're right that the promise in the log line was never kept. resubscribe_channel closed the
handle up front and, on a failing EvtSubscribe, returned through ? without ever assigning
channel_sub.subscription_handle, so the struct kept a closed handle. The next EvtNext then
returns ERROR_INVALID_HANDLE, which matches no branch, and the channel is dead for good.

This branch was unreachable before this PR, so the bug was latent — but this PR makes it live, which
means it belongs here rather than in a follow-up.

I took the first of your two suggestions: the old handle is now released only after the replacement
exists.

let old_handle = channel_sub.subscription_handle;
// ... build the replacement ...
.map_err(|e| WindowsEventLogError::CreateSubscriptionError { source: e })?;

// Replacement is in place — only now is the stale handle safe to release.
unsafe { let _ = EvtClose(old_handle); }
channel_sub.subscription_handle = new_handle;

That also gives the retry path for free without extra state: on failure the stale handle stays in
place, so the next pull reproduces the original error code and routes straight back into this
recovery branch.

One consequence worth naming explicitly: for the duration of a successful re-subscribe, two
subscriptions are briefly registered on the same signal event before the old one is closed. That
seemed clearly preferable to a window in which the channel has no valid handle at all, but say the
word if you'd rather have explicit retry state instead.

Comment on lines +1399 to +1401
events.extend(subscription.pull_events(100).unwrap_or_default());
if !events.is_empty() {
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify delivery after the handle becomes unusable

This regression test exits as soon as the first event is returned, but the failure being fixed can occur only after one or more successful EvtNext batches: pull_events_inner retains already accumulated events when a later EvtNext returns ERROR_INVALID_OPERATION. Consequently, the parent implementation can return the seeded event, swallow the subsequent error, and still make this test pass even though the subscription is now permanently silent. Require a second event written after the first pull, or otherwise assert that the invalid-operation recovery path actually ran and continued delivering events.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in ce4ba16.

You're right that the assertion was satisfiable by the broken behaviour: pull_events_inner keeps
what it has already accumulated when a later EvtNext fails, so an unpatched build can hand back
the seeded event, swallow the error that kills the handle, and pass.

The test now seeds a second event after the first pull has returned, and requires that one to
arrive as well:

seed("101", ...);
let mut events = pull_until_nonempty(&mut subscription).await;
assert!(!events.is_empty(), ...);

// Only a second event, written after the first pull completed, shows delivery survives.
seed("102", ...);
let later_events = pull_until_nonempty(&mut subscription).await;
assert!(!later_events.is_empty(), ...);

The seeding and polling are factored into two small helpers so the ordering stays readable, and the
timestamp assertions now run over both batches.

const ERROR_EVT_QUERY_RESULT_INVALID_POSITION: u32 = 0x4239; // 16953
const ERROR_INVALID_OPERATION: u32 = 4317; // 0x10DD
const ERROR_EVT_INVALID_QUERY: u32 = 15001; // 0x3A99
const ERROR_EVT_CHANNEL_NOT_FOUND: u32 = 15007; // 0x3A9F

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Continue skipping unsupported direct channels

For configurations that include a direct/analytic channel alongside valid channels, EvtSubscribe returns error 15009 (ERROR_EVT_SUBSCRIPTION_TO_DIRECT_CHANNEL). Before this correction, 15009 matched the misnamed constant and the existing subscription-error branch skipped that channel; after changing the constant to 15007 without adding a separate 15009 case, the error falls into the generic branch, closes every subscription already created, and prevents the entire source from starting. Preserve the prior behavior by naming 15009 separately and including it in the set of channel errors that are skipped.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and this one would have been a regression introduced by this PR. Fixed in ce4ba16.

Because the old ERROR_EVT_CHANNEL_NOT_FOUND happened to hold 15009, a direct/analytic channel was
skipped by accident. Correcting the constant to 15007 without naming 15009 separately would have
sent it into the generic branch, which tears down every subscription created so far — turning a
skipped channel into a source that refuses to start.

15009 now has its own constant and is part of the skip set:

const ERROR_EVT_SUBSCRIPTION_TO_DIRECT_CHANNEL: u32 = 15009; // 0x3AA1

if error_code == ERROR_EVT_CHANNEL_NOT_FOUND
    || error_code == ERROR_EVT_INVALID_QUERY
    || error_code == ERROR_EVT_SUBSCRIPTION_TO_DIRECT_CHANNEL

One small refinement to the suggestion: I added it only at the EvtSubscribe site. The other
skip-set, in the pre-flight channel check, is guarding EvtOpenChannelConfig, which does not return
15009 — opening the config of a direct channel succeeds. Adding it there would have been dead code.

@pos-ei-don

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

pos-ei-don pushed a commit to pos-ei-don/vector that referenced this pull request Aug 15, 2026
…y path, non-vacuous test

Three findings from the automated review on vectordotdev#26118, all verified against the code:

1. Correcting ERROR_EVT_CHANNEL_NOT_FOUND to 15007 removed the accidental skip of
   15009 (ERROR_EVT_SUBSCRIPTION_TO_DIRECT_CHANNEL), which previously matched the
   misnamed constant. A config listing a direct/analytic channel alongside valid ones
   would have gone from "channel skipped" to "source fails to start". 15009 is now a
   named constant of its own and part of the skip set.

2. resubscribe_channel closed the old handle before creating its replacement. On a
   transient EvtSubscribe failure the struct kept a closed handle, so the next EvtNext
   returned ERROR_INVALID_HANDLE — matching no recovery branch — and the logged promise
   to retry was never kept. The old handle is now released only after the replacement
   exists, so a failed attempt leaves the stale handle in place and the next pull
   reproduces the original error and routes back into recovery.

3. The regression test returned on the first event, but pull_events_inner retains
   already-accumulated events when a later EvtNext fails — so an unpatched build could
   deliver the seed, swallow the fatal error and still pass. A second event is now
   seeded after the first pull and must also arrive.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce4ba165a1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// stay silent forever despite this function logging that it will retry. Keeping
// the stale handle means the next pull reproduces the original error code and
// routes back into here — that *is* the retry path.
let old_handle = channel_sub.subscription_handle;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track retained stale handles as inactive

When replacement subscription creation keeps failing, this intentionally retains a non-null but unusable handle and the failure branch sets subscription_active_gauge to zero. However, channel_health_summary still counts every non-null handle as active, so the heartbeat in mod.rs reports “All channel subscriptions healthy” during exactly this outage and never emits its inactive-channel warning. Track subscription activity separately and use that state in the health summary rather than inferring it from handle nullness.

Useful? React with 👍 / 👎.

Comment on lines +1452 to +1455
"The subscription delivered the first seeded event but nothing afterwards. \
This is exactly the #26117 failure mode: the handle stops serving results \
and is never rebuilt, so the source looks healthy while being silent."
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assert receipt of the second seeded record

On a busy Application log, the first pull can exhaust its 100-event budget before reaching the failing EvtNext, leaving older records for this second pull; merely requiring later_events to be nonempty can therefore pass even when event 102 is never delivered and the subscription subsequently becomes silent. Fresh evidence in this revision is that it writes a second seed but does not identify that seed in the returned events. Assert an event with ID 102 and provider VectorTestFutureEventsSeed so the regression test proves post-pull delivery.

Useful? React with 👍 / 👎.

Comment on lines +599 to +601
if code == ERROR_EVT_QUERY_RESULT_STALE
|| code == ERROR_EVT_QUERY_RESULT_INVALID_POSITION
|| code == ERROR_INVALID_OPERATION

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve queued records when bookmark fallback is required

With the default read_existing_events = false, routing a stale or invalidated subscription into resubscribe_channel can lose records: if the strict bookmark subscription fails—most concretely after the channel is cleared—the fallback uses EvtSubscribeToFutureEvents, skipping every post-clear record written before the replacement subscription is created. Recovery should retry transient strict failures without discarding the bookmark, and when the bookmark is genuinely stale it should resume from the oldest record in the new log rather than jumping to the future.

Useful? React with 👍 / 👎.

// level, so the source went silent for good while still looking healthy.
if code == ERROR_EVT_QUERY_RESULT_STALE
|| code == ERROR_EVT_QUERY_RESULT_INVALID_POSITION
|| code == ERROR_INVALID_OPERATION

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advance the bookmark past filtered records before rebuilding

When a channel has already emitted a bookmarked event and subsequent records match ignore_event_ids, those filtered records are closed without advancing the bookmark. Once the following EvtNext returns ERROR_INVALID_OPERATION, this new branch rebuilds from the older bookmark, immediately reads and filters the same records again, and can enter a signal-driven re-subscribe loop with repeated warning logs and CPU usage until a non-filtered event arrives. Advance the bookmark for successfully consumed-but-filtered records before rebuilding the subscription.

Useful? React with 👍 / 👎.

…y path, non-vacuous test

Three findings from the automated review on vectordotdev#26118, all verified against the code:

1. Correcting ERROR_EVT_CHANNEL_NOT_FOUND to 15007 removed the accidental skip of
   15009 (ERROR_EVT_SUBSCRIPTION_TO_DIRECT_CHANNEL), which previously matched the
   misnamed constant. A config listing a direct/analytic channel alongside valid ones
   would have gone from "channel skipped" to "source fails to start". 15009 is now a
   named constant of its own and part of the skip set.

2. resubscribe_channel closed the old handle before creating its replacement. On a
   transient EvtSubscribe failure the struct kept a closed handle, so the next EvtNext
   returned ERROR_INVALID_HANDLE — matching no recovery branch — and the logged promise
   to retry was never kept. The old handle is now released only after the replacement
   exists, so a failed attempt leaves the stale handle in place and the next pull
   reproduces the original error and routes back into recovery.

3. The regression test returned on the first event, but pull_events_inner retains
   already-accumulated events when a later EvtNext fails — so an unpatched build could
   deliver the seed, swallow the fatal error and still pass. A second event is now
   seeded after the first pull and must also arrive.
@pos-ei-don
pos-ei-don force-pushed the fix/windows-event-log-error-codes branch from ce4ba16 to d34dfab Compare August 15, 2026 06:53
@pos-ei-don

Copy link
Copy Markdown
Author

It builds and the tests pass on Windows — retracting my "could not compile locally" caveat

I said in the PR description that I had no Windows toolchain and that CI here would be the first
build. That is no longer true, so here is the result rather than the disclaimer.

I ran the exact tree of this PR through a throwaway workflow_dispatch job in my fork, on
windows-latest, using this repo's own scripts/environment/bootstrap-windows-2025.ps1:

bootstrap-windows-2025.ps1                     success
cargo check -p vector --no-default-features \
  --features sources-windows_event_log --lib   success
cargo test  -p vector --no-default-features \
  --features sources-windows_event_log --lib \
  windows_event_log                            success

running 145 tests
test result: ok. 145 passed; 0 failed; 0 ignored; 307 filtered out; finished in 1.04s

The part that mattered to me — the new regression test is not silently skipped:

test sources::windows_event_log::subscription::tests::
     test_read_existing_events_false_only_receives_future_events ... ok

That test seeds the Application log via eventcreate and needs the privilege to do so. It runs and
passes on a stock windows-latest runner, so it should not be a source of flakiness or of silent
no-ops in your CI either.

Caveats, so this is not read as more than it is: this is windows-latest, not the
windows-2025-8core runner the real suite uses, and it is only the windows_event_log unit tests —
not make test, not the integration suite. It does establish that the patch compiles and that the
module's own tests are green.

The run also covers the three fixes from the automated review (direct channels, the re-subscription
retry path, and the strengthened test), not just the original commit.

…ive; pin the second seed in the regression test

Addresses R2-1 and R2-2 from the second automated review round.

Health summary read activity from handle nullness. Since a failed re-subscription now
deliberately retains the old handle so the next pull retries, a non-null handle no longer
implies a working one -- the heartbeat reported "All channel subscriptions healthy"
during exactly the outage it exists to report. ChannelSubscription tracks
subscription_active explicitly and the summary reads that.

The regression test asserted only that the second phase returned *something*. On a busy
Application log the first pull can exhaust its 100-event budget before reaching the
failing EvtNext, so backlog alone would satisfy it while the post-pull seed never
arrives. It now waits for event ID 102 from the seeding provider specifically, and the
failure message lists what did arrive.
@pos-ei-don

Copy link
Copy Markdown
Author

Field-tested the patch on real Windows hosts

I built this branch (feature-reduced: windows_event_log + a few sinks) and ran it as a service on two domain-joined Windows machines with read_existing_events = false (the failing condition), each alongside a parallel shipper as a reference.

The re-subscription fix works. EvtNext returns ERROR_INVALID_OPERATION on the fresh subscription exactly as expected, and the patched build rebuilds the handle every time — 21/21 and 26/26 successful re-subscriptions, 0 failures across the two hosts. On a client VM, 23 events flowed end-to-end through the rebuilt subscription over ~3.5 min, where the stock build delivered only via the 5 s speculative pull. The health-summary fix and the strengthened regression test (the two review points) are in the latest push.

One finding worth flagging. On a Windows Server, the now-reachable re-subscription surfaces a separate, pre-existing bookmark defect: the strict-bookmark EvtSubscribe fails with ERROR_NOT_FOUND (0x80070490) on every recovery cycle, falls back to EvtSubscribeToFutureEvents, and records in the gap are dropped — measured, with a controlled event-injection check (events present in the Application log, not delivered by Vector; a file-source line written at the same moment was delivered). CPU stays low, so this is data loss rather than a busy-loop. This is exactly the reviewer's "preserve queued records when bookmark fallback is required" point; I filed it as #26120, since it's a distinct correctness issue in the recovery path rather than the silence this PR fixes. Both are strict improvements over the silent-stall status quo.

Happy to test candidate builds against the reproducing environments (I have both a client and a server where this occurs consistently).

@pos-ei-don

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 8bac012eb7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: sources Anything related to the Vector's sources

Projects

None yet

1 participant