Skip to content

Commit daf1bfa

Browse files
committed
FEATURE-ci-remaining-native-captures: Prepare five bounded native observations
Prepare the registered three-job manual diagnostic workflow with independent fixed category allowances, durable prelaunch attempt charges and actual monotonic time accounting. Preserve original native traffic, suites, close deadlines and Linux terminal operand evaluation while collecting error-only Winsock, owning-worker PSK, nonblocking external-close and validated owned minidump receipts. Verified 39 offline contracts, 13 actual C++ objects with cached GNU/LLVM/private OpenSSL 3.5.9 and genuine Windows COFF compilation, Python syntax, changed C++ cpplint, actionlint and diff checks. Integrated the independent one-call Windows minidump proof byte for byte. Original native targets and workflow dispatches remain zero; caller readiness review and the single CI dispatch are pending. Never merge this diagnostic branch into v3.
1 parent b2ee4a2 commit daf1bfa

13 files changed

Lines changed: 1969 additions & 176 deletions

‎.github/workflows/v3-package-validation.yml‎

Lines changed: 104 additions & 168 deletions
Large diffs are not rendered by default.

‎examples/host_poll.hpp‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
#endif
3636

3737
#include <algorithm>
38+
#include <atomic>
39+
#include <cstdio>
3840
#include <cerrno>
3941
#include <chrono>
4042
#include <climits>
@@ -58,6 +60,43 @@ constexpr std::int16_t readable = POLLIN;
5860
constexpr std::int16_t writable = POLLOUT;
5961
#endif
6062

63+
#if defined(_WIN32) && defined(LIBHTTPSERVER_REMAINING_WAIT_CAPTURE)
64+
inline std::atomic<unsigned> stop_bits{0};
65+
inline void capture_stop_phase(unsigned bit) { stop_bits.fetch_or(bit); }
66+
inline void capture_stop_receipt(bool failed, bool running) {
67+
std::fprintf(stderr, "STOP_CAPTURE bits=%u failed=%d running=%d\n", stop_bits.load(), failed, running);
68+
}
69+
inline void capture_wait_error(const std::vector<slot>& fds, int saved_error) {
70+
LARGE_INTEGER ticks{};
71+
::QueryPerformanceCounter(&ticks);
72+
std::fprintf(stderr, "WSA_WAIT_ERROR code=%d stop_bits=%u ticks=%lld count=%zu\n",
73+
saved_error, stop_bits.load(), ticks.QuadPart, fds.size());
74+
for (const auto& fd : fds) {
75+
int type = 0, error = 0, length = sizeof(type);
76+
const int type_rc = ::getsockopt(fd.fd, SOL_SOCKET, SO_TYPE, reinterpret_cast<char*>(&type), &length);
77+
const int type_error = type_rc < 0 ? ::WSAGetLastError() : 0;
78+
length = sizeof(error);
79+
const int error_rc = ::getsockopt(fd.fd, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&error), &length);
80+
const int error_error = error_rc < 0 ? ::WSAGetLastError() : 0;
81+
sockaddr_storage local{}, peer{};
82+
length = sizeof(local);
83+
const int local_rc = ::getsockname(fd.fd, reinterpret_cast<sockaddr*>(&local), &length);
84+
const int local_error = local_rc < 0 ? ::WSAGetLastError() : 0;
85+
length = sizeof(peer);
86+
const int peer_rc = ::getpeername(fd.fd, reinterpret_cast<sockaddr*>(&peer), &length);
87+
const int peer_error = peer_rc < 0 ? ::WSAGetLastError() : 0;
88+
std::fprintf(stderr, "WSA_WAIT_SOCKET fd=%zu events=%d revents=%d type=%d type_rc=%d type_error=%d "
89+
"socket_error=%d error_rc=%d error_error=%d local_rc=%d local_error=%d local_family=%d "
90+
"peer_rc=%d peer_error=%d peer_family=%d\n", static_cast<std::size_t>(fd.fd), fd.events,
91+
fd.revents, type, type_rc, type_error, error, error_rc, error_error, local_rc, local_error,
92+
local.ss_family, peer_rc, peer_error, peer.ss_family);
93+
}
94+
}
95+
#else
96+
inline void capture_stop_phase(unsigned) { }
97+
inline void capture_stop_receipt(bool, bool) { }
98+
#endif
99+
61100
inline slot registration(const srv::socket_interest& interest) {
62101
#if defined(_WIN32)
63102
if (!interest.handle.valid || interest.handle.kind != srv::native_handle_kind::winsock_socket) {
@@ -99,7 +138,15 @@ inline std::vector<srv::readiness_event> wait(
99138
for (const auto& interest : interests) fds.push_back(registration(interest));
100139
#if defined(_WIN32)
101140
const auto ready = ::WSAPoll(fds.data(), static_cast<ULONG>(fds.size()), timeout(deadline));
102-
if (ready < 0 && ::WSAGetLastError() != WSAEINTR) throw std::runtime_error("WSAPoll failed");
141+
if (ready < 0) {
142+
const int saved_error = ::WSAGetLastError();
143+
if (saved_error != WSAEINTR) {
144+
#if defined(LIBHTTPSERVER_REMAINING_WAIT_CAPTURE)
145+
capture_wait_error(fds, saved_error);
146+
#endif
147+
throw std::runtime_error("WSAPoll failed");
148+
}
149+
}
103150
#else
104151
const auto ready = ::poll(fds.data(), static_cast<nfds_t>(fds.size()), timeout(deadline));
105152
if (ready < 0 && errno != EINTR) throw std::runtime_error("poll failed");

‎scripts/ci_remaining_native_captures.py‎

Lines changed: 780 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Scoped Windows owned-child minidump prototype; not a process discovery tool."""
2+
import ctypes as C
3+
from ctypes import wintypes as W
4+
import hashlib
5+
from pathlib import Path
6+
import struct
7+
import time
8+
9+
MAX_BYTES = 32 * 1024 * 1024
10+
11+
12+
def validate_dump(path, expected_pid, expected_created, expected_image):
13+
"""Validate payload, not the writer's exit status; return bounded metadata."""
14+
path = Path(path)
15+
size = path.stat().st_size
16+
if not 32 <= size <= MAX_BYTES:
17+
raise ValueError('dump size outside bound')
18+
data = path.read_bytes()
19+
def bounds(offset, length):
20+
if offset < 0 or length < 0 or offset > size or length > size-offset:
21+
raise ValueError('dump RVA outside file')
22+
def u32(offset):
23+
bounds(offset, 4)
24+
return struct.unpack_from('<I', data, offset)[0]
25+
def location(offset, required=False):
26+
length, rva = struct.unpack_from('<II', data, offset)
27+
bounds(rva, length)
28+
if required and (not length or not rva):
29+
raise ValueError('required empty location')
30+
return length, rva
31+
signature, version, count, directory, _, timestamp, flags = struct.unpack_from('<4sIIIIIQ', data)
32+
if signature != b'MDMP' or version & 0xffff != 42899 or not 1 <= count <= 1024:
33+
raise ValueError('invalid dump header')
34+
if flags & 2:
35+
raise ValueError('full memory dump prohibited')
36+
if directory < 32:
37+
raise ValueError('invalid directory RVA')
38+
bounds(directory, count * 12)
39+
streams = {}
40+
for index in range(count):
41+
kind, length, rva = struct.unpack_from('<III', data, directory + index*12)
42+
bounds(rva, length)
43+
if kind and kind in streams:
44+
raise ValueError('duplicate stream')
45+
if kind:
46+
streams[kind] = (length, rva)
47+
def required_stream(kind):
48+
length, rva = streams[kind]
49+
if length < 4 or rva < 32:
50+
raise ValueError('required stream empty')
51+
return length, rva
52+
length, rva = required_stream(3)
53+
threads = u32(rva)
54+
if not 1 <= threads <= 4096 or 4 + threads * 48 > length:
55+
raise ValueError('invalid thread list')
56+
thread_info = []
57+
for index in range(threads):
58+
row = rva + 4 + index*48
59+
tid = u32(row)
60+
stack_bytes, stack_rva = location(row + 32, True)
61+
context_bytes, context_rva = location(row + 40, True)
62+
if not tid:
63+
raise ValueError('empty thread id')
64+
thread_info.append(dict(id=tid, stack_bytes=stack_bytes, stack_rva=stack_rva,
65+
context_bytes=context_bytes, context_rva=context_rva))
66+
if len({item['id'] for item in thread_info}) != threads:
67+
raise ValueError('duplicate thread id')
68+
length, rva = required_stream(4)
69+
modules = u32(rva)
70+
if not 1 <= modules <= 4096 or 4 + modules * 108 > length:
71+
raise ValueError('invalid module list')
72+
module_info = []
73+
for index in range(modules):
74+
row = rva + 4 + index*108
75+
base, image_size, checksum, image_time, name_rva = struct.unpack_from('<QIIII', data, row)
76+
name_bytes = u32(name_rva)
77+
if not base or not image_size or not 0 < name_bytes <= 32768 or name_bytes % 2:
78+
raise ValueError('invalid module identity')
79+
bounds(name_rva+4, name_bytes)
80+
name = data[name_rva+4:name_rva+4+name_bytes].decode('utf-16-le')
81+
cv_length, cv_rva = location(row+76)
82+
location(row+84)
83+
info = dict(path=name, base=base, image_size=image_size, checksum=checksum, timestamp=image_time)
84+
if cv_length:
85+
cv = data[cv_rva:cv_rva+cv_length]
86+
info.update(codeview_bytes=cv_length, codeview_sha256=hashlib.sha256(cv).hexdigest())
87+
if cv[:4] == b'RSDS' and cv_length >= 24:
88+
info.update(pdb_guid=cv[4:20].hex(), pdb_age=u32(cv_rva+20),
89+
pdb_path=cv[24:].split(b'\0',1)[0].decode('utf-8','replace'))
90+
module_info.append(info)
91+
if expected_image.casefold() not in {item['path'].casefold() for item in module_info}:
92+
raise ValueError('owned executable missing from module list')
93+
misc_length, misc_rva = required_stream(15)
94+
if misc_length < 24 or u32(misc_rva) < 24:
95+
raise ValueError('missing process identity')
96+
misc_flags = u32(misc_rva+4)
97+
dumped_pid, dumped_created = u32(misc_rva+8), u32(misc_rva+12)
98+
if misc_flags & 3 != 3 or dumped_pid != expected_pid or dumped_created != expected_created:
99+
raise ValueError('dumped process identity mismatch')
100+
return dict(valid=True, signature='MDMP', size=size, sha256=hashlib.sha256(data).hexdigest(),
101+
version=version, flags=flags, dump_timestamp=timestamp,
102+
stream_types=sorted(streams), directory_rva=directory, stream_count=count,
103+
process_id=dumped_pid, process_created_unix= dumped_created,
104+
thread_count=threads, threads=thread_info, module_count=modules, modules=module_info)
105+
106+
107+
def windows_api():
108+
kernel = C.WinDLL('kernel32.dll', use_last_error=True, winmode=0x800)
109+
def bind(name, result, args):
110+
fn = getattr(kernel, name)
111+
fn.restype, fn.argtypes = result, args
112+
return fn
113+
bind('GetProcessId', W.DWORD, [W.HANDLE])
114+
bind('GetProcessTimes', W.BOOL, [W.HANDLE] + [C.POINTER(W.FILETIME)]*4)
115+
bind('QueryFullProcessImageNameW', W.BOOL, [W.HANDLE, W.DWORD, W.LPWSTR, C.POINTER(W.DWORD)])
116+
bind('WaitForSingleObject', W.DWORD, [W.HANDLE,W.DWORD])
117+
bind('TerminateProcess', W.BOOL, [W.HANDLE,W.UINT])
118+
bind('GetSystemDirectoryW', W.UINT, [W.LPWSTR,W.UINT])
119+
bind('GetModuleFileNameW', W.DWORD, [W.HMODULE,W.LPWSTR,W.DWORD])
120+
bind('CreateFileW', W.HANDLE, [W.LPCWSTR,W.DWORD,W.DWORD,C.c_void_p,W.DWORD,W.DWORD,W.HANDLE])
121+
bind('CloseHandle', W.BOOL, [W.HANDLE])
122+
bind('SetFilePointerEx', W.BOOL, [W.HANDLE,C.c_longlong,C.POINTER(C.c_longlong),W.DWORD])
123+
bind('WriteFile', W.BOOL, [W.HANDLE,C.c_void_p,W.DWORD,C.POINTER(W.DWORD),C.c_void_p])
124+
return kernel
125+
126+
127+
def process_identity(kernel, handle, expected_pid=None):
128+
pid = kernel.GetProcessId(handle)
129+
values = [W.FILETIME() for _ in range(4)]
130+
if not pid or not kernel.GetProcessTimes(handle, *(C.byref(v) for v in values)):
131+
raise C.WinError(C.get_last_error())
132+
created = values[0].dwLowDateTime | values[0].dwHighDateTime << 32
133+
image = C.create_unicode_buffer(32768)
134+
length = W.DWORD(len(image))
135+
if not kernel.QueryFullProcessImageNameW(handle, 0, image, C.byref(length)):
136+
raise C.WinError(C.get_last_error())
137+
if expected_pid is not None and pid != expected_pid:
138+
raise ValueError('handle PID mismatch')
139+
return dict(pid=pid, creation_filetime=created,
140+
created_unix=created//10_000_000-11_644_473_600, image=image.value)
141+
142+
143+
def capture_minidump(child, expected_identity, path, deadline, kernel):
144+
"""Use this Popen's retained handle only. Caller owns time/job retirement."""
145+
handle = W.HANDLE(int(child._handle))
146+
if process_identity(kernel, handle, child.pid) != expected_identity:
147+
raise ValueError('owned process identity changed')
148+
if kernel.WaitForSingleObject(handle, 0) != 258:
149+
raise ValueError('owned child already exited')
150+
system = C.create_unicode_buffer(32768)
151+
if not kernel.GetSystemDirectoryW(system,len(system)):
152+
raise C.WinError(C.get_last_error())
153+
dllpath = str(Path(system.value) / 'dbghelp.dll')
154+
dbghelp = C.WinDLL(dllpath, use_last_error=True, winmode=0x800)
155+
loaded = C.create_unicode_buffer(32768)
156+
if not kernel.GetModuleFileNameW(dbghelp._handle, loaded, len(loaded)):
157+
raise C.WinError(C.get_last_error())
158+
if loaded.value.casefold() != dllpath.casefold():
159+
raise ValueError('debug DLL path mismatch')
160+
class IO(C.Structure):
161+
_pack_ = 4
162+
_fields_ = [('handle',W.HANDLE),('offset',C.c_uint64),('buffer',C.c_void_p),('length',W.DWORD)]
163+
class InputPrefix(C.Structure):
164+
_pack_ = 4
165+
_fields_ = [('pid',W.DWORD),('process',W.HANDLE),('kind',W.DWORD),('io',IO)]
166+
# Only the documented prefix/union members actually accessed are modeled.
167+
assert C.sizeof(C.c_void_p) == 8 and InputPrefix.kind.offset == 12 and InputPrefix.io.offset == 16
168+
assert IO.offset.offset == 8 and IO.buffer.offset == 16 and IO.length.offset == 24
169+
CALLBACK = C.WINFUNCTYPE(W.BOOL,C.c_void_p,C.POINTER(InputPrefix),C.c_void_p)
170+
class CallbackInfo(C.Structure):
171+
_pack_ = 4
172+
_fields_ = [('callback',CALLBACK),('parameter',C.c_void_p)]
173+
# No native direct-write handle is supplied: unsupported callback I/O cannot
174+
# silently fall back to an uncapped disk writer.
175+
output = kernel.CreateFileW(str(path),0x40000000,0,None,1,0x80,None)
176+
if output == W.HANDLE(-1).value:
177+
raise C.WinError(C.get_last_error())
178+
state = dict(io_started=False, io_finished=False, writes=0, max_end=0, rejected=False)
179+
@CALLBACK
180+
def callback(_, pointer, out):
181+
try:
182+
request = pointer.contents
183+
status = C.cast(out,C.POINTER(C.c_int32))
184+
if request.kind == 11:
185+
state['io_started'] = True
186+
status[0] = 1 # S_FALSE requests all I/O through this callback.
187+
return True
188+
if request.kind == 6:
189+
cancel = C.cast(out,C.POINTER(W.BOOL))
190+
cancel[0], cancel[1] = True, time.monotonic() >= deadline
191+
return True
192+
if request.kind == 12:
193+
end = request.io.offset + request.io.length
194+
if (not state['io_started'] or end > MAX_BYTES or time.monotonic() >= deadline):
195+
state['rejected'] = True
196+
status[0] = -2147024784 # HRESULT_FROM_WIN32(ERROR_DISK_FULL)
197+
return False
198+
written = W.DWORD()
199+
if not kernel.SetFilePointerEx(output,request.io.offset,None,0):
200+
return False
201+
if not kernel.WriteFile(output,request.io.buffer,request.io.length,C.byref(written),None):
202+
return False
203+
if written.value != request.io.length:
204+
return False
205+
state['writes'] += 1
206+
state['max_end'] = max(state['max_end'],end)
207+
status[0] = 0
208+
return True
209+
if request.kind == 13:
210+
state['io_finished'] = True
211+
status[0] = 0
212+
return True
213+
if request.kind in (5,7,9,14):
214+
return False # no added memory/kernel dump/ignored read errors
215+
return True # preserve initialized default thread/module flags
216+
except BaseException as exc:
217+
state['callback_error'] = type(exc).__name__
218+
return False
219+
info = CallbackInfo(callback,None)
220+
writer = dbghelp.MiniDumpWriteDump
221+
writer.argtypes = [W.HANDLE,W.DWORD,W.HANDLE,W.DWORD,C.c_void_p,C.c_void_p,C.POINTER(CallbackInfo)]
222+
writer.restype = W.BOOL
223+
try:
224+
C.set_last_error(0)
225+
result = writer(handle, child.pid, None, 0x1000, None, None, C.byref(info))
226+
error = C.get_last_error() # immediate, before clocks/logs/other APIs
227+
finally:
228+
kernel.CloseHandle(output)
229+
receipt = dict(api='MiniDumpWriteDump', api_result=bool(result), get_last_error=error,
230+
dump_type=0x1000, full_memory=False, dll_path=loaded.value,
231+
dll_sha256=hashlib.sha256(Path(loaded.value).read_bytes()).hexdigest(),
232+
callback=state, max_bytes=MAX_BYTES, owned_identity=expected_identity)
233+
if result and state['io_started'] and state['io_finished'] and not state['rejected']:
234+
try:
235+
receipt['payload'] = validate_dump(path,child.pid,expected_identity['created_unix'],expected_identity['image'])
236+
receipt['status'] = 'CAPTURED'
237+
except (ValueError, KeyError, OSError, UnicodeError, struct.error) as exc:
238+
receipt['status'] = 'UNAVAILABLE'
239+
receipt['validation_error'] = str(exc)
240+
else:
241+
receipt['status'] = 'UNAVAILABLE'
242+
return receipt

0 commit comments

Comments
 (0)