Compare commits
1521
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4bcc6eae9 | ||
|
|
97b18caa1f | ||
|
|
6a2e552194 | ||
|
|
93672ae95e | ||
|
|
ae1d149545 | ||
|
|
33a3774938 | ||
|
|
dd80f589fa | ||
|
|
4a7f3253f6 | ||
|
|
dc70174af9 | ||
|
|
552f7b31b0 | ||
|
|
b1a272fa23 | ||
|
|
d5505c054f | ||
|
|
c52afe4f41 | ||
|
|
b7e0b1f12c | ||
|
|
c83b30aa0b | ||
|
|
6338a2aae1 | ||
|
|
358c3093dd | ||
|
|
fabf0c1663 | ||
|
|
4016e6b73c | ||
|
|
c43726c17d | ||
|
|
b31b772e66 | ||
|
|
46f2137f06 | ||
|
|
d33b67d87c | ||
|
|
85977d97e0 | ||
|
|
d4182c22cd | ||
|
|
e7adcbe139 | ||
|
|
dd1769ee4c | ||
|
|
cecdbbe1d7 | ||
|
|
4e38536a55 | ||
|
|
be0dff7b2c | ||
|
|
e194535b25 | ||
|
|
635e1726e6 | ||
|
|
55bb052406 | ||
|
|
0f2794cd51 | ||
|
|
a9bec81328 | ||
|
|
5b2e11337c | ||
|
|
285a242d2a | ||
|
|
b5fb6b0f4d | ||
|
|
8081f94ebe | ||
|
|
05d78c9960 | ||
|
|
8ab25c1389 | ||
|
|
7dab663b93 | ||
|
|
51621e6240 | ||
|
|
083495986e | ||
|
|
b5b522b732 | ||
|
|
c5711b5974 | ||
|
|
de6e2ada0c | ||
|
|
8811276ca8 | ||
|
|
33b4f0acc8 | ||
|
|
3def3f5af2 | ||
|
|
9382abb6e6 | ||
|
|
5152b17114 | ||
|
|
f84622966e | ||
|
|
ef6a5673bd | ||
|
|
05ea602a3a | ||
|
|
a4c7899ffb | ||
|
|
0878947e13 | ||
|
|
2460e810d6 | ||
|
|
0f03aa8b5c | ||
|
|
f1f113a5f2 | ||
|
|
0800fd61a7 | ||
|
|
5410c4c6c6 | ||
|
|
6c7f5b7032 | ||
|
|
8dc10bb54f | ||
|
|
ae4f524448 | ||
|
|
1651d1b870 | ||
|
|
4a5a8cf057 | ||
|
|
c5879042fb | ||
|
|
d636a03928 | ||
|
|
132e222415 | ||
|
|
791ff42547 | ||
|
|
665137314a | ||
|
|
0568d263e0 | ||
|
|
044ff034a6 | ||
|
|
ea67b491f9 | ||
|
|
321a2c3ed7 | ||
|
|
69ae8b57cb | ||
|
|
c1ad35e152 | ||
|
|
fb696d0d56 | ||
|
|
3aecc095ff | ||
|
|
da540627fc | ||
|
|
f15ca995b7 | ||
|
|
f50310afed | ||
|
|
20f506fc3c | ||
|
|
af776deed6 | ||
|
|
89a4630477 | ||
|
|
05ab3b8054 | ||
|
|
57227b331d | ||
|
|
2f4cded218 | ||
|
|
17f79b16cd | ||
|
|
c0a4beeec4 | ||
|
|
86f0ac7625 | ||
|
|
95f0e89567 | ||
|
|
8614ba5428 | ||
|
|
72ee8f2586 | ||
|
|
57bfadb04c | ||
|
|
38ab885609 | ||
|
|
f731ac340d | ||
|
|
8374c60b0e | ||
|
|
5a4ddca190 | ||
|
|
929df3b6cb | ||
|
|
8c0c17270e | ||
|
|
d20e112933 | ||
|
|
b8ccd515dd | ||
|
|
013ff93406 | ||
|
|
30648086b4 | ||
|
|
828b36f290 | ||
|
|
c5c4d0e588 | ||
|
|
48564e8ab3 | ||
|
|
b086750cf3 | ||
|
|
eb837206c3 | ||
|
|
61bdf4a6df | ||
|
|
e8aeaaf365 | ||
|
|
a9495de90f | ||
|
|
87166db832 | ||
|
|
a95245e272 | ||
|
|
299518e866 | ||
|
|
de907c697b | ||
|
|
268e8794ab | ||
|
|
d0d63c1d4b | ||
|
|
48a20cda8c | ||
|
|
c7356d2a5b | ||
|
|
5ee87c535b | ||
|
|
3f2cc2abb3 | ||
|
|
41ea2afaaa | ||
|
|
a9099a5ea7 | ||
|
|
b25766ae5a | ||
|
|
bb08b20e58 | ||
|
|
a85d2cc8c7 | ||
|
|
85af6f5b87 | ||
|
|
6916575380 | ||
|
|
d7fb8568b2 | ||
|
|
1529dfd04b | ||
|
|
8dd8dc96d7 | ||
|
|
9ac6e4a2f4 | ||
|
|
fca68080bc | ||
|
|
8a1c77793d | ||
|
|
871acd9383 | ||
|
|
09386ee392 | ||
|
|
3ae8d12434 | ||
|
|
ab6a10026e | ||
|
|
3f0c89a6a3 | ||
|
|
6eb865242b | ||
|
|
3df64b6744 | ||
|
|
dad127d770 | ||
|
|
2dbc45e63d | ||
|
|
ce80fbcd10 | ||
|
|
d6eed3e9c9 | ||
|
|
31e461dca2 | ||
|
|
e943879c50 | ||
|
|
4fd5ebf57a | ||
|
|
dae19c06d7 | ||
|
|
47e997df15 | ||
|
|
a0c8d5490c | ||
|
|
3eb7f8b0e6 | ||
|
|
7d67213847 | ||
|
|
4fbb825b9b | ||
|
|
e942190b9f | ||
|
|
1097c24d91 | ||
|
|
5dffdee8ff | ||
|
|
ff1f7a4bb8 | ||
|
|
314b77242f | ||
|
|
236dbd3e84 | ||
|
|
7e328b3908 | ||
|
|
38e2cc4336 | ||
|
|
1600f5710c | ||
|
|
eccf5f451c | ||
|
|
f5405cdb3d | ||
|
|
96166cd3a7 | ||
|
|
d5a1e4b962 | ||
|
|
c5d8307697 | ||
|
|
aa9ca53468 | ||
|
|
e0e97f1c7a | ||
|
|
236aeae828 | ||
|
|
d3e83c3c1a | ||
|
|
2c2d4f3d9d | ||
|
|
98d4f903a0 | ||
|
|
666210c3fb | ||
|
|
89cf180984 | ||
|
|
e18ecda41e | ||
|
|
8678ace89b | ||
|
|
5694f97f12 | ||
|
|
7ec46278af | ||
|
|
fdecfe1b78 | ||
|
|
d9661d0f59 | ||
|
|
401edd37b8 | ||
|
|
623344369f | ||
|
|
c8ecf89424 | ||
|
|
096c1ee2a8 | ||
|
|
6f5850b9ca | ||
|
|
9383a68f17 | ||
|
|
36af8d077f | ||
|
|
45d1b50cb4 | ||
|
|
b3f5c49c4d | ||
|
|
6f0bc4307f | ||
|
|
f7a80d0a3a | ||
|
|
36328814e6 | ||
|
|
41208833b6 | ||
|
|
68997853b2 | ||
|
|
857338a386 | ||
|
|
dc09332b2e | ||
|
|
efe421b162 | ||
|
|
bd4bd47e00 | ||
|
|
87b7c6f36c | ||
|
|
a4a4707c4a | ||
|
|
a442cea1b2 | ||
|
|
104d70d465 | ||
|
|
257df6c520 | ||
|
|
83ba4a03a6 | ||
|
|
a7b3d0fc51 | ||
|
|
eaed746934 | ||
|
|
e81138e3d1 | ||
|
|
870f1b5914 | ||
|
|
1954c36367 | ||
|
|
37c72cc2fb | ||
|
|
9f1b09aefe | ||
|
|
b71f992a45 | ||
|
|
e7a03f33fb | ||
|
|
90e33f568b | ||
|
|
482e1658b0 | ||
|
|
8d3cee4a56 | ||
|
|
8a9e421ea7 | ||
|
|
f031e510b8 | ||
|
|
3babc636b4 | ||
|
|
06058fad8e | ||
|
|
7d4a0e05fd | ||
|
|
01a84c6851 | ||
|
|
c136ff60c2 | ||
|
|
e5d3a1d584 | ||
|
|
4af2402e20 | ||
|
|
6194538963 | ||
|
|
41a4a2e37e | ||
|
|
7a2fc0112c | ||
|
|
59d1839d8d | ||
|
|
f1ffd21cda | ||
|
|
9166268102 | ||
|
|
1beb19a7f8 | ||
|
|
e49ccfeb64 | ||
|
|
2abdb229fd | ||
|
|
68d21fbffa | ||
|
|
5e9f7e01e0 | ||
|
|
fd1e3a7f77 | ||
|
|
100d208a9a | ||
|
|
a1c624a819 | ||
|
|
0330f30c53 | ||
|
|
2516545f9d | ||
|
|
ee20c8b9b7 | ||
|
|
495d399691 | ||
|
|
16c7d817cc | ||
|
|
304b48a289 | ||
|
|
e072624c8d | ||
|
|
68651795bf | ||
|
|
5dffa9917f | ||
|
|
43a749d8ab | ||
|
|
35fe0ecda1 | ||
|
|
5b80d5769d | ||
|
|
26348b35e8 | ||
|
|
f28f95a501 | ||
|
|
2f353bf35e | ||
|
|
aa27892c12 | ||
|
|
5e6313377b | ||
|
|
96f8e6db5b | ||
|
|
e9fa7ebb45 | ||
|
|
ea121ac4e7 | ||
|
|
2068774d58 | ||
|
|
e6e3006128 | ||
|
|
4d559dc08b | ||
|
|
53340db0e3 | ||
|
|
43992bb6b3 | ||
|
|
d9959e5176 | ||
|
|
b519536237 | ||
|
|
bf3d4f14e8 | ||
|
|
87e90b9462 | ||
|
|
776cec590e | ||
|
|
682ed68d2f | ||
|
|
2320aa4f71 | ||
|
|
fba07e893f | ||
|
|
5e9dad2e9e | ||
|
|
a6f41c0256 | ||
|
|
53383ec754 | ||
|
|
43f61f6f0b | ||
|
|
1ddd2a4a0b | ||
|
|
12db99531f | ||
|
|
63f393c615 | ||
|
|
3a4e1c6a7c | ||
|
|
5ad36ba57b | ||
|
|
92a301ae54 | ||
|
|
278efd574e | ||
|
|
d88d77c14d | ||
|
|
1596088ca0 | ||
|
|
8e4d28c1fc | ||
|
|
97f1eaa048 | ||
|
|
ffa5dece4f | ||
|
|
a1dcf28bf9 | ||
|
|
5fb6e72c43 | ||
|
|
cc7648bb33 | ||
|
|
92ce2d84e9 | ||
|
|
0a777baabb | ||
|
|
44a137e23b | ||
|
|
4d15c6d42a | ||
|
|
83ea839d0c | ||
|
|
4a40fb9d52 | ||
|
|
4ba21b3082 | ||
|
|
b2b34df2ec | ||
|
|
8040431a4a | ||
|
|
98cd5bf81c | ||
|
|
000273d451 | ||
|
|
d8fd73d917 | ||
|
|
71e1e5c747 | ||
|
|
7f89b7c734 | ||
|
|
8951af6b63 | ||
|
|
af2209ec96 | ||
|
|
eff6f8965a | ||
|
|
448fa39a0c | ||
|
|
99ab91d2f5 | ||
|
|
bc6ca78ce8 | ||
|
|
774dc10d84 | ||
|
|
e660f8f762 | ||
|
|
8cdb4190b6 | ||
|
|
a6f8156564 | ||
|
|
48ab63a9d3 | ||
|
|
b3e1356d16 | ||
|
|
3ef245fc0a | ||
|
|
f7ebf81061 | ||
|
|
3d2e98b02e | ||
|
|
1970f895da | ||
|
|
1da06164e2 | ||
|
|
94b5d44fe7 | ||
|
|
326bfd124e | ||
|
|
0340a59b15 | ||
|
|
f385d3dc5e | ||
|
|
83a671b72f | ||
|
|
ecd4e12274 | ||
|
|
7304647baa | ||
|
|
e857b9de75 | ||
|
|
a1c77268b8 | ||
|
|
6ac6dd1f6f | ||
|
|
02763bd4a1 | ||
|
|
4dd84c601b | ||
|
|
d2c6734bfb | ||
|
|
17dc0b26b4 | ||
|
|
42c6e2a62b | ||
|
|
9b0ca7ca2b | ||
|
|
aefca81444 | ||
|
|
ca7b23909b | ||
|
|
a5d8131d8d | ||
|
|
3e7ac47d18 | ||
|
|
826055d08b | ||
|
|
878f71e211 | ||
|
|
cf793ce6bb | ||
|
|
8eb2363177 | ||
|
|
4f9bfbbcf3 | ||
|
|
ff05e34d22 | ||
|
|
9c70858828 | ||
|
|
dd9aac79a2 | ||
|
|
15a2a7bb70 | ||
|
|
be3a2d2325 | ||
|
|
bc7063966d | ||
|
|
1600452147 | ||
|
|
68b922bf80 | ||
|
|
0a48cccd9a | ||
|
|
a65385bbb1 | ||
|
|
a90416f50c | ||
|
|
0d9dabcd23 | ||
|
|
4de7d7ec9d | ||
|
|
1559e0681b | ||
|
|
5553d968b6 | ||
|
|
abef146325 | ||
|
|
36368640f1 | ||
|
|
982315bacc | ||
|
|
b3d70ea92e | ||
|
|
fa14305349 | ||
|
|
08f3b892a5 | ||
|
|
d6c178a39b | ||
|
|
18cb661c91 | ||
|
|
2f43407ae4 | ||
|
|
19050cd4f2 | ||
|
|
ff16eec78d | ||
|
|
263d1348ec | ||
|
|
ce756ed6a9 | ||
|
|
19fd2d3042 | ||
|
|
d1dc3bb8f7 | ||
|
|
6d5b2bd176 | ||
|
|
6ee4aa99eb | ||
|
|
16a9bee8f3 | ||
|
|
8100ea728e | ||
|
|
6ec8950a85 | ||
|
|
cda411a57e | ||
|
|
45992a3dd3 | ||
|
|
8a7f732428 | ||
|
|
b24bee7a89 | ||
|
|
16f52c0136 | ||
|
|
93b174ecde | ||
|
|
8b982efa05 | ||
|
|
a5718c4ca9 | ||
|
|
5823d6f907 | ||
|
|
7810137eb0 | ||
|
|
b1a888ffb6 | ||
|
|
a5fea0c503 | ||
|
|
17caeb0a9c | ||
|
|
6677b3abfe | ||
|
|
613ea315b0 | ||
|
|
ed8d208454 | ||
|
|
a04cfe4441 | ||
|
|
1ddd75e134 | ||
|
|
9baa72759f | ||
|
|
5d74bcd865 | ||
|
|
128af68a89 | ||
|
|
67615cc330 | ||
|
|
bb8518ab67 | ||
|
|
2fd8a35230 | ||
|
|
b6ec030128 | ||
|
|
7084454e84 | ||
|
|
7699ebfcf6 | ||
|
|
0ba189863e | ||
|
|
e197f72722 | ||
|
|
530dd81a71 | ||
|
|
c1fe2a3cd0 | ||
|
|
45bc97c25b | ||
|
|
982ed0ca7d | ||
|
|
7780922353 | ||
|
|
46edb1beb5 | ||
|
|
722ca3e6f7 | ||
|
|
ef95ff54b9 | ||
|
|
a162cc6eca | ||
|
|
f494c20430 | ||
|
|
6df258cf13 | ||
|
|
81b9469302 | ||
|
|
4ff2f56aa8 | ||
|
|
841d4ecdde | ||
|
|
51611cbf5a | ||
|
|
9d6018cdbb | ||
|
|
95894273f1 | ||
|
|
43b14ece78 | ||
|
|
3203947698 | ||
|
|
ba1c4cf160 | ||
|
|
221f58c9d2 | ||
|
|
54fdd167c2 | ||
|
|
c96a63091c | ||
|
|
2b05140e74 | ||
|
|
faa75a7e1d | ||
|
|
26edd84bb9 | ||
|
|
19cece789e | ||
|
|
9cabb698e2 | ||
|
|
ba44c31f42 | ||
|
|
ed19adc1ba | ||
|
|
806632b67d | ||
|
|
73b1fc7080 | ||
|
|
60d2ba0fac | ||
|
|
468af7abbe | ||
|
|
fc5ad51ec3 | ||
|
|
6a82192b02 | ||
|
|
776345997f | ||
|
|
b318627022 | ||
|
|
0b10a38f41 | ||
|
|
383da15d95 | ||
|
|
2a4ccd8ca8 | ||
|
|
512a87502b | ||
|
|
27b99435c0 | ||
|
|
e45ddd2a7f | ||
|
|
94b9786d91 | ||
|
|
6abedd8177 | ||
|
|
cc9e4f4d85 | ||
|
|
7ed1a63b36 | ||
|
|
edc6b32fa7 | ||
|
|
d67991d08a | ||
|
|
61e013e762 | ||
|
|
774284b699 | ||
|
|
66e99066f9 | ||
|
|
793c2b251b | ||
|
|
f4cfe5ba30 | ||
|
|
3a6ef25ca6 | ||
|
|
7b8e0a95bb | ||
|
|
3dfedc8822 | ||
|
|
a541484df1 | ||
|
|
caccb4e56f | ||
|
|
8537ac1e6d | ||
|
|
4bffffb0ad | ||
|
|
6133257c24 | ||
|
|
8720cee7af | ||
|
|
fd183a43fe | ||
|
|
5360ee9ff1 | ||
|
|
9369429600 | ||
|
|
909761b0c5 | ||
|
|
638a2a2d6b | ||
|
|
aae145b82a | ||
|
|
5e0ed8b077 | ||
|
|
8551234cfb | ||
|
|
0b31f27177 | ||
|
|
04b76be1c8 | ||
|
|
ff08f05e9a | ||
|
|
4b221ef0d1 | ||
|
|
859f33b5e9 | ||
|
|
b03d101c87 | ||
|
|
309754244a | ||
|
|
b82e9cc98e | ||
|
|
264a13705f | ||
|
|
bb0fa13c8b | ||
|
|
2540909fda | ||
|
|
f76aacfba1 | ||
|
|
f9c150b7d4 | ||
|
|
7be5c8bd0b | ||
|
|
287b54b283 | ||
|
|
c932528217 | ||
|
|
26953fc1a4 | ||
|
|
01dd40fdd1 | ||
|
|
07b8c2b338 | ||
|
|
21ab92d2b4 | ||
|
|
35875a2c43 | ||
|
|
7d3f0852f7 | ||
|
|
f1da7e1837 | ||
|
|
75336387c5 | ||
|
|
858e27ec8c | ||
|
|
270eb0c3b0 | ||
|
|
19a3cc8934 | ||
|
|
f9ccca85bb | ||
|
|
41930a35c4 | ||
|
|
2c82ceaad7 | ||
|
|
3c5a9410ec | ||
|
|
25dda68ecf | ||
|
|
237ba93e1a | ||
|
|
6f9ddc88e2 | ||
|
|
f910d235fd | ||
|
|
0be56a133b | ||
|
|
0b8caaa810 | ||
|
|
e32e4be28b | ||
|
|
c137ad89d1 | ||
|
|
1c705e64ca | ||
|
|
269d09815b | ||
|
|
0931eeedad | ||
|
|
a77fc553e7 | ||
|
|
c656e3a96b | ||
|
|
8268c5112c | ||
|
|
401e65c132 | ||
|
|
69cb7664f3 | ||
|
|
38d24e7bbf | ||
|
|
0c2b96ee95 | ||
|
|
0a36dc5ac6 | ||
|
|
917a2a2b28 | ||
|
|
7ed8f90068 | ||
|
|
c0acb6b268 | ||
|
|
c9f8f17858 | ||
|
|
76f81bc14e | ||
|
|
9f1c1cf9e4 | ||
|
|
211f8f4c5d | ||
|
|
c368b77a00 | ||
|
|
ead884e6d3 | ||
|
|
067a6bd801 | ||
|
|
217a4bb27b | ||
|
|
93da173987 | ||
|
|
a53668ade8 | ||
|
|
07c9cf223c | ||
|
|
f351f289af | ||
|
|
22c8f9a69c | ||
|
|
1359247e3f | ||
|
|
c7b301b3a1 | ||
|
|
03059e3001 | ||
|
|
afa6af5012 | ||
|
|
aaf0617a1f | ||
|
|
b84561dba5 | ||
|
|
379e7731df | ||
|
|
4aa5513f25 | ||
|
|
10a4554c81 | ||
|
|
c84c328c90 | ||
|
|
4f5e46e9a9 | ||
|
|
2ac483f10f | ||
|
|
199eaa0429 | ||
|
|
154c76068c | ||
|
|
b3e7db7b26 | ||
|
|
206abcae10 | ||
|
|
3c74fc89fe | ||
|
|
684062c115 | ||
|
|
c6fdf52582 | ||
|
|
08e9b62e16 | ||
|
|
04d9d1f2b9 | ||
|
|
6e30fd474e | ||
|
|
8ff6694c5a | ||
|
|
d1bb143928 | ||
|
|
4228f37cf5 | ||
|
|
233a3f78d1 | ||
|
|
e4af3907c2 | ||
|
|
23fecc2c11 | ||
|
|
e3b09bd0b7 | ||
|
|
78ea57d183 | ||
|
|
0b285ec19b | ||
|
|
8b3b6fb5af | ||
|
|
317f362a7a | ||
|
|
a1ce486e89 | ||
|
|
37515df8c1 | ||
|
|
89efd10450 | ||
|
|
f6b5e5b42c | ||
|
|
d969b277e1 | ||
|
|
e91987d241 | ||
|
|
69bc182ee9 | ||
|
|
c0c282ca3a | ||
|
|
0219624c7d | ||
|
|
537814e7fd | ||
|
|
c31bdf74d8 | ||
|
|
5db51e64de | ||
|
|
a8c9bc6049 | ||
|
|
0aa63c98ee | ||
|
|
5290a2868d | ||
|
|
4f06b4bca9 | ||
|
|
f60390c99c | ||
|
|
529237dfc8 | ||
|
|
84e4667f5c | ||
|
|
81f36d15c8 | ||
|
|
977992e834 | ||
|
|
6f8baf82c7 | ||
|
|
00f8d8aeb0 | ||
|
|
7e502f9dd3 | ||
|
|
52dadf8b23 | ||
|
|
f1ff394f83 | ||
|
|
bc296af99f | ||
|
|
3daacc07b7 | ||
|
|
f2d6561509 | ||
|
|
a9cc6785a9 | ||
|
|
f606c65858 | ||
|
|
031b8b086b | ||
|
|
667cd1b302 | ||
|
|
3c2c8978df | ||
|
|
3bc3e86529 | ||
|
|
7374b59c03 | ||
|
|
759a576a8d | ||
|
|
0b6a882480 | ||
|
|
bd50140d26 | ||
|
|
b773656dad | ||
|
|
1ad00c12ad | ||
|
|
b86dcdd926 | ||
|
|
0ce248eb4a | ||
|
|
65f39b6bae | ||
|
|
fee528194b | ||
|
|
f624fd6a75 | ||
|
|
15ca1047ce | ||
|
|
9ee64d9a9f | ||
|
|
72f4258410 | ||
|
|
b4553db769 | ||
|
|
d5f5cf407a | ||
|
|
17772163cd | ||
|
|
7556695cc5 | ||
|
|
8b2b68d383 | ||
|
|
d0bf402824 | ||
|
|
0942bf3d24 | ||
|
|
b87050900c | ||
|
|
6adfe5cf13 | ||
|
|
0d6fd63e8f | ||
|
|
034df2939c | ||
|
|
1235f770eb | ||
|
|
adefef675c | ||
|
|
ce133a6acf | ||
|
|
f87ee63a1d | ||
|
|
9d48821af2 | ||
|
|
4c1512cb78 | ||
|
|
707938d503 | ||
|
|
d9abb1f025 | ||
|
|
6063d3eb2c | ||
|
|
27388c1137 | ||
|
|
bc78e0889a | ||
|
|
0f36248d1e | ||
|
|
87b971569e | ||
|
|
85172f3ac0 | ||
|
|
096c613666 | ||
|
|
95e59df5c7 | ||
|
|
fd4f092c9a | ||
|
|
9ef188b0d3 | ||
|
|
de60f8fb15 | ||
|
|
e23a7e27eb | ||
|
|
f3d18a57b5 | ||
|
|
299c1b86f8 | ||
|
|
4b4f48963e | ||
|
|
138f6071b0 | ||
|
|
fe7cd73646 | ||
|
|
cb95cf7d27 | ||
|
|
cfa53af3a3 | ||
|
|
71f5cc9c94 | ||
|
|
e371cc4dd9 | ||
|
|
ab37a06dad | ||
|
|
7fe90be7b2 | ||
|
|
d8109770a0 | ||
|
|
4118149dbc | ||
|
|
7b3fe48657 | ||
|
|
5286388ada | ||
|
|
3ff5ca7009 | ||
|
|
9d2ddcc496 | ||
|
|
debd3b45ef | ||
|
|
a03abcaa02 | ||
|
|
1f0c7483e0 | ||
|
|
3960e28638 | ||
|
|
d9eab97dee | ||
|
|
615bdda36e | ||
|
|
ce4ff084cc | ||
|
|
e05df14371 | ||
|
|
07d9c17dec | ||
|
|
0419c83ab0 | ||
|
|
b04b262174 | ||
|
|
59cd456386 | ||
|
|
ba44e243a9 | ||
|
|
83f8b0cbe2 | ||
|
|
656d64ae74 | ||
|
|
bc90e88e72 | ||
|
|
7d789f68d4 | ||
|
|
142278d651 | ||
|
|
7a4ce5d476 | ||
|
|
897da82b35 | ||
|
|
8a831a0ed5 | ||
|
|
5b07edcd98 | ||
|
|
4d2b59cb20 | ||
|
|
b7ca74d61d | ||
|
|
25f0e21fb9 | ||
|
|
b95fd63c46 | ||
|
|
73a5366f1c | ||
|
|
58c2b5644d | ||
|
|
939b458c33 | ||
|
|
45485f4fbc | ||
|
|
195b9c5afb | ||
|
|
48a6f03d78 | ||
|
|
143fd51791 | ||
|
|
4d18a7015d | ||
|
|
3c5e52a630 | ||
|
|
9be3c4af48 | ||
|
|
88760510da | ||
|
|
cdaad39cf2 | ||
|
|
214af09510 | ||
|
|
5f90557a36 | ||
|
|
d0755477f2 | ||
|
|
b5d4c2be8b | ||
|
|
62175e04a8 | ||
|
|
8fddf854de | ||
|
|
1515a37b8a | ||
|
|
b78fd7a8e6 | ||
|
|
bc5e6aa5a5 | ||
|
|
8f02e1ec2d | ||
|
|
84bf74e034 | ||
|
|
f11ab51ccc | ||
|
|
49cbfe1ae0 | ||
|
|
d73d62db48 | ||
|
|
2d79023c5e | ||
|
|
279d7da51c | ||
|
|
f537f302c6 | ||
|
|
5842855583 | ||
|
|
7e412d950a | ||
|
|
be11fbb8b6 | ||
|
|
096e721faa | ||
|
|
1431507ac5 | ||
|
|
30a15f6cfd | ||
|
|
005d136dc7 | ||
|
|
74035fc043 | ||
|
|
a6ae4df3ff | ||
|
|
0d45bdbad6 | ||
|
|
5a6eabf4fc | ||
|
|
f4ef949482 | ||
|
|
f71aff3e11 | ||
|
|
d6dc6234d5 | ||
|
|
6a38eaa4eb | ||
|
|
6e6260ab29 | ||
|
|
142cc1a7a1 | ||
|
|
4859d03f29 | ||
|
|
067c1cd496 | ||
|
|
2b55d9225a | ||
|
|
9e679462d4 | ||
|
|
b92727e66a | ||
|
|
0d3ffb8736 | ||
|
|
da85971969 | ||
|
|
ed1f4d8ea8 | ||
|
|
f941066934 | ||
|
|
61266986e5 | ||
|
|
4bece684da | ||
|
|
1ed110d47c | ||
|
|
b3b04b4d5a | ||
|
|
29e6c977f2 | ||
|
|
64c1019a52 | ||
|
|
aca5f806c1 | ||
|
|
823bae076f | ||
|
|
d6ea893f16 | ||
|
|
d83d58c874 | ||
|
|
1c82432de9 | ||
|
|
69b87ef3ed | ||
|
|
11b3ceaa9b | ||
|
|
8e0090b54b | ||
|
|
a09b67a70f | ||
|
|
848247bde6 | ||
|
|
50ea132c15 | ||
|
|
2ff51d0016 | ||
|
|
02ec6a50a5 | ||
|
|
44f6d8a5bc | ||
|
|
30a205340a | ||
|
|
dc1f3260e3 | ||
|
|
d582f2ea77 | ||
|
|
2dd0970295 | ||
|
|
65c6bbc03b | ||
|
|
6ba86bb1df | ||
|
|
5233513fcc | ||
|
|
8323be7272 | ||
|
|
e3b0eebc2b | ||
|
|
4d3b294ee9 | ||
|
|
a1f925c789 | ||
|
|
f1f5d33788 | ||
|
|
05b0ef50e4 | ||
|
|
5382b686e9 | ||
|
|
893e714422 | ||
|
|
3b42547740 | ||
|
|
b8e7b18937 | ||
|
|
3ba581770c | ||
|
|
482026e208 | ||
|
|
78f34a3617 | ||
|
|
4dc02d74b8 | ||
|
|
027a4b4d9e | ||
|
|
22460a1259 | ||
|
|
8d8fe8d6bf | ||
|
|
821f1b8a92 | ||
|
|
6d925fecd1 | ||
|
|
e3aa7839ae | ||
|
|
97a899c0c3 | ||
|
|
7b526ba681 | ||
|
|
f89c1975f7 | ||
|
|
8a22afc42d | ||
|
|
a6ab797659 | ||
|
|
eab9223f39 | ||
|
|
799a71d361 | ||
|
|
c7dadb0539 | ||
|
|
4389b511bf | ||
|
|
29e2afc3ce | ||
|
|
cb49b9d4a4 | ||
|
|
fb461cd56c | ||
|
|
7f0c080492 | ||
|
|
080c7a5fd5 | ||
|
|
0fe14699b0 | ||
|
|
3d48024fd8 | ||
|
|
f04637a257 | ||
|
|
3e96e429eb | ||
|
|
254029be63 | ||
|
|
b5063f435b | ||
|
|
0bac2566c9 | ||
|
|
8a96e5d1c7 | ||
|
|
2226f54a56 | ||
|
|
b4186dfae3 | ||
|
|
0132a984dd | ||
|
|
cb50e268a4 | ||
|
|
89c27b8fca | ||
|
|
4daa0e7f6b | ||
|
|
9e0cd3eddf | ||
|
|
5c2ba74e3e | ||
|
|
ae594e1e1b | ||
|
|
488d8a601f | ||
|
|
e9391c9790 | ||
|
|
b1510ab437 | ||
|
|
ce06fe242b | ||
|
|
0594be9513 | ||
|
|
2d12dfb975 | ||
|
|
4f27ce35a5 | ||
|
|
6fdba59885 | ||
|
|
2d255afa6c | ||
|
|
8691608924 | ||
|
|
fd80b35f8f | ||
|
|
7eaeb0e2f9 | ||
|
|
ae4fb89bef | ||
|
|
d730dee925 | ||
|
|
fcbec01c07 | ||
|
|
16a68652ae | ||
|
|
eca03747b3 | ||
|
|
b62ca4093e | ||
|
|
6c1b429e91 | ||
|
|
d72a2c092e | ||
|
|
90d6f78a9e | ||
|
|
f54aa17e55 | ||
|
|
803d5f6583 | ||
|
|
8c30c8e6ce | ||
|
|
d307492d22 | ||
|
|
b95d3628bf | ||
|
|
0a69864d10 | ||
|
|
9c782be562 | ||
|
|
bfc280310d | ||
|
|
c25ed7c243 | ||
|
|
138de8d446 | ||
|
|
90a588cfc1 | ||
|
|
7d4da3a54d | ||
|
|
a701580d54 | ||
|
|
bd105c630d | ||
|
|
6106fc1b5d | ||
|
|
905d30ceaf | ||
|
|
95e88ac66f | ||
|
|
9fdd2dc9fb | ||
|
|
d4a969dc38 | ||
|
|
cf4a209837 | ||
|
|
6c0aa98508 | ||
|
|
ddb373d445 | ||
|
|
cba3f4f75a | ||
|
|
3c6200f9c3 | ||
|
|
74bcf91407 | ||
|
|
271cfcd2f8 | ||
|
|
f12c7d072a | ||
|
|
9b24c1f012 | ||
|
|
7c050eb7ef | ||
|
|
ce3287ac94 | ||
|
|
054ade0dcd | ||
|
|
1efd8fb26c | ||
|
|
c73090759f | ||
|
|
0656f24a7c | ||
|
|
cb9c04ff61 | ||
|
|
33556fefea | ||
|
|
189ae43a7c | ||
|
|
a4edd34695 | ||
|
|
e28386c868 | ||
|
|
8151fc5a9c | ||
|
|
1c6d203754 | ||
|
|
fb7846780b | ||
|
|
3b0ae79527 | ||
|
|
6c777d4426 | ||
|
|
63406a9742 | ||
|
|
6f9723f8ce | ||
|
|
e3bd66f0b7 | ||
|
|
ed00f1ad9a | ||
|
|
1aface994f | ||
|
|
6429d4ca23 | ||
|
|
c7618ba62b | ||
|
|
20ffa3fc6c | ||
|
|
842083cdeb | ||
|
|
1e3633ee56 | ||
|
|
8552cf8316 | ||
|
|
abc2f8efb6 | ||
|
|
4d4fafc00b | ||
|
|
50fff66412 | ||
|
|
f81f6299b4 | ||
|
|
282bf99d4a | ||
|
|
2822fa53e3 | ||
|
|
6104086d5b | ||
|
|
34a95b7733 | ||
|
|
a8958865bc | ||
|
|
6390c5db6a | ||
|
|
ee37cf0a55 | ||
|
|
655733e272 | ||
|
|
89e2a2f22b | ||
|
|
9342795008 | ||
|
|
fe5cf910e1 | ||
|
|
09e3ba62ef | ||
|
|
fdb0381f5c | ||
|
|
81fdbf90e1 | ||
|
|
fec2cecdb3 | ||
|
|
5360453960 | ||
|
|
34e729d988 | ||
|
|
11135283e7 | ||
|
|
c8e1d120ba | ||
|
|
104b702653 | ||
|
|
be311cf144 | ||
|
|
9e10f7a357 | ||
|
|
6224c017da | ||
|
|
a14fdf6d83 | ||
|
|
98741e47d8 | ||
|
|
e3ea2ef78f | ||
|
|
b7f799ebe5 | ||
|
|
cde8c7b8e5 | ||
|
|
a9618169ab | ||
|
|
16d7fc4fb5 | ||
|
|
91fb996a09 | ||
|
|
f0d864bfd9 | ||
|
|
59d8065fae | ||
|
|
247952e9ed | ||
|
|
8bf09d9513 | ||
|
|
f66c4e928e | ||
|
|
cce633b5b0 | ||
|
|
beac8afba8 | ||
|
|
ecb589c670 | ||
|
|
ebb89e1bc3 | ||
|
|
70a4c669dc | ||
|
|
f1d80922c7 | ||
|
|
64adb799e9 | ||
|
|
6efe3000d0 | ||
|
|
3195167ff3 | ||
|
|
1cc60a3079 | ||
|
|
20c095f2f3 | ||
|
|
b52e5f5489 | ||
|
|
30d58a4bde | ||
|
|
1843a492bc | ||
|
|
463041a87f | ||
|
|
cb0b71aec1 | ||
|
|
f5270db52b | ||
|
|
92317bf383 | ||
|
|
29e15254a1 | ||
|
|
d5070a5df6 | ||
|
|
5fe66585b6 | ||
|
|
774fb66bb9 | ||
|
|
cfe4c24584 | ||
|
|
cec3e27aab | ||
|
|
5af4572ae1 | ||
|
|
618c68268c | ||
|
|
41b3503398 | ||
|
|
fd4c13f219 | ||
|
|
a9bc410aad | ||
|
|
7279967a29 | ||
|
|
8de9cfb1d2 | ||
|
|
2504dd0b40 | ||
|
|
1bb824fb8a | ||
|
|
32a8c1c48b | ||
|
|
958feb4e75 | ||
|
|
d3c5565c91 | ||
|
|
63c92840e2 | ||
|
|
32c89c7110 | ||
|
|
ae74b621c8 | ||
|
|
2c691f98f7 | ||
|
|
e8b913a351 | ||
|
|
65409f8c80 | ||
|
|
8b16f40094 | ||
|
|
e645ef309a | ||
|
|
e0598ccc22 | ||
|
|
bc41bb68b6 | ||
|
|
f378c7b28d | ||
|
|
04ce61e3ca | ||
|
|
8003413d97 | ||
|
|
f1cada4c02 | ||
|
|
01c2ebd7d8 | ||
|
|
1d84765f08 | ||
|
|
60942e542b | ||
|
|
1ce58cbeac | ||
|
|
956febe1d7 | ||
|
|
129fece0af | ||
|
|
55e270898b | ||
|
|
72b3068a8b | ||
|
|
c0aacb3287 | ||
|
|
6a6ad5c612 | ||
|
|
999fe909e4 | ||
|
|
a4dd4a10d5 | ||
|
|
1c5faaffc3 | ||
|
|
9b55e3c7cb | ||
|
|
6c0f9c9068 | ||
|
|
1b0034e1ba | ||
|
|
a7a7e4b5a1 | ||
|
|
d741eb7374 | ||
|
|
7f0845e50c | ||
|
|
739ee0802f | ||
|
|
9876677a5f | ||
|
|
1666538b0c | ||
|
|
70251fafab | ||
|
|
63e76d2cf5 | ||
|
|
1820128f5b | ||
|
|
efe879bfd9 | ||
|
|
aef6aae432 | ||
|
|
cf7a3a0939 | ||
|
|
d89a86c9dd | ||
|
|
7fa20ceb79 | ||
|
|
96a3d05c74 | ||
|
|
fc94fa0bfd | ||
|
|
b0acb285c1 | ||
|
|
a591469770 | ||
|
|
1f19720cb8 | ||
|
|
6a4fa8065a | ||
|
|
1e5fc2d335 | ||
|
|
3309bee7eb | ||
|
|
41ce04e2f0 | ||
|
|
795f930dd6 | ||
|
|
55328e18af | ||
|
|
8fefd510a6 | ||
|
|
dafd3a2adc | ||
|
|
0995a1eac3 | ||
|
|
40ae782719 | ||
|
|
43f9608fbf | ||
|
|
27aa76f914 | ||
|
|
9fa58b4f83 | ||
|
|
f6f3ca8da7 | ||
|
|
bc11c6ac00 | ||
|
|
52d8ec4e49 | ||
|
|
6bbd41f49f | ||
|
|
7f8869537b | ||
|
|
108266328c | ||
|
|
b9d36b42af | ||
|
|
59d51d0e80 | ||
|
|
13918d1cea | ||
|
|
00c4065771 | ||
|
|
61ed80e18c | ||
|
|
67368ebbac | ||
|
|
2165c8a1f8 | ||
|
|
7e7cabfb3a | ||
|
|
78d0e3f54f | ||
|
|
b31f974a44 | ||
|
|
1b695fe247 | ||
|
|
fdb11ed3ce | ||
|
|
d9f5da130e | ||
|
|
fc60ca295a | ||
|
|
45d5aaa7a1 | ||
|
|
fe75a493ef | ||
|
|
6803369a13 | ||
|
|
618f6ca073 | ||
|
|
c3ac2059f4 | ||
|
|
8850121984 | ||
|
|
6a6f8db208 | ||
|
|
cd6ca2388f | ||
|
|
34e6fecbd0 | ||
|
|
75124e0efa | ||
|
|
12c5945eed | ||
|
|
77e0a3c93e | ||
|
|
b2addca5ce | ||
|
|
d22bae19b4 | ||
|
|
d7ffa1031f | ||
|
|
b71c3040d4 | ||
|
|
4d19de7cce | ||
|
|
851c6723ff | ||
|
|
d1bfd1d16c | ||
|
|
023bd78e16 | ||
|
|
b163642b52 | ||
|
|
8a0654e57b | ||
|
|
e43bcec7cb | ||
|
|
383eb6d076 | ||
|
|
bb961dd394 | ||
|
|
4d87cab28a | ||
|
|
5bb27d1b61 | ||
|
|
9480f8029b | ||
|
|
a00db6229f | ||
|
|
ad577318d9 | ||
|
|
1cfafc43a4 | ||
|
|
2fe024fbbb | ||
|
|
98a6d3cce0 | ||
|
|
760398b96c | ||
|
|
492f6adc99 | ||
|
|
102da344d9 | ||
|
|
15ec41f2fd | ||
|
|
9f7ceb5815 | ||
|
|
13039933da | ||
|
|
ef8e8f2cb5 | ||
|
|
7649981cbc | ||
|
|
401193950b | ||
|
|
8c36d00fd2 | ||
|
|
8a7f4469dc | ||
|
|
77d6e8cfd0 | ||
|
|
55ad0e971a | ||
|
|
e6c0271e2c | ||
|
|
845bf2580d | ||
|
|
012bd650ea | ||
|
|
b8b6636644 | ||
|
|
9839f56f8c | ||
|
|
03d1afbd13 | ||
|
|
935bd704ae | ||
|
|
54a6e33c5e | ||
|
|
ab0b866c4c | ||
|
|
4b189891f7 | ||
|
|
5cf823da6a | ||
|
|
334991fbe5 | ||
|
|
52651cfa0d | ||
|
|
87f312f31b | ||
|
|
b19e6797c9 | ||
|
|
e229811e20 | ||
|
|
aefef51c58 | ||
|
|
45257bf37f | ||
|
|
d998b390f3 | ||
|
|
ec8c6e9eb1 | ||
|
|
8523448c3a | ||
|
|
5b6c96d3d8 | ||
|
|
48bdb85ff9 | ||
|
|
8b34889a19 | ||
|
|
e45f33e7b5 | ||
|
|
82ef6f282f | ||
|
|
0a75a53ed9 | ||
|
|
c39bdef344 | ||
|
|
030336eaeb | ||
|
|
d0ff7a81f7 | ||
|
|
65cdb5b468 | ||
|
|
cad10a33e0 | ||
|
|
93057c9eb7 | ||
|
|
1afb1010cc | ||
|
|
2a9b5748e9 | ||
|
|
20350338b8 | ||
|
|
e483027e3e | ||
|
|
b46927f40d | ||
|
|
371aa3ac19 | ||
|
|
ca15dbb88a | ||
|
|
2f35502978 | ||
|
|
809f009796 | ||
|
|
5c638a60db | ||
|
|
b0d246913d | ||
|
|
18df7cc43d | ||
|
|
9009153cc5 | ||
|
|
5dab86e840 | ||
|
|
f6005fd96d | ||
|
|
f1da7d7ee8 | ||
|
|
cd0329b301 | ||
|
|
8ccad3b56b | ||
|
|
e88f3ce46a | ||
|
|
f83033d3a1 | ||
|
|
5778c8aa0a | ||
|
|
6382c794ea | ||
|
|
4089944673 | ||
|
|
e10354a0ac | ||
|
|
df80c43a57 | ||
|
|
8e3c536cbb | ||
|
|
f0b7b22f92 | ||
|
|
fd82e7bf1b | ||
|
|
71dfc9d70d | ||
|
|
e5ca2e174c | ||
|
|
c75bfc618a | ||
|
|
07f63c3047 | ||
|
|
289b592598 | ||
|
|
99ebee37e7 | ||
|
|
549cc202fb | ||
|
|
d877e8881c | ||
|
|
e33d51a34a | ||
|
|
1899b7f8cf | ||
|
|
589fef2aae | ||
|
|
8718ee901c | ||
|
|
2c48a53c6a | ||
|
|
9e68928222 | ||
|
|
781d41e003 | ||
|
|
8f3d9edf8b | ||
|
|
998993997d | ||
|
|
5169ffd0b3 | ||
|
|
cc04f1c0fa | ||
|
|
76527de44f | ||
|
|
e65ceeafdc | ||
|
|
1916ba4270 | ||
|
|
bc623f2371 | ||
|
|
f521bb37f4 | ||
|
|
b84a6ab3b9 | ||
|
|
2e8885548b | ||
|
|
83df895532 | ||
|
|
f724703156 | ||
|
|
34133e4f6f | ||
|
|
8cbadd821f | ||
|
|
161422b2cd | ||
|
|
de83d7bbf4 | ||
|
|
3987295707 | ||
|
|
497792da73 | ||
|
|
3f195c058b | ||
|
|
2fce4641ec | ||
|
|
8642b04e6d | ||
|
|
93ec12226f | ||
|
|
28a5d0509f | ||
|
|
25a8b905fc | ||
|
|
ab8713d88f | ||
|
|
c5075354d0 | ||
|
|
1456f02c03 | ||
|
|
b48f9e7a19 | ||
|
|
750ccf638d | ||
|
|
bf94b240c8 | ||
|
|
7d6197b5eb | ||
|
|
d532fe062e | ||
|
|
f92077e601 | ||
|
|
e00d1f5212 | ||
|
|
669ad86e57 | ||
|
|
df39999624 | ||
|
|
316bacd8c8 | ||
|
|
f42f002eb1 | ||
|
|
fefc0ee003 | ||
|
|
0444a4bfd9 | ||
|
|
a7b556d9a8 | ||
|
|
2833c53644 | ||
|
|
a46091cb19 | ||
|
|
3a4143183d | ||
|
|
3fe697e1a9 | ||
|
|
d76eb067bb | ||
|
|
1402413ab9 | ||
|
|
a87ece370c | ||
|
|
012cbdcd49 | ||
|
|
e822f9fef7 | ||
|
|
5e5ed41c19 | ||
|
|
41c2a77fe6 | ||
|
|
a079381c0a | ||
|
|
45c282e0b7 | ||
|
|
137e30ec82 | ||
|
|
d20f31c165 | ||
|
|
5ae5fd066d | ||
|
|
143788cdcd | ||
|
|
aa97e053cd | ||
|
|
7853fb0fc6 | ||
|
|
557c98aeae | ||
|
|
fd53b443a0 | ||
|
|
26432be0e4 | ||
|
|
6734035734 | ||
|
|
068eddefd8 | ||
|
|
b0bf27de61 | ||
|
|
9fe4e834fe | ||
|
|
59e694bd83 | ||
|
|
5788d2557b | ||
|
|
cb08faae04 | ||
|
|
dda4a780e3 | ||
|
|
3dc5eb502d | ||
|
|
87d24b722b | ||
|
|
d09ed0316f | ||
|
|
fa76447f53 | ||
|
|
79b06846c6 | ||
|
|
000b28027c | ||
|
|
bdaff406be | ||
|
|
f83e27260d | ||
|
|
b6588d0594 | ||
|
|
03be46c339 | ||
|
|
5fee00c451 | ||
|
|
54a7522dd9 | ||
|
|
0ad80f3906 | ||
|
|
7c4e6f9972 | ||
|
|
913ae531ec | ||
|
|
ae9d3f0103 | ||
|
|
45834692a7 | ||
|
|
03b050949c | ||
|
|
bf4d14c46e | ||
|
|
1bc27aa2ca | ||
|
|
ec795b4764 | ||
|
|
ff394c7a6a | ||
|
|
21af2e0f35 | ||
|
|
d943c0ddca | ||
|
|
c2908a6c58 | ||
|
|
5587c60603 | ||
|
|
faaa5a835a | ||
|
|
2429688382 | ||
|
|
f0df3d8848 | ||
|
|
bbc9f750c3 | ||
|
|
e11eca93bb | ||
|
|
266406261a | ||
|
|
a38b1f1e0b | ||
|
|
6f8164d7cd | ||
|
|
a4d8637f45 | ||
|
|
ea3685d8f5 | ||
|
|
c7387c2879 | ||
|
|
198cf52791 | ||
|
|
9838384d43 | ||
|
|
56a06da35f | ||
|
|
96d99a183d | ||
|
|
7e69595372 | ||
|
|
c5eb17e59b | ||
|
|
aa09459c70 | ||
|
|
aea8ca7236 | ||
|
|
1634499d84 | ||
|
|
f3be00438b | ||
|
|
953e094c92 | ||
|
|
5cae3d095d | ||
|
|
c5a09c1413 | ||
|
|
789e5663f7 | ||
|
|
4278eb7851 | ||
|
|
ea1c17593b | ||
|
|
bf941bfd14 | ||
|
|
4b394b75e0 | ||
|
|
490b30b956 | ||
|
|
8925503d59 | ||
|
|
e7ae4025e2 | ||
|
|
42808450b5 | ||
|
|
9c1b8e3e03 | ||
|
|
e1e9d63925 | ||
|
|
8e2036b435 | ||
|
|
e56790649a | ||
|
|
16c64ca626 | ||
|
|
26b6eca150 | ||
|
|
81712e74cd | ||
|
|
e4de937953 | ||
|
|
8b525c06a9 | ||
|
|
fe182e4acc | ||
|
|
87b22966b7 | ||
|
|
fbe3e7a279 | ||
|
|
1f3f1d9a34 | ||
|
|
fe7bcb334c | ||
|
|
f79b9261c7 | ||
|
|
6689cf543b | ||
|
|
409d9f3ca9 | ||
|
|
de535e1713 | ||
|
|
8533dfc303 | ||
|
|
0f54f5ef66 | ||
|
|
c5292c9994 | ||
|
|
495972cfe6 | ||
|
|
b25acce11b | ||
|
|
080b14a51d | ||
|
|
5259c746d1 | ||
|
|
b116b20b11 | ||
|
|
c148351259 | ||
|
|
3956711c16 | ||
|
|
840bc9a6b0 | ||
|
|
27ed4c74ff | ||
|
|
962a2df3f8 | ||
|
|
d67e0b0c23 | ||
|
|
9ab9d01907 | ||
|
|
4abebdf35c | ||
|
|
ced9dc2340 | ||
|
|
8627e4f6a5 | ||
|
|
a056b2c262 | ||
|
|
017a326a13 | ||
|
|
1a9fe0a33b | ||
|
|
8e74619501 | ||
|
|
bb6ed1723f | ||
|
|
435ca204fe | ||
|
|
8640b45e99 | ||
|
|
4219d5ce1d | ||
|
|
85e87cc9f8 | ||
|
|
e577daf6ab | ||
|
|
0d6d1aeb19 | ||
|
|
a58f3516ab | ||
|
|
44103038c0 | ||
|
|
06ad255b95 | ||
|
|
9201d8c4f7 | ||
|
|
ceaf71caf8 | ||
|
|
817e3744ee | ||
|
|
68ead8c7dc | ||
|
|
59f900810e | ||
|
|
0d14613244 | ||
|
|
f5488adab0 | ||
|
|
fd93f39c78 | ||
|
|
1d92591780 | ||
|
|
234c21fb6a | ||
|
|
88801318bb | ||
|
|
9992228dcb | ||
|
|
4e32de8e65 | ||
|
|
a7d2333deb | ||
|
|
5e63bc2a42 | ||
|
|
b44ea19b4b | ||
|
|
d681d5abf0 | ||
|
|
7ea1fa9954 | ||
|
|
372b7d7f65 | ||
|
|
e5e61723d8 | ||
|
|
7fe77a49c9 | ||
|
|
9888820146 | ||
|
|
9a7091b595 | ||
|
|
1041849030 | ||
|
|
eff0446f8d | ||
|
|
e4852c82b4 | ||
|
|
99d52afd3f | ||
|
|
4abeafa20e | ||
|
|
70b7823993 | ||
|
|
46bf5b8541 | ||
|
|
d21af324ac | ||
|
|
f581fa423b | ||
|
|
bff354f9cc | ||
|
|
7593120bd9 | ||
|
|
869421cc05 | ||
|
|
6bd4690c3b | ||
|
|
018f8a20f3 | ||
|
|
13f379621a | ||
|
|
8bacf73e86 | ||
|
|
f327f7f434 | ||
|
|
5e7eb6290f | ||
|
|
bdf0bdf873 | ||
|
|
2f4830a3d0 | ||
|
|
f37ab0824d | ||
|
|
7e526521eb | ||
|
|
88b05c07d9 | ||
|
|
47bf724748 | ||
|
|
2949101ff8 | ||
|
|
c0365ebb38 | ||
|
|
9a13989c7e | ||
|
|
36900780f2 | ||
|
|
6660f59b3f | ||
|
|
f8c299c2e3 | ||
|
|
9296b36366 | ||
|
|
fe6b79127f | ||
|
|
dd81f7a665 | ||
|
|
ce30ec5fe6 | ||
|
|
c3fa6d4db1 | ||
|
|
235c2c4f4d | ||
|
|
9fb939ff24 | ||
|
|
e035f53ab6 | ||
|
|
8b0002e489 | ||
|
|
fcc4a2cf42 | ||
|
|
539f09004c | ||
|
|
c832a5d7a5 | ||
|
|
f09eb81381 | ||
|
|
59b3b9bd34 | ||
|
|
b3d3df5c57 | ||
|
|
e75bd460b8 | ||
|
|
77b43363fb | ||
|
|
199839b273 | ||
|
|
8dd28fb90a | ||
|
|
94a76e569e | ||
|
|
09c7e0c937 | ||
|
|
469f95c596 | ||
|
|
88c2d7a4e3 | ||
|
|
e8713e7a2c | ||
|
|
28c447370a | ||
|
|
f276fda411 | ||
|
|
c26b626c35 | ||
|
|
7bcdccc4d6 | ||
|
|
fc8a06547d | ||
|
|
b1a07df606 | ||
|
|
9d465bce16 | ||
|
|
eda65ece52 | ||
|
|
ae85423e39 | ||
|
|
f3bc5acacc | ||
|
|
556f6e0b9e | ||
|
|
270b9fcf80 | ||
|
|
288b8091ce | ||
|
|
46f6c03147 | ||
|
|
87dd27d022 | ||
|
|
2eade0a7c0 | ||
|
|
0e17756e19 | ||
|
|
5b5221189c | ||
|
|
677f79881c | ||
|
|
3ffacbfe37 | ||
|
|
c3cf86b9e0 | ||
|
|
4a785c2598 | ||
|
|
b7d74d8f87 | ||
|
|
db0d7a1f47 | ||
|
|
9844fdefcb | ||
|
|
a353797edb | ||
|
|
b83a28becc | ||
|
|
a3f004772a | ||
|
|
702389d8d9 | ||
|
|
88004f11ca | ||
|
|
1f6f815917 | ||
|
|
2a7266e17f | ||
|
|
e137778ba2 | ||
|
|
496a3c788e | ||
|
|
772c0728e1 | ||
|
|
b23d5e6047 | ||
|
|
4695fe4371 | ||
|
|
28c951ed9e | ||
|
|
2cc5a6df91 | ||
|
|
10d6ddee38 | ||
|
|
4314dff390 | ||
|
|
c311bb7a1f | ||
|
|
b387c4b127 | ||
|
|
6d6afb4faa | ||
|
|
e996e09f6e | ||
|
|
8d035a065a | ||
|
|
0df339c384 | ||
|
|
c341a9b6cc | ||
|
|
897cc007e1 | ||
|
|
79e37f64f8 | ||
|
|
4d729a2ff0 | ||
|
|
4f96926f6b | ||
|
|
c1da55ad87 | ||
|
|
f7b064a11a | ||
|
|
41f0aa2439 | ||
|
|
1c1b2b2026 | ||
|
|
488577ac0e | ||
|
|
4d40b6f5f7 | ||
|
|
2d8384830d | ||
|
|
0c8e9eef3c | ||
|
|
20b4975d57 | ||
|
|
0f42f92feb | ||
|
|
0b2dac3c5b | ||
|
|
5df6d0c8f3 |
No files matched your search
@@ -77,6 +77,8 @@ callgrind.out.*
|
||||
tests/*.log
|
||||
tests/*.trs
|
||||
tests/logs/
|
||||
/db_a/
|
||||
/db_b/
|
||||
|
||||
# Mass update logs
|
||||
logs/
|
||||
|
||||
@@ -843,7 +843,6 @@ cmake .. && make -j4
|
||||
| `ptt_hotkey_manager.h/cpp` | Глобальный PTT: физические клавиши Windows/X11, системный портал Wayland |
|
||||
| `ptt_key_chord.h` | Хранение физических сочетаний с отдельными левыми/правыми модификаторами и запись одновременного нажатия |
|
||||
| `ptt_portal.h/cpp` | D-Bus-сессия GlobalShortcuts для Wayland, настройка сочетаний и обработка нажатий/отпусканий |
|
||||
| `media_blocks.h/cpp` | Чтение/сборка фрагментированных медиафайлов |
|
||||
| `imageviewer_window.h/cpp` | Окно просмотра изображений |
|
||||
| `videoplayer_engine.h/cpp` | Движок декодирования видео |
|
||||
| `videoplayer_window.h/cpp` | Окно просмотра видео |
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ echo "Building utun.exe..."
|
||||
cd src
|
||||
EXE_NEEDS_REBUILD=0
|
||||
|
||||
CORE_SOURCES="utun.c utun_instance.c config_parser.c config_updater.c routing_layer/route_lib.c topo_bgp.c routing_layer/routing.c tun_if.c tun_route.c transport_layer/etcp.c transport_layer/etcp_padding.c transport_layer/reality_record.c transport_layer/reality_io.c transport_layer/etcp_session.c transport_layer/etcp_connections.c transport_layer/etcp_loadbalancer.c transport_layer/secure_channel.c transport_layer/crc32.c transport_layer/pkt_normalizer.c transport_layer/etcp_api.c tun_windows.c"
|
||||
CORE_SOURCES="utun.c utun_instance.c config_parser.c config_error.c config_updater.c routing_layer/route_lib.c topo_bgp.c routing_layer/routing.c tun_if.c tun_route.c transport_layer/etcp.c transport_layer/etcp_padding.c transport_layer/reality_record.c transport_layer/reality_io.c transport_layer/etcp_session.c transport_layer/etcp_connections.c transport_layer/etcp_loadbalancer.c transport_layer/secure_channel.c transport_layer/crc32.c transport_layer/pkt_normalizer.c transport_layer/etcp_api.c tun_windows.c"
|
||||
|
||||
for src in $CORE_SOURCES; do
|
||||
if [ -f "$src" ]; then
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,473 @@
|
||||
BLE GATT → ETCP (chatgui desktop + Android)
|
||||
|
||||
Текущее состояние (29.10.2026):
|
||||
Write Without Response/Notify, HIGH и PHY2M включены. BLE version3 добавляет
|
||||
16-bit ID каждого DATA GATT-value, накопительный ACK по отдельной характеристике
|
||||
не чаще15ms и окно12500байт на направление (фиксированные2Мбит/с ×50ms).
|
||||
Локальный completion не возвращает кредит. Формат и таймауты — doc/ble_transport.txt.
|
||||
Предыдущие исправления готовности BlueZ, старого ManufacturerData, incoming HIGH
|
||||
и потолка DATA RTO сохранены. Ограничение очереди само по себе не считается
|
||||
подтверждённым исправлением всей ETCP-нагрузки; аппаратные результаты ниже.
|
||||
|
||||
Общий код: tools/cgathui_common/ble_etcp.c/h.
|
||||
Платформенные backend остаются в lib/ble и android_ble.c; Qt не участвует в протоколе.
|
||||
UTUN binary этот модуль не собирает. При активном UTUN-сервисе общего instance
|
||||
BLE-драйвер не передаёт ETCP: CHAT и UTUN используют общий conn/normalizer,
|
||||
поэтому разделить их уже упакованные пакеты внутри линка нельзя.
|
||||
Обнаружение и объявления при этом остаются доступны.
|
||||
|
||||
Владелец в потоке uasync:
|
||||
ble_etcp_create(ua, instance, &config)
|
||||
ble_start(ble_etcp_radio(driver))
|
||||
ble_advertise / ble_set_incoming — через заимствованный radio
|
||||
ble_etcp_neighbors(driver, output, capacity) — копия свежих соседей
|
||||
ble_etcp_connect(driver, node_id) — явное подключение к обнаруженному соседу
|
||||
ble_etcp_destroy(driver) — обязательно до instance/uasync, вне callbacks
|
||||
|
||||
instance=NULL оставляет сырой BLE и discovery для аппаратных проб; штатные
|
||||
frontend передают instance. config.callbacks — наблюдатели; received/writable
|
||||
штатного ETCP принадлежат драйверу. Сырые TX/RX нельзя читать параллельно ETCP.
|
||||
|
||||
Таблица в управляющей структуре драйвера, не в instance и не в UI:
|
||||
64 записи {node_id, UTF-8 name, endpoint, RSSI, seen_us}; срок свежести 30 секунд.
|
||||
Объявление обновляет адрес и RSSI. По истечении срока запись удаляется.
|
||||
Desktop формирует видимый список из snapshot общего драйвера. Android получает
|
||||
тот же C API через android_ble_driver(). node_id/name в эфире не аутентифицированы.
|
||||
|
||||
Автоматическое подключение проверяется раз в секунду и при появлении соседа:
|
||||
- только работающий CHAT-сервис, полная разрешённая запись мембера source=0/deleted=0;
|
||||
- нет живого BGP-пути в конкретной группе, включая маршрут через посредника;
|
||||
- группа получает TOPO_PEER_REQUEST и владеет NCD уже в CONNECTING;
|
||||
- GATT прикрепляется к тому же ETCP_CONN, которым владеет NCD;
|
||||
- один GATT к обнаруженному node_id разделяется запросами нескольких групп;
|
||||
- запрос снимается на READY, удалении группы/членства либо через 20 секунд;
|
||||
готовую сессию продолжает держать сама группа;
|
||||
- повтор GATT ограничен интервалом 5..6 секунд; появление объявления само по себе
|
||||
не создаёт подключения к постороннему узлу.
|
||||
|
||||
Каждый GATT-канал предоставляет packet_link_driver: ctx/ua/name, bind/get_state,
|
||||
send/set_rx_enabled/stop/destroy. Контракт — doc/packet_link.txt.
|
||||
max_packet=1500 — размер непрозрачного пакета BLE, включая заголовки ETCP.
|
||||
Более длинное пользовательское сообщение дробит нормализатор ETCP; получившиеся
|
||||
пакеты BLE транспорт дополнительно фрагментирует под фактический ATT MTU.
|
||||
TX limit=1 включает пакет в процессе фрагментации. У адаптера нет второй TX-очереди:
|
||||
send сразу передаёт ll_entry в ble_tx_put. Готовность читается через ble_tx_ready;
|
||||
после завершения целого пакета writable будит ETCP. При отказе владение не меняется.
|
||||
Дополнительно ble_tx_ready учитывает ACK-кредит: после локальной отправки целого
|
||||
пакета writable выдаётся, только если есть место в удалённом окне. При полном
|
||||
окне ACK освобождает кредит и будит отправителя; INIT/keepalive проходят тот же лимит.
|
||||
RX использует ll_queue общего транспорта и включается packet_link потребителем.
|
||||
DATA передаётся через Write Without Response (client → server) и Notification
|
||||
(server → client). BLE send завершает только локальную отправку; доставку и повторы
|
||||
обеспечивает ETCP. INVITE остаётся отдельным Write Request/Indication каналом.
|
||||
BLE ACK подтверждает приём GATT-value драйвером, не доставку целого ETCP-пакета.
|
||||
|
||||
После BLE HELLO/ACCEPT первым целым кадром каждой стороны идёт 40 байт:
|
||||
'V' 'C' 'E' 'T' 01 00 00 00 | 32 байта X25519 public key
|
||||
Драйвер сверяет производный node_id с HELLO и ключом уже существующего ETCP_CONN,
|
||||
затем прикрепляет динамический линк. Это ещё не доказательство владения ключом:
|
||||
его обеспечивает штатный зашифрованный INIT и handshake эпох ETCP. Следующие
|
||||
кадры — неизменённые зашифрованные ETCP-пакеты, без IP-заголовков/добавочного framing.
|
||||
До завершения обмена ключами действует дедлайн 20 секунд.
|
||||
|
||||
Диагностика линка в обеих GUI использует общий etcp_link_get_snapshot: подпись BLE,
|
||||
физическое направление из ble_get_outgoing и фактические link_state/link_status
|
||||
ETCP. GATT READY сам по себе не включает зелёный индикатор. Адреса IP, порты и имя
|
||||
сетевого сокета для BLE отсутствуют. До прикрепления к ETCP попытка GATT не входит
|
||||
в список ETCP-линков; протокольный is_server не подменяется направлением GATT.
|
||||
|
||||
BLE callbacks только обновляют состояние/будят отложенную обработку. Закрытие
|
||||
GATT делается вне этих callbacks; packet_link откладывает destroy. При выключении
|
||||
frontend закрывает свои ETCP-линки, отсоединяет ctx от manager и уничтожает радио;
|
||||
поздний destroy ctx не обращается к уже уничтоженному manager. Ключи не логируются.
|
||||
Категория диагностики discovery; для handshake/маршрутов — etcp, connection, bgp, ncd.
|
||||
|
||||
Проверки:
|
||||
- tools/chatgui/build/test_ble_etcp: настоящий BLE common protocol + fake async GATT,
|
||||
encrypted ETCP handshake, MTU23, 1500-byte duplex, потеря DATA после успешного
|
||||
локального send и восстановление ETCP, TX busy/ownership/resume,
|
||||
несовпадающий public key, teardown, реальные SQLite/NCD/BGP и READY двух групп.
|
||||
- test_desktop_ble: UI policy и discovery через общий snapshot.
|
||||
- lib/ble/tests/test_ble_transport.c: MTU23/247/517, пропущенные фрагменты,
|
||||
bounded queues, независимые соединения и stop.
|
||||
- Android: ./gradlew assembleDebug assembleDebugAndroidTest.
|
||||
|
||||
Ручная аппаратная проверка (один процесс VibeChat/probe на BLE-адаптер):
|
||||
adb -s SERIAL shell am instrument -w -e etcp true -e seconds 180 \
|
||||
com.utun.chat.test/com.utun.chat.BlePeerProbe
|
||||
tools/chatgui/build/ble_etcp_probe 150
|
||||
Обратное направление: instrumentation -e initiator true, desktop ... 150 listen.
|
||||
ETCP-probe объявляет тестовое имя; desktop подключается только к нему.
|
||||
Временно включается ETCP_ID_BLE_PROBE echo, выключенный в обычном приложении.
|
||||
32 пакета по 1500 байт сверяются побайтно; успех — RESULT ... verified=32 failed=0.
|
||||
Instrumentation восстанавливает Bluetooth-политику в finally. PHY2M и HIGH priority
|
||||
запрашивает native backend; фактические параметры/результат запроса смотреть в логах.
|
||||
|
||||
Нагрузочная проба: ble_etcp_probe seconds [connect|listen] [data|invites] [packets] [window].
|
||||
По умолчанию packets=32/window=1. Для заполнения канала: например,
|
||||
tools/chatgui/build/ble_etcp_probe 90 listen data 4096 16
|
||||
и Android с -e etcp true -e initiator true -e seconds 100.
|
||||
Обратная роль: desktop connect и Android без initiator. В полёте не более window
|
||||
запросов (1..64); каждый из packets (1..1000000) сверяется побайтно.
|
||||
THROUGHPUT считает verified_bytes_each_direction и aggregate_bytes_per_second
|
||||
за время от первого send до последнего проверенного echo, без GATT/ETCP handshake.
|
||||
Это двунаправленный echo goodput, не скорость PHY. Неполный прогон имеет ненулевой exit.
|
||||
При неполном прогоне THROUGHPUT complete=0 учитывает всё окно до остановки,
|
||||
включая простой после последнего echo; RESULT failed=1 и ERROR содержат target/sent/verified.
|
||||
До первого send разрыв/смена адреса возвращает пробу к discovery; разрыв во время
|
||||
передачи завершает её с ошибкой. UTUN_TEST_DEBUG=1 включает ETCP trace/connection debug
|
||||
на desktop для подробного разбора ACK/RTO без изменения штатных фильтров приложения.
|
||||
Перед пробой выключить BLE обычного desktop GUI: два сервиса с одинаковыми UUID
|
||||
на одном адаптере делают GATT discovery неоднозначным.
|
||||
|
||||
Проверено 2026-10-29:
|
||||
- desktop CMake и Android arm64-v8a/x86_64 APK собираются;
|
||||
- ./check.sh: 230/230 + proxy/burst/load; выбранные GUI/ETCP/BLE: 6/6;
|
||||
- test_ble_etcp под ASAN/UBSAN с detect_leaks=1: без ошибок;
|
||||
- Android (RZ8R72MG64B) инициирует GATT к Linux: 32×1500 байт echo совпали,
|
||||
PHY2M, MTU517, фактический connection interval=12 (15 ms);
|
||||
- Linux инициирует GATT к тому же Android: несколько ETCP-пакетов передаются,
|
||||
затем HCI Connection Timeout (0x08). Сырой ble_peer_probe без ETCP также
|
||||
воспроизводит HCI 0x08 (подтверждено btmon). Этот сбой нижнего BLE-стека
|
||||
не устранён данным драйвером; обе аппаратные роли нельзя считать проверенными.
|
||||
|
||||
Приглашения через Bluetooth
|
||||
---------------------------
|
||||
Общий владелец регистрирует заимствованный chat_local_provider в instance;
|
||||
сам список соседей остаётся в ble_etcp. chat_local объединяет LAN и BT по node_id.
|
||||
При отправке выбирается доступный LAN endpoint, иначе BLE; при синхронном отказе
|
||||
LAN допустим BLE. После принятой отправки транспорт сам выдаёт один конечный
|
||||
статус: асинхронный таймаут не запускает рассылку по другому транспорту.
|
||||
Явная отправка приглашения создаёт GATT при необходимости, без проверки членства
|
||||
или BGP-пути. При активном UTUN приглашения доступны без добавления BLE в ETCP.
|
||||
|
||||
Характеристика f61a9023-4abc-45ac-98e1-761df4c13811: Write + Indicate.
|
||||
Клиент пишет запросы/ответы в неё; сервер адресует indications одному клиенту.
|
||||
DATA и INVITE имеют отдельные ll_queue и сборщики фрагментов, но одну незавершённую
|
||||
GATT-операцию на соединение. Планировщик чередует фрагменты, HELLO имеет приоритет.
|
||||
DATA TX использует tx_limit, INVITE TX допускает один целый пакет; RX ограничены
|
||||
64 пакетами на канал. Необходимы подписки на обе характеристики.
|
||||
|
||||
После сборки INVITE: version(1)=1, kind(1)=1 запрос/2 ответ,
|
||||
epoch(8 BE), request_id(8 BE), status(1), payload.
|
||||
Эпоха случайная ненулевая на экземпляр драйвера. Запрос содержит готовую join-ссылку
|
||||
1..1100 байт (тот же предел, что LAN), status=0. Ответ без payload: status=0 FAILED,
|
||||
1 RECEIVED, 2 FORBIDDEN. RECEIVED означает доставку в обработчик приложения,
|
||||
не вступление в группу. ATT completion сам по себе доставку приложению не подтверждает.
|
||||
Ожидающих отправок до 8, дедлайн 30 с, до 3 попыток с интервалом 8 с.
|
||||
Разрыв соединения/остановка завершают pending с FAILED. Повторы принятого запроса
|
||||
подавляются 60 с по peer/epoch/request и SHA256 содержимого (кеш 64 записи).
|
||||
Заполненный кеш отказывает новым запросам, не вытесняя ещё действующую защиту.
|
||||
Отправитель не получает гарантии отсутствия доставки при FAILED/потере ACK.
|
||||
|
||||
[chatserver] bluetooth_invites_enabled=0 — отдельное разрешение, по умолчанию выключено.
|
||||
Проверяется при каждом запросе, в том числе на существующем GATT. Не влияет на DATA,
|
||||
LAN-приглашения или исходящую отправку. Новому входящему GATT также нужно разрешение
|
||||
«Allow incoming Bluetooth connections». Desktop и Android показывают переключатель
|
||||
во вкладке Bluetooth. JOIN выполняется только по подтверждению пользователя.
|
||||
Содержимое приглашения и пароль не выводятся в логи (категория discovery).
|
||||
Формат join-ссылки не изменён: для последующего JOIN по-прежнему нужны её IP-адреса.
|
||||
|
||||
Проверка: test_ble_transport (MTU23/247/517, независимые каналы), test_ble_bluez
|
||||
(два клиента, адресные fd), test_ble_etcp (приложение/ACK, запрет, повторы,
|
||||
потеря ACK, совместный ETCP, teardown и GATT по запросу без ETCP).
|
||||
Физическая проба: ble_etcp_probe 60 listen invites; Android BlePeerProbe
|
||||
с -e etcp true -e initiator true -e invites true -e seconds 50.
|
||||
Проба использует синтетическую ссылку, не запускает JOIN и восстанавливает политику.
|
||||
|
||||
Проверено 2026-10-29 на Android RZ8R72MG64B и Linux/BlueZ:
|
||||
- Android инициирует GATT: приглашение 939 байт туда/обратно, ACK обоих приложений;
|
||||
параллельно проверены все 32 ETCP echo-пакета по 1500 байт.
|
||||
- Desktop инициирует GATT: те же приглашения и ACK в обе стороны; за окно 45 с
|
||||
успели завершиться 7/32 ETCP echo-пакетов, затем проба остановлена по лимиту времени.
|
||||
Это не полный нагрузочный проход обратной роли.
|
||||
- Windows backend проверен кросс-компиляцией MinGW/C++WinRT, без физического Win BT.
|
||||
- Общие тесты: 230/230; отдельные BLE/BlueZ/UI и ASAN/UBSAN пройдены.
|
||||
|
||||
Write Without Response / Notification: проверка 2026-10-29
|
||||
--------------------------------------------------------
|
||||
Android, BlueZ и WinRT используют быстрый DATA, INVITE сохраняет Request/Indication.
|
||||
Android HIGH/MTU517/2M сохранены; WinRT запрашивает ThroughputOptimized при наличии
|
||||
API Windows 11. BlueZ D-Bus не предоставляет аналогичного per-link профиля.
|
||||
Desktop GUI/Linux, Android APK+instrumentation (arm64/x86_64), WinRT cross-build PASS.
|
||||
./check.sh: 230/230 + proxy/burst/load; BLE common/BlueZ/identity/ETCP/UI: 5/5;
|
||||
BLE ASAN/UBSAN: 3/3; Android app/router unit tests: 770/770.
|
||||
|
||||
Телефон RZ8R72MG64B, один desktop BLE-сервис на адаптере:
|
||||
- Android central: 512/512 ETCP echo по 1500 байт, window=16, по 768000 байт
|
||||
в каждом направлении за 61.694 с; суммарный goodput 24897 B/s (12448 B/s
|
||||
на направление). Приглашения 939 байт и ACK приложений прошли в обе стороны.
|
||||
PHY2M TX/RX, MTU517; для Android central подтверждён interval=12 (15 ms).
|
||||
- Сырой BLE, Android central: 96/96 echo, по 45636 байт за 4.473 с,
|
||||
суммарно 20407 B/s. Размеры пакетов различаются, напрямую с ETCP не сравнивать.
|
||||
- Desktop central: приглашения/ACK прошли, но за окно 85 с ETCP подтвердил
|
||||
только 20/512 echo. Повтор без приглашений, window=1: 0/32 echo за окно 65 с,
|
||||
после первого send 50.060 с повторялись INIT/INIT_RESPONSE и DATA RTO,
|
||||
DATA_RTT_us=0; BLE-сборщик не регистрировал отбросов. Причина не установлена;
|
||||
исправление этого сбоя не входит в подтверждённый результат профиля скорости.
|
||||
|
||||
Журналы текущего сеанса: /tmp/ble-speed-android-central-{desktop,logcat}.log,
|
||||
/tmp/ble-speed-desktop-central-{desktop,logcat}.log,
|
||||
/tmp/ble-speed-desktop-central-serial-retry-desktop.log и
|
||||
/tmp/ble-speed-desktop-central-serial-live-logcat.log.
|
||||
После аппаратных проб обычный desktop VibeChat перезапущен новой сборкой;
|
||||
Android instrumentation восстановила обычную Bluetooth-политику в finally.
|
||||
|
||||
Диагностика очередей и аудит ошибок 2026-10-29
|
||||
---------------------------------------------
|
||||
Последующие пробы уточнили причину просадки desktop central. Локальный completion
|
||||
WriteValue(type=command) означает приём фрагмента стеком, а не его передачу по радио.
|
||||
tx_limit=1 ограничивает только нашу ll_queue; очередь BlueZ/ядра этим не ограничена.
|
||||
|
||||
Доказательства по журналам, без сравнения часов разных устройств:
|
||||
- /tmp/ble-errors-final-hci.btsnoop и /tmp/ble-speed-errors-final-desktop.log:
|
||||
сопоставлены 882 исходящих целых кадра по seq/размеру/FNV-отпечатку ciphertext.
|
||||
От локального BLE completion до последнего ACL-фрагмента в HCI проходит
|
||||
3.548..20.167 с; следовательно, очередь находится перед границей HCI TX.
|
||||
Для 675 совпавших входящих кадров HCI RX → BLE callback занимает 0.6..32.9 мс,
|
||||
обычно 1..2 мс. Часовой сдвиг форматов времени исключён по HCI RX/callback.
|
||||
- ETCP в этой пробе повторяет DATA по RTO=1000000 us, пока исходные кадры
|
||||
ещё находятся в нижней очереди. Повторные копии увеличивают задержку.
|
||||
Начальный INIT также повторяется примерно через 50 мс и содержит padding.
|
||||
- Результат контрольного запуска: 42/128 echo по 1500 байт, window=16,
|
||||
63000 подтверждённых байт на направление за 65.730 с после первого send,
|
||||
complete=0; общий лимит запуска 75 с. drops=0 и missing=0 на BLE-сборщике.
|
||||
- Более ранний cause-load1: desktop локально завершил 233 кадра, Android получил
|
||||
только первые 40; обратно завершено 262, получены первые 76. У полученных
|
||||
кадров совпали размеры/отпечатки, внутренних разрывов нет — отсутствуют хвосты.
|
||||
Дополнительная задержка относительно первого кадра выросла на 15.982/10.012 с.
|
||||
- Сбой перемежающийся: отдельная проба desktop central window=1 прошла 32/32
|
||||
за 11.148 с, суммарно 8612 B/s. Это не подтверждает устойчивость под нагрузкой.
|
||||
|
||||
В контрольной пробе Android также каждые ~6 с подключался к соседу
|
||||
4A:2F:B0:6D:A6:35 со старыми свойствами RX=8 (Write), TX=32 (Indicate).
|
||||
Сервис найден со status=0, но не поддерживает новые DATA-свойства. Причина отказа
|
||||
теперь явно указывает отсутствующий Write Without Response/Notification.
|
||||
Влияние этих параллельных подключений на скорость отдельно не измерено.
|
||||
|
||||
Локальный completion недостаточен для оценки фактического продвижения кадров.
|
||||
До изменения wire-протокола необходимо проверять готовность GATT, профиль
|
||||
соединения и поведение ETCP при RTT больше секунды. Последующее расследование
|
||||
ниже подтвердило ошибки готовности/профиля и воспроизвело очередь без ETCP.
|
||||
Произвольная задержка между send и смена на AcquireWrite сами по себе не дают
|
||||
гарантии ограниченной очереди. Дополнительный credit-протокол пока не вводился.
|
||||
|
||||
Аудит диагностики:
|
||||
- Android: ошибки SDK/callback сохраняют status, роль/адрес/link/channel;
|
||||
отказы CCC сохраняют код API33, MTU — requested/reported/effective;
|
||||
неожиданные закрытия/отклонённые чтения/записи имеют WARN, потерянный callback
|
||||
или таймер Handler — ERROR, timeout server writer содержит его состояние.
|
||||
- JNI: переполнение mailbox содержит generation/capacity/discarded;
|
||||
поздние события удалённого link видны на TRACE. Тестовые уровни discovery/debug
|
||||
восстанавливаются при detach и не остаются включёнными после instrumentation.
|
||||
- BLE common: разрывы последовательности целых кадров учитываются отдельно
|
||||
как missing; partial/drop не смешивается с ними. Некорректные фрагменты,
|
||||
замена неполного кадра, отказ allocation и completion имеют контекст ошибки.
|
||||
- BlueZ: ошибки recv/reply/socketpair сохраняют errno/текст; D-Bus ошибки
|
||||
сохраняют имя и сообщение. WinRT: HRESULT и ATT_error/status доступны при
|
||||
WARN, включая discovery/subscription, без необходимости включать DEBUG.
|
||||
- ETCP: проверяются RNG salt/sc_encrypt и фактические результаты отправки
|
||||
INIT/keepalive; busy — DEBUG, отказ — WARN/ERROR. Успешный лог называется
|
||||
submitted, поскольку он не подтверждает доставку получателю.
|
||||
|
||||
Для сопоставления кадров и BLE ACK: debug=debug; для сводок очередей — discovery=debug.
|
||||
Категория debug содержит DATA TX/RX с seq каждого GATT-пакета, ACK TX/RX с ID и кредитом,
|
||||
а также wire TX/RX с отдельным seq, длиной и отпечатком целого кадра.
|
||||
Для RTO/ACK ETCP — etcp=trace, connection=debug, keepalive=debug.
|
||||
На desktop UTUN_TEST_DEBUG=1 включает trace диагностической категории;
|
||||
Android BlePeerProbe включает её на время пробы. Ключи/ссылки приглашений не выводятся.
|
||||
|
||||
Проверки после аудита: C-ядро, desktop GUI/probe, Android APK/instrumentation
|
||||
для arm64-v8a/x86_64 и WinRT cross-build собираются. BLE transport/identity/BlueZ,
|
||||
четыре desktop packet-link/BLE теста и ETCP connect/packet_link/session проходят.
|
||||
HCI можно снимать через sudo btmon -w FILE; обычному пользователю monitor запрещён.
|
||||
Обычный desktop VibeChat после пробы восстановлен, политика Android восстановлена.
|
||||
|
||||
Причина длительного ожидания и исправление входящего HIGH
|
||||
---------------------------------------------------------
|
||||
Это очередь перед HCI TX, а не единая пауза ожидания PHY negotiation:
|
||||
- /tmp/ble-ready-baseline-hci.log: ACL Connected success, MTU517, LL TX/RX=251,
|
||||
PHY TX/RX=2M success до отправки ETCP INIT. Linux central выбрал interval=45ms,
|
||||
latency=0 и supervision timeout=420ms. В этой пробе также был HCI timeout 0x08.
|
||||
- В Android HIGH запрашивался только из исходящего BluetoothGatt callback.
|
||||
Входящий GATT server запрашивал 2M, но не connection priority, поэтому Linux
|
||||
central сохранял медленный профиль. PHY2M сам по себе interval не сокращает.
|
||||
- /tmp/ble-ready-raw-{hci,desktop}.log: без ETCP и его повторов прошли все 96
|
||||
raw echo; по 45636 байт на направление за 27.585s, суммарно 3309B/s.
|
||||
Все 96 кадров сопоставлены с HCI по seq/длине/отпечатку. Последний исходящий
|
||||
кадр попал в HCI через 26.035s после локального completion, входящие callback
|
||||
следовали за HCI RX через 1..2.7ms. Следовательно, ETCP не является необходимым
|
||||
условием возникновения очереди: стек принимает burst быстрее, чем обслуживает ACL.
|
||||
Контроллер сообщил 6 LE TX-буферов по 251 байту; по Completed Packets они заняты.
|
||||
- При изменении interval 45→7.5ms в отдельной пробе частота HCI TX увеличилась
|
||||
примерно в шесть раз. После обновления параметры подтверждены событием
|
||||
LE Connection Update Complete status=0; долговременного pending negotiation нет.
|
||||
hcitool lecup имеет собственные особенности ожидания результата/длины event:
|
||||
вывод команды и её exit недостаточны, согласование проверяется по HCI event.
|
||||
- ETCP усиливает перегрузку: INIT вначале повторяется примерно каждые 50ms
|
||||
с padding; DATA RTO ограничен 1s даже при уже измеренном KA RTT=5..6s.
|
||||
В результате повтор может попасть в очередь раньше физической отправки оригинала.
|
||||
На этапе исправления incoming HIGH алгоритмы INIT/RTO ещё не менялись;
|
||||
последующее исправление RTO описано ниже.
|
||||
|
||||
Отдельно найден мешающий discovery: D-Bus sender :1.70, PID3047 kdeconnectd,
|
||||
каждые 60s устанавливает Transport=bredr. BlueZ объединяет его с нашим Transport=le,
|
||||
и контроллер выполняет Inquiry по 10.24s почти непрерывно. Штатное disable-backend
|
||||
объявило BluetoothLinkProvider disabled, но не остановило старую discovery-сессию.
|
||||
В изолированной пробе потребовался краткий restart KDE Connect с этим backend
|
||||
выключенным; отсутствие Inquiry подтверждено HCI до восстановления backend.
|
||||
Медленный drain/повторы сохранились и без Inquiry: это дополнительный фактор,
|
||||
а не достаточное объяснение всех задержек. Backend KDE Connect затем возвращён
|
||||
в enabled; его LAN backend и постоянные настройки Bluetooth не менялись.
|
||||
|
||||
Исправления:
|
||||
- BlueZ не открывает кешированные UUID до Connected=1 и ServicesResolved=1.
|
||||
После resolve повторно читается snapshot/MTU. Регрессионный private-bus тест
|
||||
с доступным кешем и ServicesResolved=false падал до исправления, теперь проходит;
|
||||
после сигнала resolve используется актуальный MTU517 вместо прежнего MTU23.
|
||||
В аппаратной пробе видны оба snapshot: connected=1/resolved=0, затем resolved=1.
|
||||
- GetManagedObjects используется только как metadata snapshot: закешированное
|
||||
ManufacturerData больше не обновляет discovery/last_seen. Иначе старый RPA
|
||||
телефона мог выдаваться за актуальный endpoint. Новые объявления поступают
|
||||
только из live InterfacesAdded/PropertiesChanged. Регрессионный тест со старым
|
||||
ManufacturerData в snapshot падал до исправления и проходит после него.
|
||||
- Android после первой входящей CCC открывает BluetoothGatt handle к тому же ACL
|
||||
исключительно для requestConnectionPriority(HIGH). У BluetoothGattServer нет
|
||||
соответствующего публичного метода. Новый handle не выполняет discovery/DATA,
|
||||
хранится у входящего Link и закрывается при его teardown. Ошибка настройки
|
||||
логируется WARN и не отменяет работоспособный GATT server.
|
||||
- /tmp/ble-ready-priority-fixed-{hci,logcat}.log: incoming HIGH accepted=true;
|
||||
контроллер получил remote request 11.25..15ms, latency=0, timeout=5000ms,
|
||||
Linux ответил success, Update Complete success установил interval=15ms.
|
||||
PHY2M/MTU517 сохранены. Клиентский handle переиспользовал существующий ACL.
|
||||
Ручной HCI настройки и отключения KDE Connect в этой пробе не было.
|
||||
- В этой нагрузочной пробе сопоставлены 715 TX кадров: локальный completion→HCI
|
||||
занял до 1.713s вместо ранее измеренных 20..26s. У 684 входящих кадров
|
||||
HCI→callback занял 0.8..2.8ms. Очередь существенно уменьшилась, остаточные
|
||||
задержки и недостаточный для такого RTT RTO требуют отдельной работы.
|
||||
За окно 80s подтверждено 266/512 echo, complete=0, после первого send 70.876s,
|
||||
суммарно 11259B/s. Это неполная нагрузочная проба, не успешные 512/512.
|
||||
|
||||
Дополнительные журналы: /tmp/ble-ready-isolated-*, /tmp/ble-ready-quiet-*,
|
||||
/tmp/ble-ready-priority-check-*, /tmp/ble-ready-dbus.log.
|
||||
Поздние повторения ETCP с HIGH всё ещё нестабильны: 115/128 и 27/128 за разные
|
||||
окна; один профиль не устранил проблему ETCP целиком, все такие пробы complete=0.
|
||||
|
||||
Чистое радио после HIGH: /tmp/ble-ready-raw-noscan-fresh-* (несмотря на имя файла,
|
||||
pauseScan в аргументах этого запуска НЕ было, scanning оставался включённым):
|
||||
96/96, по 45636 байт за 4.021s, суммарно 22700B/s. У 96 совпавших исходящих
|
||||
кадров очередь до HCI достигала 3.753s: большой burst всё ещё буферизуется.
|
||||
/tmp/ble-ready-raw-noscan-valid-*: scanning Android остановлен с явным логом,
|
||||
96/96 за 10.620s, суммарно 8594B/s. Остановка scanning не дала ускорения;
|
||||
одиночная парная проба не доказывает отсутствие любого его влияния.
|
||||
Обе пробы начинались после сброса оставшегося ACL предыдущего теста/GUI.
|
||||
Первые две попытки сравнения прервались до передачи с le-connection-abort-by-local
|
||||
и не учитываются. Raw probe теперь повторяет discovery после разрыва до первого
|
||||
send; разрыв после начала передачи остаётся ошибкой.
|
||||
|
||||
Радио-проба умеет выбирать точный node_id, чтобы не подключаться к соседнему
|
||||
устройству со старым протоколом:
|
||||
ble_peer_probe connect 80 1657b3ea8281c0a8
|
||||
UTUN_TEST_DEBUG=1 включает отпечатки кадров также в raw probe.
|
||||
|
||||
Проверки кода: C-ядро, desktop GUI/probes, Android APK/instrumentation arm64/x86_64
|
||||
собираются; Android unit tasks, private-bus BlueZ и четыре desktop BLE/packet-link
|
||||
теста проходят. Пробы восстанавливают GUI, Android-политику и временное состояние
|
||||
KDE Connect. Сборку APK и запуск instrumentation нужно выполнять последовательно:
|
||||
одна проба во время установки завершилась Process crashed и не учитывается.
|
||||
|
||||
Изоляция старого протокола и RTO при RTT больше секунды
|
||||
------------------------------------------------------
|
||||
Сосед 1588291579282de3 публиковал DATA RX=Write/TX=Indicate. Android пытался
|
||||
подключиться к нему примерно каждые 6s и обнаруживал несовместимые свойства.
|
||||
Новый wire-profile требует объявления VC/version=2; версия 1 отклоняется
|
||||
на discovery, до connectGatt. JNI передаёт scan prefix из общего C codec,
|
||||
Kotlin не хранит отдельную версию. Нужны новые сборки на обоих пирах.
|
||||
В /tmp/ble-ready-priority-v2-* нет этих подключений: 128/128 echo за10.623s,
|
||||
суммарно36149B/s, TX queue до0.554s. Длинные повторения остались нестабильны:
|
||||
/tmp/ble-ready-desktop-load-v2-* 49/512, /tmp/ble-ready-android-central-v2-* 439/512.
|
||||
|
||||
В desktop-load-v2 при KA_RTT_us=2025400 повтор DATA назначался через1000000us;
|
||||
далее наблюдался RTT2..6s с тем же потолком. Попытки удалялись раньше ACK,
|
||||
повторы увеличивали очередь и мешали получить действующие DATA/BBR samples.
|
||||
etcp_tx_rto_us теперь сохраняет ceil(1.2*max(DATA_RTT,KA_RTT)+1.5*jitter+3000us),
|
||||
минимум10ms и начальное1s без RTT; ограничение сверху — представимость uint32.
|
||||
Переполнение формулы считается в uint64 и логируется WARN при насыщении.
|
||||
test_etcp_windows проходит все63 сценария, включая RTT2s/ACK1.5s без повтора,
|
||||
таймер7.8672s и предельные значения. Формат DATA/ACK и модель BBR не менялись.
|
||||
|
||||
Аппаратная проба после исправления: /tmp/ble-ready-rto-desktop-v2-*.
|
||||
MTU517, HIGH принят, PHY2M. За71.328s подтверждено183/512 echo, complete=0,
|
||||
суммарно7697B/s; это НЕ успешный нагрузочный проход. Сопоставлены534 TX кадра:
|
||||
local completion→HCI до4.583s; у503 RX кадров HCI→callback0.7..2.5ms.
|
||||
До первого INIT_RESPONSE отправлены24 INIT (вначале каждые~50ms), после готовности
|
||||
линия продолжает передавать кадры. Исправление RTO не ограничивает очередь ОС.
|
||||
Начальный RTO без RTT всё ещё1s, и в этой пробе он сработал до первого измерения.
|
||||
Нужен отдельный выбор ограничения стартовых INIT/нижней TX-очереди; изменение
|
||||
одного таймера не считается подтверждённым устранением всех задержек BLE.
|
||||
|
||||
При полном check обнаружена некорректная фикстура test_etcp_server_lifecycle:
|
||||
синтетический линк MTU=0 не мог отправлять keepalive, но тест ожидал роста счётчика.
|
||||
После аудита ошибок счётчик увеличивается только при принятии пакета транспортом.
|
||||
Фикстура теперь явно задаёт результат отправки через codec без сетевого I/O;
|
||||
добавлена проверка, что busy/error сохраняют счётчик, а успешная отправка увеличивает.
|
||||
|
||||
Обратная роль после RTO: /tmp/ble-ready-rto-android-central-v2-repeat-*.
|
||||
За76.272s подтверждено475/512 echo, complete=0, суммарно18683B/s. У1133 TX кадров
|
||||
local completion→HCI до1.497s; у1111 RX кадров HCI→callback0.3..2.6ms.
|
||||
Первая попытка /tmp/ble-ready-rto-android-central-v2-* получила Android GATT133
|
||||
до открытия/передачи; она не является измерением скорости. Повтор выполнил обмен,
|
||||
но также не закончил512 пакетов в заданном окне80s. Начальная настройка HIGH/PHY
|
||||
асинхронна: запрос не подтверждает её завершения, фактический результат виден в HCI.
|
||||
Длинная очередь остаётся и после успешного LE Connection Update Complete.
|
||||
|
||||
Итоговые проверки: ./check.sh230/230, proxy/burst/load PASS (178s);
|
||||
test_etcp_windows63 сценария и исправленный server_lifecycle PASS. Desktop GUI/probe
|
||||
и Android APK/instrumentation arm64/x86_64 пересобраны; APK установлен на A52.
|
||||
Обычный desktop VibeChat и Android-политика после проб восстановлены.
|
||||
|
||||
BLE version3: ACK и ограничение очереди (29.10.2026)
|
||||
---------------------------------------------------
|
||||
Общий C-драйвер добавляет flow_id BE16 к каждому DATA GATT-value и отдельную
|
||||
ACK-характеристику f61a9024-4abc-45ac-98e1-761df4c13811 (Write Request/Indication).
|
||||
ACK содержит только последний полученный flow_id BE16. Public DATA/INVITE API
|
||||
сохраняет владение ll_entry; backend имеет отдельный внутренний wire-канал ACK.
|
||||
До opened обязательны все три подписки; ранний HELLO отвергается с WARN.
|
||||
Все DATA, включая INIT/keepalive/bootstrap, расходуют одно окно12500байт на направление.
|
||||
Кредит возвращает только удалённый ACK; ring хранит размеры, не payload.
|
||||
При полном окне TX останавливается, ACK продолжает работать; подтверждение будит writable.
|
||||
Без прогресса ACK15s действует самостоятельный watchdog, не зависящий от native pending.
|
||||
|
||||
ACK объединяется до последнего ID, следующий разрешён через15ms после completion
|
||||
предыдущего. Это также гарантирует >=15ms между отправками и даёт DATA/INVITE время
|
||||
между ACK даже при медленном ATT. Первоначальный безусловный приоритет ACK давал
|
||||
голодание DATA: /tmp/ble-ready-ack16-desktop-v3-* получил0/512 за233.032s,
|
||||
хотя окно строго соблюдалось, HCI TX queue была <=0.753s. Raw-проба до исправления
|
||||
планировщика /tmp/ble-ready-raw-ack16-v3-* дала64/96, complete=0.
|
||||
Новая точная регрессия20ms ACK completion + непрерывный RX падала до исправления;
|
||||
после него встречный DATA приходит примерно за21ms. Изменения ETCP для этого не нужны.
|
||||
|
||||
Проверки: общий transport (MTU23/247/517, дуплексное окно, ACK>=15ms,70000 IDs,
|
||||
повторы/неверный ACK, ранний HELLO, настоящий15s watchdog), identity, private-bus BlueZ,
|
||||
ETCP/packet-link/UI проходят. ASAN/UBSan с detect_leaks=1:3/3 PASS.
|
||||
./check.sh230/230 + proxy/burst/load PASS (180s). Android APK/instrumentation
|
||||
arm64-v8a/x86_64, app/router unit tasks и desktop GUI/probes собираются;
|
||||
WinRT backend с ACK проходит MinGW/C++WinRT cross-build, физического Win radio нет.
|
||||
|
||||
Аппаратная проверка после исправления планировщика:
|
||||
- /tmp/ble-ready-ack16-fair-desktop-v3-*: Linux central, Android A52 peripheral.
|
||||
512/512 echo, по768000байт в каждую сторону за72.344s, суммарно21232B/s.
|
||||
Максимум DATA flight:8713байт desktop,4442 Android, оба меньше12500.
|
||||
Минимальный интервал отправок ACK:27.958ms desktop,31.561ms Android.
|
||||
С HCI сопоставлены1220 TX/1057 RX кадров по seq/размеру/отпечатку:
|
||||
local completion→HCI TX <=0.174s, HCI RX→callback0.6..2.9ms.
|
||||
Это успешный полный проход, не прежняя незавершённая проба на183/512.
|
||||
- /tmp/ble-ready-ack16-fair-android-central-v3-*: Android central, Linux peripheral.
|
||||
512/512 echo, по768000байт в каждую сторону за115.141s, суммарно13340B/s.
|
||||
Максимум DATA flight:10204байта desktop,4411 Android, оба меньше12500.
|
||||
Минимальный интервал отправок ACK:59.451ms desktop,44.817ms Android.
|
||||
С HCI сопоставлены1148 TX/899 RX кадров: local completion→HCI TX <=0.358s,
|
||||
HCI RX→callback0.4..1.7ms. В обоих запусках подтверждены MTU517/PHY2M/15ms.
|
||||
Скорость указана суммарно для echo; это не полезная скорость2Мбит/с.
|
||||
После обеих проб обычный desktop GUI перезапущен новой сборкой, Android-политика
|
||||
восстановлена instrumentation finally. Настройки KDE Connect не менялись.
|
||||
@@ -0,0 +1,341 @@
|
||||
BLE транспорт VibeChat (lib/ble)
|
||||
|
||||
Область модуля
|
||||
---------------
|
||||
Независимая от Qt библиотека предоставляет manufacturer-броадкасты и адресные
|
||||
двусторонние GATT-сессии. Один локальный GATT-сервис обслуживает до 16 соединений.
|
||||
Броадкаст — повторяемое ОС объявление, а не гарантированно доставляемая датаграмма.
|
||||
Общий codec ble_identity.h: VC (2 байта), версия 3 (1), node_id BE64 (8),
|
||||
имя UTF-8 (0..13). Имя сокращается по границе символа. Всего 11..24 байта.
|
||||
Приглашения и автоматический выбор соседей реализованы в tools/cgathui_common (doc/ble_etcp.txt); транспорт не запускает JOIN/ETCP.
|
||||
Node ID передаётся в HELLO для сопоставления сессий; подлинность он не доказывает.
|
||||
|
||||
Публичный API: lib/ble/ble_transport.h.
|
||||
Платформенный контракт: lib/ble/ble_backend.h.
|
||||
Desktop GUI запускает библиотеку по настройкам «Bluetooth» (по умолчанию выключена).
|
||||
|
||||
Потоки и время жизни
|
||||
-------------------
|
||||
Все функции и callbacks выполняются в одном переданном uasync-потоке.
|
||||
Из GUI отправлять команды через существующий gui_bridge_post_bound_fn/owned_fn.
|
||||
Остановить BLE до остановки/уничтожения этого uasync; из callbacks запрещены
|
||||
stop/destroy/disconnect (отложить действие в uasync). send/read допустимы.
|
||||
Входящие аргументы callbacks заимствованы до возврата; endpoint можно скопировать.
|
||||
ID соединения монотонен в пределах handle transport. Старый ID не переиспользуется
|
||||
после stop/start; после destroy ни один ID/указатель этого transport не действителен.
|
||||
|
||||
Порядок использования
|
||||
--------------------
|
||||
1. ble_create(ua, config): задать ненулевой node_id, manufacturer company_id,
|
||||
callbacks и context. Linux adapter=NULL выбирает первый адаптер, иначе hciN.
|
||||
Windows использует системный адаптер, adapter должен быть NULL.
|
||||
2. ble_start(): асинхронная регистрация сервиса и scanning; ждать status(BLE_READY).
|
||||
3. ble_advertise(data,len): 0..24 произвольных байта; NULL,0 выключает объявление.
|
||||
company_id должен быть одинаковым у участников. 0xffff — только эксперимент.
|
||||
advertising(READY) означает регистрацию/запуск ОС, не получение соседом.
|
||||
Обновления могут объединяться до последнего значения; точный период не задаётся.
|
||||
4. broadcast callback получает endpoint, bytes и RSSI (127 = отсутствует).
|
||||
ble_connect(endpoint, expected_node) самостоятельно выполняет GATT discovery,
|
||||
подписку и HELLO. expected_node=0 принимает любой ненулевой ID кроме своего.
|
||||
5. validate вызывается для полученного HELLO/ACCEPT до допуска кадров. NULL
|
||||
разрешает всех; ненулевой результат callback разрешает конкретный запрос.
|
||||
connection(READY) сообщает согласованную сессию. GATT/ACL само по себе не READY.
|
||||
6. Для отправки выделить ll_alloc_lldgram(length), заполнить dgram и len,
|
||||
вызвать ble_tx_put. При успехе entry принадлежит transport, иначе остаётся
|
||||
вызывающему. WOULD_BLOCK: сохранить entry и ждать writable, затем повторить.
|
||||
7. received уведомляет о целых кадрах. ble_rx_get извлекает ll_entry;
|
||||
после обработки освободить queue_dgram_free(entry), queue_entry_free(entry).
|
||||
8. ble_disconnect закрывает свою сессию. ble_destroy выполняет stop и освобождение.
|
||||
|
||||
Память и backpressure
|
||||
--------------------
|
||||
Для каждого канала link: отдельные ll_queue TX/RX и RX-буфер сборки.
|
||||
DATA TX ограничен config.tx_limit (0 означает 64), INVITE TX — одним пакетом,
|
||||
RX — 64 пакетами. Текущий TX входит в лимит. writable выдаётся при освобождении
|
||||
места после заполнения, без необходимости сначала получить WOULD_BLOCK.
|
||||
ble_channel_tx_put/tx_ready/rx_get выбирают DATA/INVITE; ble_tx_put/tx_ready/rx_get
|
||||
— сокращения для DATA. Для INVITE используются invite_received/invite_writable. Не обращаться к
|
||||
внутренним очередям напрямую: API сохраняет правила владения при переполнении.
|
||||
RX при заполнении отбрасывает целый новый кадр с WARN; старые кадры сохраняются.
|
||||
Кадр означает непрозрачный буфер длиной 1..1500 байт ВКЛЮЧАЯ Ethernet-заголовок,
|
||||
если он присутствует. Это не Ethernet payload MTU 1500 (которому нужно >=1514).
|
||||
На link не больше одной незавершённой GATT-передачи. Она хранит свой fragment
|
||||
до завершения ОС. Очередь ядра/Qt не используется как неограниченный TX-буфер.
|
||||
DATA дополнительно ограничен неподтверждёнными GATT-values: фиксированные2Мбит/с
|
||||
×50ms/8 =12500байт на направление, включая flow/framing headers. Локальный sent
|
||||
не возвращает этот кредит: его освобождает накопительный ACK удалённого драйвера.
|
||||
При заполнении ble_tx_ready=false; ACK будит writable после появления места.
|
||||
Текущий кадр может остановиться посреди фрагментации, INVITE и ACK продолжают работать.
|
||||
Предел12500 — расчётный размер по фиксированной скорости, не гарантия фактических50ms
|
||||
при медленном радио. Сверх окна остаётся только ограниченная собственная TX-очередь.
|
||||
Переподключение не переносит старые кадры и не повторяет неоднозначные отправки.
|
||||
|
||||
Wire protocol
|
||||
-------------
|
||||
UUID service: f61a9020-4abc-45ac-98e1-761df4c13811
|
||||
UUID RX: f61a9021-4abc-45ac-98e1-761df4c13811 (Write Without Response)
|
||||
UUID TX: f61a9022-4abc-45ac-98e1-761df4c13811 (Notify)
|
||||
UUID INVITE: f61a9023-4abc-45ac-98e1-761df4c13811 (Write + Indicate)
|
||||
UUID ACK: f61a9024-4abc-45ac-98e1-761df4c13811 (Write + Indicate)
|
||||
RX/TX названы относительно сервера; API одинаков для обеих ролей.
|
||||
Версия объявления3 означает DATA Write Without Response/Notify с16-bit flow ID
|
||||
и обязательной ACK-характеристикой; версии1/2 отклоняются до GATT-подключения.
|
||||
Desktop и Android должны использовать
|
||||
новую версию вместе. Scan prefix Android приходит из C codec через JNI start,
|
||||
номер версии в Kotlin не дублируется. Это отдельный протокол от BLE_EXAMPLE
|
||||
и tools/ble-test-android; автоматическая совместимость с ними не заявляется.
|
||||
|
||||
Все многобайтные целые big-endian, без выравнивания C-структур.
|
||||
Каждое DATA GATT-value начинается с flow_id(2), затем одно из тел ниже.
|
||||
INVITE содержит только тело FRAME без flow_id; ACK содержит только last_flow_id(2).
|
||||
HELLO: [type=1:1][source_node:8][target_node:8], Central -> Peripheral.
|
||||
ACCEPT: [type=2:1][source_node:8][target_node:8], Peripheral -> Central.
|
||||
DATA: [type=3:1][frame_id:2][total_len:2][offset:2][payload:1..].
|
||||
HELLO/ACCEPT идут только по DATA, сборка DATA и INVITE независима.
|
||||
frame_id увеличивается modulo 65536 отдельно для каждого направления и канала.
|
||||
Планировщик чередует фрагменты DATA/INVITE; готовый ACK имеет высший приоритет,
|
||||
затем HELLO/ACCEPT. До HELLO обязательны подписки DATA/INVITE/ACK с обеих сторон.
|
||||
Размер значения <= min(ATT_MTU-3,512), минимальный ATT MTU=23.
|
||||
Полезный DATA-фрагмент:11байт при MTU23,235 при247,503 при517; HELLO/ACCEPT занимают19.
|
||||
Нулевой offset начинает новый кадр и заменяет незавершённую предыдущую сборку.
|
||||
Далее обязательны тот же frame_id/total_len и offset==число принятых байтов.
|
||||
Пропуск, неверная длина, новое начало или 15-секундный таймаут сбрасывают сборку;
|
||||
в RX помещается только целый кадр. Повторы целых кадров выполняет потребитель.
|
||||
DATA, включая HELLO/ACCEPT и начальный обмен ключами, использует Write Command
|
||||
и Notification без ATT-подтверждений. Подтверждения/повторы данных выполняет ETCP;
|
||||
потеря начального обмена завершает попытку по существующему дедлайну подключения.
|
||||
INVITE сохраняет Write Request и Indication. Завершение локального send не означает
|
||||
чтения кадра приложением и не защищает от заполнения RX.
|
||||
|
||||
ACK и управление окном
|
||||
----------------------
|
||||
flow_id начинается с1, увеличивается modulo65536 для каждого DATA GATT-value,
|
||||
включая HELLO/ACCEPT, bootstrap, ETCP control и DATA. ACK — последний полученный
|
||||
flow_id; при пропуске последующий ID освобождает кредит предыдущих отправок,
|
||||
но НЕ утверждает их доставку приложению. Сборщик/ETCP обнаруживают потери отдельно.
|
||||
Дубликат/старый DATA не выдаётся повторно, текущий ACK может быть повторён.
|
||||
Кольцо хранит только размеры неподтверждённых GATT-values, без копии payload.
|
||||
В окне не более1250 минимальных DATA-values по10байт; это меньше половины16-bit
|
||||
пространства ID. Modulo-разность ACK однозначно проверяется по активному окну.
|
||||
Старый/повторный ACK не освобождает кредит, ACK вперёд за отправленное окно закрывает
|
||||
соединение с WARN. При reconnect все flow ID/кредит/таймеры создаются заново.
|
||||
|
||||
ACK отправляется отдельным uasync-таймером, не polling-таймером100ms. Поступившие
|
||||
ID объединяются до последнего. Следующий ACK разрешён не раньше15000us после
|
||||
completion предыдущего: между ACK есть время для DATA/INVITE даже при медленном ATT.
|
||||
Интервал между принятыми backend ACK тем самым также не меньше15000us.
|
||||
Один ACK в процессе отправки и один объединённый ожидающий; очереди ACK нет.
|
||||
ACK не расходует DATA-кредит и не требует встречного DATA. Его Write Request/
|
||||
Indication подтверждается ATT; ошибка/зависание завершают соединение с диагностикой.
|
||||
15s без продвижения ACK закрывают link даже при успешных локальных completions.
|
||||
Дубликаты ACK этот срок не продлевают. Close/stop отменяют ACK-таймер.
|
||||
|
||||
Linux
|
||||
-----
|
||||
BlueZ D-Bus, клиент sd-bus из libsystemd; зависимость от Qt отсутствует.
|
||||
Собственная приватная D-Bus connection: закрытие освобождает registrations и scan.
|
||||
Основной процесс ОС не обязан быть systemd, но нужны системный D-Bus и bluetoothd.
|
||||
Connectable-объявление UUID и пользовательский manufacturer-броадкаст занимают
|
||||
два advertising instances при одновременном включении. Оба connectable:
|
||||
адрес manufacturer-маяка должен позволять GATT connect (отдельный broadcast
|
||||
instance может иметь другой random address). Нехватка ресурса — ошибка.
|
||||
Входящий WriteValue содержит device; серверный AcquireNotify(device,mtu) получает
|
||||
отдельный SOCK_SEQPACKET socketpair для каждого клиента и канала. DATA fd принимает
|
||||
notifications: успешная запись завершает локальный send без ожидания ATT ACK.
|
||||
INVITE/ACK fd передают indications; байт1 в обратную сторону подтверждает ATT completion.
|
||||
EAGAIN сохраняет фрагмент и ждёт writable; синхронных callbacks send нет.
|
||||
Value/Confirm без адреса клиента для серверной передачи не используются.
|
||||
Выходящий клиент: DATA WriteValue(type=command), INVITE/ACK WriteValue(type=request),
|
||||
StartNotify для TX, INVITE и ACK; открытие после всех трёх подписок.
|
||||
Публичный Device1 D-Bus API не задаёт per-link PHY/connection interval: согласованием
|
||||
управляют ОС и peer; Android запрашивает 2M также в серверной роли.
|
||||
Не вызывается глобальный Device1.Disconnect: ACL может обслуживать другие сервисы.
|
||||
Проверено с BlueZ 5.85; требуется серверный AcquireNotify с indication completion.
|
||||
|
||||
Windows
|
||||
-------
|
||||
WinRT BluetoothLEAdvertisementWatcher/Publisher и GattServiceProvider.
|
||||
GATT-клиент использует WriteValueWithResultAsync, сервер — адресный
|
||||
NotifyValueAsync(value, subscribedClient): DATA — notify-only, INVITE/ACK — indicate-only.
|
||||
DATA пишет с WriteWithoutResponse и подписывается на Notify; INVITE/ACK сохраняют
|
||||
WriteWithResponse/Indicate. Сервер отвечает Respond только на WriteWithResponse.
|
||||
После открытия исходящего GATT при наличии IBluetoothLEDevice6 (Windows 11)
|
||||
запрашивается ThroughputOptimized. Request хранится до close линка и закрывается
|
||||
отдельно. GetConnectionParameters/GetConnectionPhy и события изменений логируют
|
||||
фактический интервал/latency/timeout и TX/RX PHY. Установка 2M этим API не доступна;
|
||||
Windows выбирает PHY с peer. Отсутствие/отказ API не ломает GATT-подключение.
|
||||
Результаты WinRT сериализуются в uasync через bounded mailbox (256 событий).
|
||||
Опоздавшие callbacks используют weak ownership и ID, закрытые handles не трогают.
|
||||
Перед остановкой uasync снять источники событий, отменить операции и posted_task.
|
||||
WinRT может сохранять физический ACL для других сервисов. Гарантия close относится
|
||||
к нашей сессии и ресурсам; принудительный разрыв общего ACL в API не обещается.
|
||||
Нужен адаптер с central+peripheral и Windows 10 2004 / build 19041 или новее:
|
||||
помимо GATT server API/BytesSent требуется BluetoothAddressType принятого объявления.
|
||||
Наличие свойства проверяется при старте, интерфейса EventArgs2 — на фактическом событии;
|
||||
отсутствие даёт явную причину отказа. Версия ОС сама по себе поддержку драйвером не гарантирует.
|
||||
Радио Windows необходимо проверить на реальном Windows-ПК; Wine этого не заменяет.
|
||||
|
||||
Сборка и проверка
|
||||
----------------
|
||||
Linux: пакет libsystemd-dev и dbus-daemon (dbus-run-session).
|
||||
MSYS2 UCRT64: mingw-w64-ucrt-x86_64-cppwinrt; C++20, windowsapp.
|
||||
|
||||
cmake -S lib/ble -B /tmp/utun-ble-build -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build /tmp/utun-ble-build -j4
|
||||
ctest --test-dir /tmp/utun-ble-build --output-on-failure
|
||||
|
||||
test_ble_transport: реальные common transports с управляемым backend; MTU
|
||||
23/247/517, duplex, пропущенный фрагмент, TX/RX full, несколько пиров,
|
||||
отказ validate и уничтожение во время отправки; проверяется учёт памяти.
|
||||
test_ble_bluez: отдельный dbus-run-session с подставным BlueZ; настоящее
|
||||
экспортирование сервиса/объявлений, два AcquireNotify fd, адресные подтверждения,
|
||||
приём кадров и независимое закрытие. К системному Bluetooth тест не обращается.
|
||||
test_ble_winrt (Windows): настоящий mailbox и hresult_error без радио; исключение
|
||||
одного link не останавливает другого, ошибка radio прекращает доставку batch,
|
||||
shutdown отменяет posted_task и поздние callbacks. Тест можно запускать в Wine;
|
||||
он не проверяет драйвер, доступность Bluetooth API и физическую передачу.
|
||||
ble_probe 3: ручная проверка реального радио (scan, GATT registration, публикация
|
||||
и замена тестового manufacturer-объявления); не подключается к чужим устройствам.
|
||||
Успех требует READY радио, первоначальной и обновлённой публикации. Одного
|
||||
scan/GATT ready недостаточно. Для диагностики ожидания ресурсов: ble_probe 15.
|
||||
Логи: discovery=debug. INFO — состояния и режим DATA; DEBUG — подписки и сводка трафика;
|
||||
WARN/ERROR — причины отброса/отказа. Содержимое кадров/объявлений не выводится.
|
||||
|
||||
Отладка Bluetooth на Windows
|
||||
---------------------------
|
||||
В существующей секции [debug] файла vibechat.cfg установить discovery=debug;
|
||||
общий [gui] debug_level может оставаться info. Журнал задаёт [gui] debug_file
|
||||
(по умолчанию chatgui.log рядом с конфигом). После изменения фильтра перезапустить GUI.
|
||||
Ранние ошибки подготовки INI всегда пишутся в chatgui.log до чтения настроек лога;
|
||||
их точные причины также показаны в окне ошибки независимо от фильтра.
|
||||
|
||||
INFO: семейство/версия Windows (DeviceFamilyVersion), доступность обязательного
|
||||
BluetoothAddressType, wire=3, каналы DATA/INVITE/ACK. Waiting и Stopping publisher
|
||||
имеют разные причины состояния; Waiting означает ожидание ресурсов публикации.
|
||||
|
||||
DEBUG: адаптер (BLE, central/peripheral, extended advertising, поддержка 2M/Coded),
|
||||
этап/результат каждой служебной async-операции, состояния рекламы и сканирования,
|
||||
адрес и роль соединения, список UUID/свойств характеристик, CCCD, MTU/PDU,
|
||||
сводка трафика, закрытие с pending-состоянием и очистка mailbox.
|
||||
HRESULT записывается с именем события, конкретной отмеченной операцией, link ID
|
||||
и текстом Windows даже при выключенном DEBUG/TRACE. Например:
|
||||
event=Watcher.Received event_link=0: ... phase=Received.BluetoothAddressType link=0 HRESULT=0x80004002.
|
||||
Регистрация обработчиков помечена auto_revoke: E_NOINTERFACE на этом этапе
|
||||
нужно отличать от ошибки доступа к свойству в уже принятом событии.
|
||||
Ошибка события с известным link ID закрывает этот link; ошибка общего события
|
||||
переводит radio в failed. Повторный запуск после общей ошибки: выключить Bluetooth
|
||||
в настройках приложения, применить, затем включить и применить ещё раз.
|
||||
BluetoothError записывается
|
||||
числом и именем, GATT status — числом, ATT protocol error — числом либо -1 (нет).
|
||||
GATT status: 0=Success, 1=Unreachable, 2=ProtocolError, 3=AccessDenied.
|
||||
PHY_2M_supported — возможность адаптера, НЕ измеренная скорость соединения;
|
||||
-1 у необязательных возможностей означает отсутствие API в этой версии Windows.
|
||||
Для отдельных кадров, ATT-фрагментов и переходов backpressure использовать discovery=trace.
|
||||
WinRT TRACE содержит channel (0=DATA, 1=INVITE, 2=ACK), направление, длину и elapsed_us
|
||||
локальной отправки до её обработки в uasync; это время включает ожидание mailbox.
|
||||
DEBUG показывает число подписчиков DATA/INVITE/ACK, недостающие подписки конкретного
|
||||
устройства и ATT-ошибку записи CCCD. Первое входящее значение перечитывает подписки,
|
||||
если Windows ещё не доставил SubscribedClientsChanged.
|
||||
На DEBUG раз в 5 секунд активного соединения и при его закрытии пишется BLE traffic:
|
||||
TX/RX_Bps полезных байтов BLE-кадров (оба канала вместе, до вычета заголовков ETCP),
|
||||
число кадров/фрагментов/отбросов, среднее/максимальное локальное ожидание GATT,
|
||||
размеры DATA/INVITE очередей и возраст pending. Локальный TX не равен доставке.
|
||||
Отдельная сводка flow показывает flight bytes/packets, peak_bytes, ID/ACK и число
|
||||
ACK TX/RX. INFO печатает фиксированную скорость/окно/интервал/разрядность ID.
|
||||
Категория debug (debug=debug) содержит каждый DATA TX/RX с seq, type и длиной,
|
||||
ACK TX/RX с ID, освобождённым кредитом и остатком flight. Повторные ACK видны как ignored=stale.
|
||||
DATA seq — 16-битный ID GATT-пакета, соответствующий ID в ACK; type: 1=HELLO, 2=ACCEPT, 3=FRAME.
|
||||
BLE wire TX/RX содержит отдельный seq целого кадра, длину и отпечаток tag для сверки двух узлов.
|
||||
DATA/ACK TX означает принятие backend, wire TX — завершение локальной отправки целого кадра;
|
||||
удалённый ACK подтверждает приём GATT-пакетов. Содержимое пакетов в эти логи не выводится.
|
||||
Повторные радиообъявления не засоряют DEBUG.
|
||||
|
||||
Desktop GUI
|
||||
-----------
|
||||
Настройки → Bluetooth: включить транспорт, рекламировать ID/имя, принимать входящие.
|
||||
В [gui] bluetooth_flags: bit 0 — транспорт, bit 1 — объявление, bit 2 — входящие;
|
||||
по умолчанию 0. Save сохраняет флаги и будит uasync, без перезапуска ядра.
|
||||
Системный переключатель Bluetooth пользователь включает в ОС.
|
||||
DesktopBle создаётся/уничтожается в runLoop UtunNode и использует общий C API.
|
||||
GUI читает копию состояния; список обнаружения ограничен 64 узлами с TTL 30 секунд.
|
||||
Изменение имени обновляет объявление. Входящая валидация пока разрешает всех.
|
||||
UtunNode::bluetoothTransport() — заимствованный handle ТОЛЬКО для uasync-потока;
|
||||
кадры остаются в RX-очередях до чтения потребителем. Автоматических JOIN/ETCP нет.
|
||||
Ручная проверка с Android BlePeerProbe initiator=true: build/ble_desktop_probe.
|
||||
Проба проверяет готовность радио/объявления, обнаружение и входящую GATT-сессию,
|
||||
затем останавливает transport до уничтожения uasync.
|
||||
|
||||
Android
|
||||
-------
|
||||
Общие ble_transport.c/ble_identity.c компилируются в libutun.so; android_ble.c
|
||||
реализует ble_backend.h через BleRadio.kt (Android BluetoothGatt/Server/LE).
|
||||
Данные и callbacks core — в instance_lite uasync. Android callbacks сериализованы
|
||||
HandlerThread, JNI mailbox ограничен 256 событиями, каждое несёт generation.
|
||||
eventfd немедленно будит uasync при новом событии; polling-таймера между фрагментами нет.
|
||||
Переполнение mailbox останавливает radio с ошибкой. На соединение не более одной
|
||||
ожидающей отправки; сервер notifications/indications обслуживает адресно, по очереди.
|
||||
DATA использует WRITE_TYPE_NO_RESPONSE, CCC ENABLE_NOTIFICATION_VALUE и confirm=false;
|
||||
INVITE/ACK — WRITE_TYPE_DEFAULT, ENABLE_INDICATION_VALUE и confirm=true. Оба направления
|
||||
ждут локальный callback готовности стека, даже когда ATT ACK отсутствует.
|
||||
Политика входящих ble_set_incoming закрывает только входящие сессии.
|
||||
Настройки используют DataStore; Bluetooth off останавливает только радио приложения.
|
||||
Android 12+: SCAN/CONNECT/ADVERTISE runtime permissions; системное включение BT
|
||||
через ACTION_REQUEST_ENABLE. На Android 8–11 сканирование требует геопозицию.
|
||||
Длительная работа принадлежит существующему HeadlessService (connectedDevice).
|
||||
Перед teardown worker вызывает android_ble_detach, старые callbacks отбрасываются.
|
||||
android_ble_transport() возвращает общий C handle только для использования в worker.
|
||||
В обычном режиме нет автоматического connect/echo или подключения пакетов к ETCP.
|
||||
|
||||
Физическая проверка
|
||||
-------------------
|
||||
./gradlew assembleDebug assembleDebugAndroidTest (в tools/chatgui-android)
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
|
||||
Выдать приложению разрешения Bluetooth, включить системный Bluetooth.
|
||||
Запустить одновременно:
|
||||
adb shell am instrument -w -e seconds 90 -e initiator false \
|
||||
com.utun.chat.test/com.utun.chat.BlePeerProbe
|
||||
/tmp/utun-ble-build/ble_peer_probe connect 75
|
||||
Обратные роли: initiator=true и ble_peer_probe listen 75.
|
||||
Instrumentation временно включает echo, возвращает сохранённую политику после
|
||||
теста. Успех определяет desktop RESULT ok=1: 96 пакетов размеров
|
||||
1/13/14/20/244/512/1499/1500, побайтовая сверка, TX WOULD_BLOCK и writable.
|
||||
Не запускать этот тест одновременно с обычным использованием приложения.
|
||||
|
||||
Профиль скорости Android
|
||||
------------------------
|
||||
После подключения client просит CONNECTION_PRIORITY_HIGH и ATT MTU 517.
|
||||
Обе роли запрашивают PHY_LE_2M для TX/RX при поддержке локального контроллера.
|
||||
Запрос не гарантирует смену PHY: onPhyUpdate/onPhyRead логируют фактические TX/RX.
|
||||
При отсутствии поддержки остаётся согласованный PHY. S2/S8 — параметры только
|
||||
LE Coded PHY (дальность/скорость), с выбранным 2M применяется NO_PREFERRED.
|
||||
У Android BluetoothGattServer нет public requestConnectionPriority; входящий link
|
||||
использует дополнительный BluetoothGatt handle того же ACL только для HIGH.
|
||||
Запрос/результат логируются, handle закрывается при teardown; нового ACL для настройки нет.
|
||||
Сам UUID/advertising остаётся legacy
|
||||
1M для обнаружения; PHY 2M относится к установленному соединению.
|
||||
|
||||
Проверка 2026-10-29
|
||||
------------------
|
||||
Физический Q8 DA6B8P01, Android 14 (API 34), Linux BlueZ на этом ПК.
|
||||
Обе роли подключения: RESULT ok=1, sent=96, verified=96, blocked=1, discovered=1.
|
||||
Каждое направление: 45636 байт с побайтовой сверкой; пакеты до 1500 включительно.
|
||||
После профиля скорости: desktop central 19.801 с, Android central HIGH 9.977 с.
|
||||
Это elapsed теста с подтверждениями/echo, не измерение предельной канальной скорости.
|
||||
Q8 сообщает isLe2MPhySupported=false; readPhy возвращает status=6. PHY 2M
|
||||
реализован как запрос, его фактическое согласование на этом телефоне не проверено.
|
||||
Общий C codec/transport и BlueZ mock: ASAN+UBSAN, leak detection — 3/3 PASS.
|
||||
Сборки Android arm64-v8a/x86_64, desktop GUI Linux и WinRT cross-build проходят.
|
||||
Физический Windows radio и несколько одновременных Android-клиентов не проверены.
|
||||
|
||||
Проверки ACK version3
|
||||
---------------------
|
||||
test_ble_transport: MTU23/247/517, duplex12500-byte bound при задержанных удалённых ACK,
|
||||
completion сам не освобождает окно, дубликаты/неверный ACK, переход65535→0
|
||||
на70000 GATT-values, интервал ACK>=15ms и настоящий15s ACK watchdog без native pending.
|
||||
Отдельная регрессия имитирует20ms completion ACK при непрерывном входящем DATA:
|
||||
до исправления планировщика встречный DATA не приходил за250ms; после — примерно21ms.
|
||||
Таймер отсчитывается после completion ACK, чтобы новый ACK не вытеснял DATA/INVITE.
|
||||
Private-bus BlueZ проверяет обязательность третьей подписки, адресные ACK indications
|
||||
двух клиентов и ACK Write Request в исходящей роли. test_ble_etcp проверяет настоящий
|
||||
ETCP/crypto, потери DATA и INVITE, NCD/BGP и lifecycle через новый flow prefix.
|
||||
File diff suppressed because it is too large.
Load diff
+83
-11
@@ -9,6 +9,9 @@ HEVC Main с Main/High tier (High начиная с level 4), 8-bit 4:2:0, progr
|
||||
HRD при наличии, геометрию и независимый IDR. Фактический level проверяется после
|
||||
открытия encoder. CAPS — 64 байта, header — 56 байт; старый формат не поддерживается.
|
||||
VBV является локальной настройкой software encoder, в сетевом контракте его нет.
|
||||
Проверка KEY в call_video.c ограничена envelope; разбор нужных полей VPS/SPS/PPS/IDR
|
||||
выполняет call_video_bitstream. Он не декодирует entropy payload и не заменяет decoder.
|
||||
У HEVC High tier также действуют консервативные Main-tier бюджеты HRD/CPB.
|
||||
Результат запуска decoder (generation + accepted) передаётся отдельно от router ACK:
|
||||
неподдержанный формат вызывает fallback в Auto, потеря references — запрос KEY.
|
||||
Камера, разрешение, частота до 60 fps, кодек и диапазон битрейта меняются во время звонка.
|
||||
@@ -22,7 +25,8 @@ SoundTouch и очередь устройства. Алгоритм регули
|
||||
Показ имеет собственное абсолютное расписание с плавным темпом по суммарному запасу до 1.6x;
|
||||
audio clock ограничивает опережение videoPTS-audioPTS до +40мс. Устойчивый долг при ограничении
|
||||
частоты экрана снимается пропуском только показа уже декодированных кадров.
|
||||
Подробности и границы измерения — в разделе «Независимое расписание показа» ниже.
|
||||
Подробности — в разделах «Часы показа и сквозная очередь», «Независимый desktop-планировщик»
|
||||
и «До 60 fps и восстановление presentation»; раннее «Независимое расписание показа» историческое.
|
||||
Искусственная тишина/тоны не продвигают исходную позицию. Формат видео меняется по PTS;
|
||||
новое поколение не уничтожает старые кадры, соответствующие ещё не прозвучавшей речи.
|
||||
|
||||
@@ -43,8 +47,69 @@ audio clock ограничивает опережение videoPTS-audioPTS до
|
||||
Состояние звонка/регулятора/роутера — uasync. Камера и encoder/decoder — платформенные
|
||||
workers. Ограниченный mailbox, одно ожидающее уведомление. Команды bitrate имеют
|
||||
call_id, video_generation и request_id; одно исполнение и последнее желаемое значение.
|
||||
Принятая настройка не заменяет измерение фактического выхода. Shutdown отменяет
|
||||
уведомления/таймеры, останавливает producers, затем освобождает очереди и codecs.
|
||||
Принятая настройка не заменяет измерение фактического выхода. Остановка освобождает
|
||||
уведомления/таймеры, очереди и codecs в порядке владельцев: desktop присоединяет workers
|
||||
перед close port; Android ставит stopped, присоединяет RX, закрывает port, затем TX handler
|
||||
закрывает камеру/encoder и завершает свой looper. Это разные контракты завершения.
|
||||
|
||||
Границы модулей и wire (сверено с кодом 26.10.2026)
|
||||
call_video.h: чистые проверки/сериализация доступны workers, receiver имеет одного владельца.
|
||||
Limits — независимые границы, полностью нулевой limit означает отсутствие
|
||||
проверенного профиля. Format: ненулевой generation, чётные размеры 16..4096, 1..60 fps,
|
||||
rotation=0/90/180/270, target ceiling 300000..4000000 бит/с, один profile=1/2.
|
||||
Поля формата, включая maximum_bps, неизменны внутри generation; текущий bitrate — отдельно.
|
||||
frame_id и capture PTS строго возрастают сквозь поколения; пропуск frame_id требует KEY.
|
||||
|
||||
CAPS: четыре limits по 16 байт: encode AVC, encode HEVC, decode AVC, decode HEVC.
|
||||
Внутри limit: width:2, height:2, fps:2, pixels_per_second:4, maximum_bps:4, level:1, profiles:1.
|
||||
Header фрагмента (смещения в байтах):
|
||||
0 call_id:8 8 generation:8 16 frame_id:8 24 capture_pts_us:8
|
||||
32 width:2 34 height:2 36 fps:2 38 rotation:2
|
||||
40 codec:1 41 flags:1 42 level:1 43 profile:1
|
||||
44 frame_size:4 48 offset:4 52 maximum_bps:4
|
||||
Все многобайтовые поля CAPS/header — BE. Длина payload 1..1200, полный кадр <=2 МиБ.
|
||||
Parse возвращает view внутри входного пакета; fragment.data уже указывает на payload
|
||||
данного offset. Локальный received_us в wire не входит. Control имеет отдельный формат:
|
||||
type:1 + call_id:8 + revision/generation:8, для CAPS ещё 64 байта; оба uint64 — native endian,
|
||||
как в текущей сигнализации call. Это описание действующего кода, не обещание cross-endian совместимости.
|
||||
|
||||
call_video_receiver хранит одну частичную сборку. Offset идёт непрерывно; повтор/перестановка,
|
||||
разрыв frame_id, регресс PTS/поколения и смена формата без нового generation нарушают цепочку.
|
||||
Возраст сборки >2с проверяется на следующем push; отдельного таймера сборки нет.
|
||||
Готовый буфер заимствован до следующего push/reset/destroy; очередь готовых кадров — у владельца.
|
||||
|
||||
call_video_transport принадлежит uasync: control/audio/video используют отдельные router services,
|
||||
control и video требуют подпись и шифрование. CAPS повторяются каждые 500мс до прикладного ACK.
|
||||
Configure может применить локальное состояние и затем вернуть -1 при отказе отправки CAPS.
|
||||
TX FIFO: 32 кадра, размах PTS <=500мс, суммарно <=2 МиБ; общий admission учитывает router debt.
|
||||
Голова ждёт audio_end_pts и CAPS_ACK. Flush выдаёт до 16 фрагментов, продолжает через call_soon;
|
||||
send_q/full debt ждут send_ready/audio/tick. Возраст головы >2с сбрасывает зависимую цепочку.
|
||||
Tick 50мс обновляет регулятор; timeout encoder-команды 12с выключает admission до configure_rate.
|
||||
Callback заимствует payload до возврата и не вызывает изменяющий call API реентерабельно.
|
||||
|
||||
call_video_port — единственный процессный mutex/mailbox. Attach/detach — uasync, workers используют
|
||||
потокобезопасные команды. Open того же id сохраняет RX/TX/peer/wake/received counter;
|
||||
другой id очищает их. Open/format/applied/range/decoded подтверждают приём команды,
|
||||
проверка активной сессии и исполнение происходят позже в uasync.
|
||||
Send=1 означает копирование пачки в mailbox, transport может позже отвергнуть её с key_serial.
|
||||
Send=0 не принимает ничего; transport busy сохраняет непринятый хвост без нового KEY.
|
||||
TX mailbox: до 8 кадров по 2 МиБ (до 16 МиБ); RX: до 8 кадров/4 МиБ.
|
||||
Pull передаёт владение data, освобождение u_free; RX wake вызывается под port mutex, без входа в port API.
|
||||
До detach останавливают пользователей и снимают transport callbacks через close с обработкой команды
|
||||
либо завершение call session. Сам detach не заменяет остановку транспорта.
|
||||
|
||||
call_video_rate — чистая модель 500мс окон с 50мс шагом; единицы — уникальные encoded bytes,
|
||||
без fragment headers. Invalid sample не меняет состояние/результат. Чистый step допускает до
|
||||
100 Мбит/с; диапазон звонка 300..4000 кбит/с ограничивают parser и transport.
|
||||
call_video_playout не владеет изображениями: WAIT сохраняет голову, SHOW резервирует до presented,
|
||||
DROP_ORDER/DROP_LATE освобождают только decoded presentation силами владельца.
|
||||
Desktop считает общий запас как received-completed-discarded без mutex порта;
|
||||
Android складывает снимок port.rx_frames с локальными pending/inputs/ready/awaiting.
|
||||
|
||||
Desktop смена пользовательского режима отменяет ещё не переданную worker-пачку и заменяет encoder;
|
||||
это отличается от смены bitrate с полным drain старого encoder. Android при смене геометрии
|
||||
сначала drain, отправляет pendingFrames и ждёт tx_idle перед format. Готовые RX-изображения
|
||||
старого поколения сохраняются до своего показа на обеих платформах.
|
||||
|
||||
Применение bitrate на ключевом кадре (уточнение пользователя)
|
||||
Worker хранит pending-команду и применяет её перед кодированием ближайшего
|
||||
@@ -99,8 +164,11 @@ GOP продолжается. Android сначала меняет bitrate чер
|
||||
Видео следует аудио при догоне, underrun, смене устройства и формате видеотрека.
|
||||
Завершение звонка безопасно во время переключения камеры, bitrate и decoder.
|
||||
|
||||
Далее журнал этапов разработки. Старые описания wire/profile/VBV ниже исторические;
|
||||
актуальный контракт приведён в начале, последние проверки — в конце документа.
|
||||
Далее журнал этапов разработки. Формулировки «текущий», «действующий», «пока» и результаты
|
||||
проверок относятся к дате соответствующего этапа; старые wire/profile/VBV/буферы/алгоритмы
|
||||
не задают нынешний контракт. Актуальная сводка приведена выше, подробности поздних этапов
|
||||
уточняют расписание, High tier, 60 fps и прогрев. Исторические аппаратные результаты и логи
|
||||
при сверке документации 26.10.2026 заново не воспроизводились.
|
||||
|
||||
Проверка SoundTouch, 14.10.2026
|
||||
test_soundtouch_clock сравнивает положение реальных четырёхсэмпловых фрагментов
|
||||
@@ -922,7 +990,7 @@ Windows-диагностика: AudioDiag теперь работает чере
|
||||
video late drop содержит точные PTS/AV/возраст первого позднего кадра за окно, playout —
|
||||
диапазоны всех late кадров. Измерения не корректируют Windows callback-estimate аудиочасы.
|
||||
|
||||
Восстановление без пропусков, 19.10.2026 — действующее поведение
|
||||
Восстановление без пропусков, 19.10.2026 — базовый этап; уточнения 19–20.10 ниже
|
||||
Encoded-кадр после принятия сохраняет ID/PTS и владельца до передачи дальше. TX port
|
||||
удерживает непринятый хвост пачки и повторяет его при уведомлении writable. Новая пачка
|
||||
не принимается во время обработки старой. Busy=0 не меняет references и не запрашивает KEY;
|
||||
@@ -949,7 +1017,8 @@ Decoder получает кадры строго последовательно,
|
||||
входы decoder ограничены отдельно восемью, чтобы output lookahead не блокировал запуск GPU.
|
||||
Основной backlog хранится encoded, а не большим RGB FIFO или удержанием всех аппаратных outputs.
|
||||
|
||||
Playout больше не содержит DROP_LATE/DROP_SCHEDULE. Invalid/order остаётся диагностируемой ошибкой.
|
||||
На этом этапе playout не содержал DROP_LATE/DROP_SCHEDULE; 20.10 возвращён только DROP_LATE
|
||||
для устойчивого долга при ограничении частоты экрана. Invalid/order остаётся диагностируемой ошибкой.
|
||||
CallTimeline публикует rate_ppm из фактического SourceSpan SoundTouch -> PCM на текущей позиции устройства.
|
||||
Первоначальный вариант управлял темпом по audioPTS-framePTS. Его заменила описанная ниже схема
|
||||
с подтверждённым временем показа, общей глубиной конвейера и небольшим рабочим запасом.
|
||||
@@ -1004,8 +1073,9 @@ N = полные encoded в port + pending input worker + принятые decod
|
||||
+ decoded FIFO + один выбранный, но ещё не подтверждённый GUI/Surface кадр.
|
||||
Перемещение между стадиями не меняет N; исключение кадра происходит после подтверждения показа
|
||||
или решения DROP_LATE для уже декодированного кадра (дополнение 20.10.2026 ниже).
|
||||
Снимок port.rx_frames берётся под mutex после локальных перемещений RX worker. Сетевая частичная
|
||||
сборка не считается полным кадром. Очереди отправителя не видны этому счётчику.
|
||||
На Android снимок port.rx_frames берётся под mutex после локальных перемещений RX worker.
|
||||
Desktop использует атомарный cumulative received-completed-discarded (см. независимый планировщик ниже).
|
||||
Сетевая частичная сборка не считается полным кадром. Очереди отправителя не видны этому счётчику.
|
||||
RX admission возобновляется при появлении места, без искусственного ожидания полного drain FIFO.
|
||||
Уведомление port будит desktop RX немедленно; callback снимается до освобождения владельца.
|
||||
|
||||
@@ -1018,7 +1088,8 @@ submit/receive независимы: EAGAIN сохраняет непринят
|
||||
SHOW резервирует кадр, presented(PTS, MONOTONIC) подтверждает его потребление и продвигает часы.
|
||||
Desktop ACK приходит после paintEvent; Android — после SurfaceTexture/RenderThread commit, не по OnFrameRendered.
|
||||
Это подтверждения платформенного потребления, а не измерение физического scanout экрана.
|
||||
Срок следующей головы фиксируется один раз: последнее ACK + период format / speed - оценка dispatch-to-ACK.
|
||||
Срок следующей головы фиксируется один раз: фаза последнего показа + период format / speed
|
||||
минус оценка dispatch-to-ACK. Фаза плавно корректируется по ACK; большая пауза привязывает её к ACK.
|
||||
Оценка lead сглаживается и принимает обычный ACK за целый цикл экрана; выбросы больше 1.25 периода +1мс игнорируются.
|
||||
Пустая очередь сохраняет привязку: готовый через 12мс следующий кадр не показывается раньше своего обычного срока.
|
||||
Поздно пришедшая голова выходит сразу после готовности; разрыв capture PTS не повторяется ожиданием.
|
||||
@@ -1031,7 +1102,8 @@ Desktop ACK приходит после paintEvent; Android — после Surfa
|
||||
Опережение аудио по-прежнему ограничено 40мс; невалидные аудиочасы запрещают новый показ без очистки FIFO.
|
||||
|
||||
Windows ожидает абсолютный срок через high-resolution waitable timer + event, без spin/timeBeginPeriod.
|
||||
Отказ high-resolution API явно логируется, остаётся regular timer; полный отказ handles использует condition_variable.
|
||||
Отказ high-resolution API явно логируется, остаётся regular timer; при отсутствии необходимых handles
|
||||
desktop start отклоняет запуск видео. Runtime ошибка ожидания передаётся RX для лога и остановки workers.
|
||||
Stop и данные прерывают ожидание. Фактический overshoot остаётся в video RX worker wake_late_max_us.
|
||||
|
||||
CPU encoder: libx264 threads=4/sliced-threads=1; libx265 pools=4/wpp=1/frame-threads=1.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+182
-50
@@ -2,6 +2,76 @@
|
||||
|
||||
Статус: реализовано. Звонок использует роутер, CM подготавливает оптимальный путь.
|
||||
|
||||
Статус аудита 26.10.2026: проверка начата с голосовой сигнализации, call.c прочитан целиком.
|
||||
Выявленный дефект исходящего допуска исправлен: call_start отклоняет новую попытку при любой
|
||||
незавершённой обычной сессии или приглашении рации до создания session/CM/INVITE.
|
||||
Новый ID получает ENDED/BUSY; уже зарегистрированный ID, включая ENDED до очистки,
|
||||
отклоняется без событий, чтобы отказ не завершил прежний звонок. Новый уникальный ID после ENDED разрешён.
|
||||
Штатный test_call_admission на двух настоящих ядрах проверяет состояния обеих сторон,
|
||||
повторы ID, CHAT/DM, сохранение первого звонка/аудиоприёма, ENDED и взаимодействие с рацией.
|
||||
Полная сверка аудиостека, видео и платформенных разделов не завершена.
|
||||
Доказательства и вариант исправления: [call_audit_findings.txt](call_audit_findings.txt).
|
||||
|
||||
Продолжение аудита: полностью прочитаны call_audio/jitter/delay/timeline/tones.
|
||||
Дефект TX-счётчика при повторной привязке call_audio исправлен отдельной эпохой instance:
|
||||
задачи прежней привязки не меняют новый pending, не отправляют медиа и не записывают позднюю статистику.
|
||||
Смена звонка того же owner сохраняет учёт занятых слотов до callbacks; повтор init идемпотентен.
|
||||
Предел8 относится к действующей привязке; старые tasks освобождаются самим uasync при callback/destroy.
|
||||
Постоянные регрессии с настоящими Opus/uasync:13/13 PASS, голосовой C++ профиль4/4 PASS;
|
||||
со старым voice-архивом шесть новых сценариев выявляют прежний дефект.
|
||||
Это проверка внутренних posted tasks, аппаратный/сетевой звонок не воспроизводился.
|
||||
Реализация, проверенный план и границы — разделы6–8 отчёта; полный аудит call/ ещё не завершён.
|
||||
|
||||
Проверка headless: TCP-сервис call_headless имеет собственные кодеки instance и выполняет callbacks в uasync;
|
||||
он не использует DSP/jitter движка call_audio. Исправлен разрыв фрейма при переполнении исходящей очереди:
|
||||
header/PCM собираются до отправки, принимается или отбрасывается целый кадр, partial send сохраняет его хвост.
|
||||
PCM-потери учитываются отдельно для клиента/сервиса с WARN и итогом при закрытии/destroy.
|
||||
Постоянная регрессия с TCP/Opus/uasync:11/11 PASS, весь поток сравнивается с независимым decoder;
|
||||
старый код выявляется на границе очереди. Штатный e2e1/1 и check203/203 PASS,proxy9/9,burst1/1,load8/8.
|
||||
Реализация и границы проверки — раздел12 отчёта. Полная проверка платформенных I/O остаётся впереди.
|
||||
|
||||
Продолжение платформенной проверки: Android mute исправлен передачей исходного PCM и snapshot muted
|
||||
через JNI в call_audio_feed_pcm_muted. AEC/AGC продолжают обрабатывать микрофон, mute применяется перед Opus.
|
||||
Постоянная регрессия настоящего bridge/AEC/AGC/Opus:5/5 PASS, включая следующий unmute и старый ранний mute как контроль.
|
||||
Voice/desktop-профиль30/30, Android timing/trace unit14/14, assembleDebug arm64-v8a+x86_64 и check203/203 PASS.
|
||||
Это компонентная проверка и сборка; аппаратный Android-звонок не запускался. Диагностика — раздел13,
|
||||
внедрение и границы — раздел14 отчёта; полный аудит голосового/платформенного кода ещё не завершён.
|
||||
|
||||
Продолжение Android I/O: исправлено владение BluetoothHeadset proxy при позднем callback,
|
||||
disconnect сервиса и stop/start. Поколение запроса и общий монитор router защищают принятие/закрытие;
|
||||
disconnect сохраняет владение до closeProfileProxy. Постоянные JVM-регрессии15/15 PASS,
|
||||
включая64 конкурентных callback/stop; прежний исходник выявляется в трёх отказных сценариях.
|
||||
Android timing/trace/buffer/recovery26/26, voice/desktop30/30 и assembleDebug для двух ABI PASS.
|
||||
Это управляемая модель lifecycle API, без Bluetooth/Binder и акустической проверки телефона.
|
||||
Доказательства, полный профиль и ограничения — раздел15 отчёта; общий аудит остаётся частичным.
|
||||
|
||||
Продолжение сверки контрактов: call_clock/delay/jitter/jitter_window/timeline/tones.h проверены целиком,
|
||||
уточнены правила потоков, единицы/сброс счётчиков, поколения и ошибки часов. Исправлены устаревшие
|
||||
описания минимального запаса (20мс), Linux capture (базовый запрос 10мс) и существующей desktop-адаптации.
|
||||
Повторный voice/desktop-профиль30/30 PASS; реализации не менялись. Подробности и оставшаяся область — раздел16 отчёта.
|
||||
|
||||
При продолжении обнаружен и после согласования удалён неиспользуемый Android online-API,
|
||||
напрямую читавший uasync-очереди из вызывающего потока. Проверка доступности осталась внутри `call_start`;
|
||||
публичного межпоточного запроса больше нет. Android/JVM170/170, native/две ABI APK и check204/204 PASS.
|
||||
Диагностика, изменение и границы проверки — раздел17 отчёта; остальные частичные отметки сохранены.
|
||||
|
||||
Дефект регистрации call_audio после stop/start чата исправлен: заимствованный audio_ops хранится
|
||||
у UTUN_INSTANCE и переживает пересоздание call_ctx. Повторный init не требуется; идемпотентный init
|
||||
того же владельца сохраняет эпоху и занятые TX-слоты. Android worker привязывает voice до SERVICE_STARTED
|
||||
и снимает привязку перед уничтожением ядра, включая частичный запуск; GUI запускает только DSP.
|
||||
Новые восемь lifecycle-регрессий и общий voice/desktop-профиль38/38 PASS.
|
||||
Диагностика до исправления — раздел18, реализация и границы — раздел19 отчёта.
|
||||
|
||||
Следующая сверка закрыла публичные аудиоконтракты call.h/call_audio.h/call_headless.h/call_proto.h
|
||||
и desktop call_audio_engine.h. Уточнены сроки жизни callback/аргументов, фиксированный wire-формат,
|
||||
завершение и восстановление устройств. Изменения только в комментариях/doc; voice38/38 и четыре
|
||||
набора сигналинга/headless PASS. Подробности — раздел20 отчёта.
|
||||
|
||||
Сверка видеоконтрактов и внутреннего `call_priv.h` завершена: все21 заголовок `src/call/` закрыты,
|
||||
также проверены17 C/C++ видеозаголовков и `doc/call_video.txt` (разделы22–23 отчёта).
|
||||
Уточнены владение сессией/видеотреком, асинхронное завершение и смысл счётчиков; реализации не менялись.
|
||||
Общий аудит архитектуры и платформенных разделов остаётся частичным, новая аппаратная приёмка не выполнялась.
|
||||
|
||||
## 1. Область
|
||||
|
||||
Аудио и видео 1:1. Звонок можно инициировать **из CHAT-группы или из p2p-чата (DM)**.
|
||||
@@ -45,13 +115,16 @@ Android обычного звонка показывает своё уведом
|
||||
|---|---|---|---|
|
||||
| `src/call/call_proto.h` | C | везде | wire-формат сигналинга (`0x36`) и аудио (`0x3F`), subcmd |
|
||||
| `src/call/call.h` / `call.c` | C | везде (autotools + GUI) | сигналинг, реестр сессий, таймеры, watchdog (20с по приёму), релей медиа с backpressure, счётчики |
|
||||
| `src/call/call_audio.h/c` | C | только GUI-сборки | Opus encode/decode, фрейминг, pull-обвязка с тонами; регистрация через `call_audio_ops` |
|
||||
| `src/call/call_tones.h/c` | C | только GUI-сборки | генератор тонов (glitch/ended), синус |
|
||||
| `src/call/call_jitter.h/cpp` | C++ | только GUI-сборки | SoundTouch джиттер-буфер, C API |
|
||||
| `src/call/call_audio.h/c` | C | frontend с I/O и CMake-тесты | Opus, AGC, raw/prepared capture, pull с тонами; регистрация через `call_audio_ops` |
|
||||
| `src/call/call_tones.h/c` | C | общий voice-контур | генератор тонов (glitch/ended), синус |
|
||||
| `src/call/call_jitter.h/cpp` | C++ | общий voice-контур | FIFO Opus, сетевой запас, decode-on-pull; C API |
|
||||
| `src/call/call_delay.h/cpp`, `call_timeline.h/cpp` | C++ | общий voice-контур | обработка задержки PCM и карта исходного PTS до устройства |
|
||||
| `src/call/call_headless.h/c` | C | autotools и CMake core | отдельный TCP PCM-сервис с собственными кодеками, без call_audio DSP |
|
||||
| `src/call/call_video.h/c`, `call_video_transport.h/c` | C | везде | формат видеотрека, согласование возможностей и передача кадров отдельными каналами |
|
||||
|
||||
Autotools-сборка демона остаётся C-only (`.cpp` не компилируется); сигналинг `call.c`
|
||||
присутствует и в демоне, аудиопроцессинг — только там, где есть GUI.
|
||||
Autotools-сборка демона остаётся C-only (`.cpp` не компилируется): сигналинг `call.c`
|
||||
и headless TCP PCM присутствуют в ядре. Общий call_audio DSP собирается отдельно
|
||||
через `voice_sources.cmake` для desktop/Android и компонентных CMake-тестов.
|
||||
|
||||
Диагностика: категория `call` (`DEBUG_CATEGORY_CALL`); транспортная доставка — `etcp_route`.
|
||||
Desktop направляет сообщения FFmpeg и Qt в общий `debug_file` (по умолчанию `chatgui.log`).
|
||||
@@ -75,6 +148,10 @@ Desktop направляет сообщения FFmpeg и Qt в общий `debu
|
||||
|
||||
Формат приёма сигналинга и аудио: `entry->dgram[0]=svc_id`, служебный заголовок роутера до
|
||||
`ROUTER_SVC_PAYLOAD_OFF=26`, затем subcmd и тело команды.
|
||||
Структуры `call_proto.h` упакованы без padding; целые поля передаются в порядке байтов хоста.
|
||||
Пакет INVITE имеет фиксированные38 байт: ровно один descriptor AUDIO/OPUS, 48кГц mono, 32кбит/с.
|
||||
Согласования аудиоформата нет: поля descriptor не настраивают decoder, frontend создаёт его с фиксированными параметрами.
|
||||
`expires_us` отправляется как NTP-время now+2с; приём требует `now < expires_us <= now+4с`.
|
||||
|
||||
Сигналинг (`0x36`):
|
||||
|
||||
@@ -109,7 +186,7 @@ CALL_MEDIA 0x10 {call_id:8, track_id:1, seq:2, ts_ms:4, capture_pts_us:8, capt
|
||||
Надбавка к сервисным данным: nonce(13) + CRC(4) + CCM tag(16) + Ed25519(64) = 97 байт.
|
||||
- `CALL_MEDIA` переносит только аудио (`track_id=CALL_TRACK_AUDIO`); остальные значения приёмник отклоняет.
|
||||
Видео имеет отдельный формат и транспорт: `call_video.h`, `call_video_transport.h`, [call_video.txt](call_video.txt).
|
||||
- Аудио: Opus 48kHz mono, 20ms (960 сэмплов), ~24–32 kbps. ~50 кадров/с.
|
||||
- Аудио: Opus 48kHz mono, 20ms (960 сэмплов), целевой bitrate32кбит/с, ~50 кадров/с.
|
||||
- Заголовок медиа — 36 байт с subcmd. `ts_ms` — время отправки для оценки сетевого джиттера;
|
||||
`capture_pts_us` — время первого входного сэмпла в монотонной шкале отправителя, `INT64_MIN` при отсутствии метки.
|
||||
`capture_epoch` различает разрывы захвата; `codec_delay_samples` передаёт Opus lookahead.
|
||||
@@ -118,16 +195,16 @@ CALL_MEDIA 0x10 {call_id:8, track_id:1, seq:2, ts_ms:4, capture_pts_us:8, capt
|
||||
## 4. Машина состояний
|
||||
|
||||
```
|
||||
Caller: IDLE → OUTGOING(INVITE) → RINGING(RINGING) → ACTIVE(ACCEPT) → ENDED
|
||||
Callee: IDLE → INCOMING(INVITE) → RINGING(sent RINGING) → ACTIVE(accept) → ENDED
|
||||
Caller: IDLE → OUTGOING(INVITE) → [WAIT_DECISION(PHONE_BUSY) → OUTGOING(force)] → RINGING → ACTIVE(ACCEPT) → ENDED
|
||||
Callee: IDLE → [WAIT_DECISION, без INCOMING] → RINGING(INCOMING + sent RINGING) → ACTIVE(accept) → ENDED
|
||||
```
|
||||
|
||||
`ENDED` reasons: `LOCAL_HANGUP / REMOTE_HANGUP / DECLINE / BUSY / CANCEL /
|
||||
NO_TRAFFIC / RING_TIMEOUT / CONNECT_FAIL`.
|
||||
NO_TRAFFIC / RING_TIMEOUT / CONNECT_FAIL / PHONE_BUSY`.
|
||||
|
||||
Таймауты:
|
||||
- **кодограмма INVITE 300мс** — вызывающий шлёт её весь период вызова
|
||||
(`OUTGOING` + `RINGING`) как «сердцебиение»: покрывает потерю INVITE/RINGING и
|
||||
(`OUTGOING` + `RINGING` + `WAIT_DECISION`) как «сердцебиение»: покрывает потерю INVITE/RINGING и
|
||||
держит маршрут живым;
|
||||
- **watchdog 2с у вызываемого** — нет кодограммы 2с → вызов считается сорванным
|
||||
(отмена потерялась), сессия закрывается локально (`CALL_REASON_CANCEL`);
|
||||
@@ -144,8 +221,10 @@ NO_TRAFFIC / RING_TIMEOUT / CONNECT_FAIL`.
|
||||
Команда и ACK проверяются по подписи, шифрованию, размеру, call_id, пиру и группе существующей сессии.
|
||||
ACK не подтверждается. Новый звонок имеет другой call_id и не затрагивается поздними командами старого.
|
||||
При одновременном завершении стороны независимо подтверждают команды друг друга.
|
||||
После ACK сессия удаляется на ближайшем tick завершения; при удалённом завершении CM удерживается
|
||||
ещё 500мс для отправки ACK. Остановка ядра отменяет таймер без повторных отправок.
|
||||
После ACK сессия удаляется на ближайшем tick завершения (до300мс); без собственной завершающей команды
|
||||
при наличии CM handle либо роли callee запись удерживается2.1с (`CALL_RING_DEAD_TB+1000`).
|
||||
Это также сохраняет tombstone от позднего INVITE; константа `CALL_TEARDOWN_DELAY_TB=5000` не используется.
|
||||
Остановка ядра отменяет таймеры без повторных отправок и событий ENDED.
|
||||
Запоздалые кодограммы вызова при идущем разговоре (`ACTIVE`) игнорируются.
|
||||
|
||||
Онлайн-проверка: `topo_node_find_by_id(group, peer) == NULL` → «не в сети» (event).
|
||||
@@ -181,6 +260,9 @@ CM handle инициирует оптимальное подключение и
|
||||
## 6. Потоки и жизненный цикл
|
||||
|
||||
`call_session`, CM handle, очередь TX и таймеры принадлежат uasync-потоку.
|
||||
`call_init/destroy` относятся к сервису чата; заимствованный audio provider хранится у ядра и переживает stop/start чата.
|
||||
Отдельный `call_set_media_recv_cb` живёт в call_ctx, требует запущенный модуль и сбрасывается при его уничтожении.
|
||||
Trampoline освобождают переданный heap-аргумент, но не удерживают instance: владелец обеспечивает его срок жизни до callback.
|
||||
Аудиопоток кодирует PCM и постит `call_send_media`; приём в uasync кладёт Opus в `call_jitter`.
|
||||
Декодер, SoundTouch и генератор тонов принадлежат аудиопотоку.
|
||||
Кольцо кодированных кадров и снимок статистики защищены мьютексом; статистика не читает SoundTouch из другого потока.
|
||||
@@ -207,15 +289,30 @@ ACTIVE запрашивается до создания аудиоустройс
|
||||
Реальное время запуска/переключения зависит от Android и требует проверки на устройстве.
|
||||
Маршрутизатор использует один снимок входных/выходных устройств; обновляет его при старте,
|
||||
событиях устройств/BT-профиля и явной переоценке. По завершении снимок освобождается.
|
||||
CallAudioRouter используется также MediaAudioController: start может выполняться из I/O, Bluetooth callbacks — на main.
|
||||
start/stop и принятие/отключение BT-профиля сериализуются существующим монитором router.
|
||||
Каждый запрос getProfileProxy получает поколение; stop инвалидирует его до освобождения ресурсов.
|
||||
Поздний connected закрывает полученный proxy через создавший его adapter, не меняя новый lifecycle.
|
||||
Текущий proxy и adapter сохраняются до stop, в том числе после onServiceDisconnected:
|
||||
отключение сервиса не отменяет владение, а getConnectedDevices при недоступном сервисе возвращает пустой список.
|
||||
При stop ссылки отсоединяются до closeProfileProxy; старый disconnected после restart игнорируется.
|
||||
Пока принятый запрос не доставил proxy, закрыть его через публичный API нельзя: очистка выполняется при позднем connected.
|
||||
Логи CallAudioRouter содержат поколение, proxy, результат запроса, retained/closed/stale и ошибки API.
|
||||
INFO-логи Android показывают длительности настройки режима, регистрации callback, BT-профиля, выбора маршрута,
|
||||
создания/старта устройств, первого чтения микрофона и первой записи в AudioTrack, а также готовность другого направления.
|
||||
Общий `call_audio` логирует время ожидания первого PCM; запись в AudioTrack может содержать начальную тишину.
|
||||
Эффективный рабочий буфер AudioTrack на всех поддерживаемых API при старте запрашивается размером
|
||||
два блока PCM (40мс); на API 31+ стартовый порог следует фактически принятому размеру.
|
||||
Ёмкость выделенной памяти может быть больше. Ограничение повторяется по callback фактической
|
||||
смены выхода на main, под trackLock; callback старого AudioTrack игнорируется и снимается при stop.
|
||||
Подачу PCM задаёт свободное место в AudioTrack, без дополнительного таймера 20мс.
|
||||
Выход принадлежит playback worker. Для подтверждённого speaker на API31+ сначала пробуется AAudio:
|
||||
принимаются48кГц mono PCM16, LOW_LATENCY, burst1–480 и запрошенное устройство.
|
||||
Иначе используется AudioTrack; гарнитуры сразу идут через AudioTrack. Начальная заявка рабочего буфера —
|
||||
один блок PCM (20мс) для AAudio, два (40мс) для AudioTrack; стартовый порог AudioTrack на API31+ следует принятому размеру.
|
||||
Ёмкость выделенной памяти может быть больше. Callback на main фиксирует подтверждение выхода;
|
||||
повторное открытие и настройку выполняет playback worker, callback прежнего AudioTrack игнорируется и снимается при закрытии.
|
||||
Подачу PCM задаёт свободное место в устройстве, без дополнительного таймера 20мс.
|
||||
Частичная запись удерживает остаток одного блока до полной передачи; следующий pull ждёт.
|
||||
При смене устройства или восстанавливаемом отказе сохраняется ещё не принятый устройством хвост блока.
|
||||
Новый локальный playback head получает базу общего счётчика принятого PCM; прежняя очередь устройства
|
||||
не переигрывается и не считается доказанно прозвучавшей. Core clock приостанавливается в той же эпохе;
|
||||
новое подтверждение позиции возобновляет его, JB сохраняется.
|
||||
Сетевой запас остаётся в адаптивном JB, его рост и SoundTouch-догон не изменены.
|
||||
Лог `playback buffering` показывает причину настройки, запрошенный/принятый размер, порог и ёмкость;
|
||||
ежесекундный `audio summary` — queueUs, текущий порог и underruns; новый episode underrun имеет уровень WARN.
|
||||
@@ -243,8 +340,10 @@ Backend публикует только числовой снимок в огр
|
||||
Счётчик sequence описывает полученные backend-сэмплы; отсутствие потерь очереди само по себе
|
||||
не доказывает непрерывность физического захвата. Проверять вместе с hole/overflow и route-событиями.
|
||||
|
||||
Для захвата звонка desktop явно запрашивает период 20мс вместо дефолта PulseAudio 25мс;
|
||||
фактический период сообщает `audio: call-capture initialized`. Сборщик по-прежнему принимает произвольные чанки.
|
||||
Для захвата звонка desktop сохраняется общий запрос 20мс; `AudioDevice::requestLowLatency`
|
||||
для Linux/PulseAudio заменяет его запросом 10мс при каждом открытии, включая recovery/fallback.
|
||||
Фактический период сообщает `audio: call-capture initialized`: backend может округлить заявку.
|
||||
Сборщик принимает произвольные чанки и по-прежнему формирует кадры Opus/AEC по 960 сэмплов (20мс).
|
||||
Секундные замеры задержки при `call=debug`:
|
||||
- `voice-io: capture latency`: возраст первого сэмпла перед native feed (включает сам блок 20мс),
|
||||
подготовка/AEC в worker и вызов native feed; диапазоны min/avg/max в микросекундах.
|
||||
@@ -281,8 +380,8 @@ Backend публикует только числовой снимок в огр
|
||||
Возраст PCM содержит часть измеренной обработки: эти числа нельзя механически складывать.
|
||||
PTS/возраст используют локальный MONOTONIC, интервалы внутри native — локальный get_time_us/get_time_tb;
|
||||
эти временные базы и часы разных устройств напрямую не вычитаются. Все сводки формируются вне device callback.
|
||||
Период захвата 20мс и новые измерения требуют контрольного звонка; дополнительная desktop PCM-очередь,
|
||||
жизненный цикл подготовки Android и таймер показа видео этим изменением не менялись.
|
||||
Сравнительные Linux-замеры запроса 10мс и границы аппаратной проверки описаны в [linux_audio_recovery.md](linux_audio_recovery.md).
|
||||
Запрос периода устройства не меняет размер кадра Opus/AEC, жизненный цикл подготовки Android и таймер показа видео.
|
||||
|
||||
На Android `CallPlaybackBuffer` подбирает рабочую очередь отдельно от JB/SoundTouch:
|
||||
после 30с наблюдаемого продвижения playback head без underrun пробует уменьшить её на один
|
||||
@@ -292,16 +391,18 @@ PTS/возраст используют локальный MONOTONIC, интер
|
||||
После старта, запроса/подтверждения маршрута и увеличения даются 2с на стабилизацию;
|
||||
после уменьшения первый underrun учитывается немедленно. Отсутствие продвижения head или
|
||||
пропуск наблюдений >1с обнуляет стабильный участок. Сброс системного счётчика учитывается отдельно.
|
||||
Фактическая смена выхода начинает подбор с 40мс заново. Непринятая меньшая заявка фиксирует
|
||||
Открытие нового выхода начинает подбор с начальной заявки backend (AAudio20мс, AudioTrack40мс). Непринятая меньшая заявка фиксирует
|
||||
нижнюю границу до следующего выхода; ошибки API выключают подбор с WARN/ERROR, сохраняя воспроизведение.
|
||||
Reporter управляет размером под trackLock независимо от фильтра debug; playback не берёт этот lock.
|
||||
Нет flush/recreate, частичная запись PCM сохраняется. После смены размера/выхода через секунду
|
||||
Сам подбор размера не делает flush/recreate; смена выхода переоткрывает backend в playback worker.
|
||||
Частичная запись PCM сохраняется. После смены размера/выхода через секунду
|
||||
повторно измеряется задержка для AEC. Ожидание полного буфера и недостача сети не увеличивают его размер.
|
||||
Лог настройки содержит reason, previous/requested/actualFrames и прирост underruns;
|
||||
достижение ёмкости предупреждает не чаще раза в 10с. Принятие и стабильность 20мс ещё требуют
|
||||
проверки на Samsung; буферы HAL и Bluetooth могут добавлять собственную задержку.
|
||||
На desktop адаптация устройства ещё не реализована: выход принадлежит SoundManager,
|
||||
изменение backend-буфера должно выполняться его владельцем, а не отдельным звонком.
|
||||
На desktop voice-выход принадлежит SoundManager; `AudioDevice` при backend underrun/overflow
|
||||
может увеличить запрошенный период capture/voice-output и переоткрыть устройство через recovery.
|
||||
Это отдельная политика backend, без Android-проб уменьшения AudioTrack на один PCM-блок.
|
||||
|
||||
Диагностика Android при `call=debug`: playback сохраняет один числовой снимок на блок (20мс)
|
||||
в кольце 512 элементов, без форматирования/вывода. Отдельный reporter раз в 200мс забирает записи;
|
||||
@@ -339,22 +440,38 @@ Stop дожидается reporter, включая непрерываемый An
|
||||
|
||||
`call_jitter` независим от `radio_jitter`: рация сохраняет свои правила задержки и сброса.
|
||||
Буфер звонка хранит до 750 кодированных кадров (15с): 10с перебоя и 5с запаса.
|
||||
Целевой запас — `clamp(max-min, 60, max_reserve_ms)` мс по изменению задержки доставки:
|
||||
Целевой запас — `clamp(max-min, 20, max_reserve_ms)` мс по изменению задержки доставки:
|
||||
на входящем кадре накапливается разность интервалов приёма и отправки, десять секундных интервалов хранят её экстремумы.
|
||||
Отправитель уже передаёт timestamp в CALL_MEDIA; смещение часов пиров исключается, переполнение uint32 учитывается.
|
||||
Подготовка местного аудиоустройства и ускоренное воспроизведение очереди не изменяют оценку доставки.
|
||||
Минимум/максимум глубины очереди для GUI собираются отдельно в аудиопотоке за те же десять секунд.
|
||||
Верхний предел задаётся `call_jitter_max_reserve_ms`: 500–2000 мс, по умолчанию 1000 мс.
|
||||
Настройка есть в аудиопараметрах desktop и Android и применяется к следующему звонку.
|
||||
Начальное и повторное накопление ждёт этот запас; увеличение запаса не прерывает текущую речь.
|
||||
Начальное и повторное накопление ждёт этот запас плюс размер запрошенного PCM-блока;
|
||||
после первой полной выдачи остаётся целевой запас. Увеличение запаса не прерывает текущую речь.
|
||||
Ускорение зависит от превышения запаса. При отсутствии входящих кадров обычное воспроизведение
|
||||
может исчерпать запас: это не жёсткий запрет воспроизведения ниже цели.
|
||||
Статистика раз в секунду отображается в desktop-окне и Android-экране звонка (под кнопками): глубина, запас, минимум/максимум,
|
||||
темп, RTT, удаления при переполнении и недостачи PCM. Диагностика буфера — категория `call=debug`.
|
||||
После восстановления кадры последовательно декодируются; темп плавно растёт до 1.6x
|
||||
без изменения высоты голоса: линейный предел роста 1.0x→1.6x за 500мс воспроизведения.
|
||||
`CallDelay` имеет режимы Direct/Stretch/Drain. Direct выдаёт PCM напрямую и при необходимости
|
||||
удаляет отдельные сэмплы с ускорением до 2%, доводя разность входной/выходной позиций до границы 960 сэмплов.
|
||||
Это не синхронизация частот физических устройств. Нижняя огибающая за 150–200мс подавляет пилу пакетизации.
|
||||
При избытке не менее 120мс включается SoundTouch, при избытке не более 40мс либо низком запасе
|
||||
заканчивается выдача готового DSP-выхода, затем возвращается Direct с сохранением исходного PCM-хвоста.
|
||||
В SoundTouch темп плавно растёт до 1.6x без изменения высоты голоса:
|
||||
линейный предел роста 1.0x→1.6x за 500мс воспроизведения.
|
||||
При уменьшении накопления целевой темп снижается к 1.0x; скорость снижения ограничена тем же пределом.
|
||||
Пауза аудиоустройства не сбрасывает буфер. Переполнение удаляет старейший кадр с WARN и счётчиком.
|
||||
При временном отсутствии pull сетевой FIFO продолжает накапливаться. Явный reset playback I/O
|
||||
сбрасывает PCM/DSP/локальную эпоху часов и сохраняет только свежий сетевой запас Opus.
|
||||
Переполнение FIFO удаляет старейший кадр и увеличивает счётчик dropped.
|
||||
|
||||
Локальная epoch относится к playback-карте. Переоткрытие выхода с сохранением PCM-позиций
|
||||
использует suspend/progress в прежней epoch; полный playback reset создаёт новую epoch.
|
||||
`CallTimeline` принимает абсолютную позицию от reset и локальный срок её вывода.
|
||||
Якорь старше 250мс или suspend даёт `valid=0` до нового progress; регрессия позиции/времени наблюдения,
|
||||
невозможная позиция либо ошибка/переполнение карты запрещают часы до полного reset.
|
||||
PTS приходит со шкалы удалённого захвата, из него вычитается задержка Opus encoder.
|
||||
Локальные тишина/тоны удерживают PTS, удалённый mute продолжает шкалу источника.
|
||||
|
||||
Роутер сохраняет последовательность и неподтверждённые данные при смене пути.
|
||||
Для сервиса звонка срок без прогресса ACK увеличен примерно до 20с; прочие сервисы сохраняют прежний срок.
|
||||
@@ -384,16 +501,22 @@ SoundTouch: `USE_QUICKSEEK=1, SEQUENCE_MS=20, SEEKWINDOW_MS=10, OVERLAP_MS=4`.
|
||||
## 9. GUI-события (extend `chat_event.h` + `gui_bridge.h` + android)
|
||||
|
||||
```
|
||||
CHAT_EVT_CALL_INCOMING 32 [ch_id_len][ch_id][caller_node_id:8][call_id:8]
|
||||
CHAT_EVT_CALL_RINGING 33 [call_id:8]
|
||||
CHAT_EVT_CALL_ACCEPTED 34 [call_id:8]
|
||||
CHAT_EVT_CALL_ENDED 35 [call_id:8][reason:1]
|
||||
CHAT_EVT_CALL_DECLINED 36 [call_id:8][reason:1]
|
||||
CHAT_EVT_CALL_INCOMING 34 [ch_id_len:1][ch_id][caller_node_id:8][call_id:8]
|
||||
CHAT_EVT_CALL_RINGING 35 [call_id:8]
|
||||
CHAT_EVT_CALL_ACCEPTED 36 [call_id:8]
|
||||
CHAT_EVT_CALL_ENDED 37 [call_id:8][reason:1]
|
||||
CHAT_EVT_CALL_DECLINED 38 [call_id:8][reason:1]
|
||||
CHAT_EVT_CALL_STATS 39 [call_id:8][rtt:2][buffer:2][tempo:2][dropped:2][underruns:2][min:2][max:2][reserve:2]
|
||||
CHAT_EVT_CALL_ERROR 38 [call_id:8][err:1][text:var]
|
||||
CHAT_EVT_CALL_AUDIO_ROUTE 39 (Android: текущий маршрут/наличие гарнитуры)
|
||||
CHAT_EVT_CALL_ERROR 40 [call_id:8][err:1][text:var]
|
||||
CHAT_EVT_CALL_PATH 42 [call_id:8][text:var]
|
||||
CHAT_EVT_CALL_CONNECTION 44 [call_id:8][CONN_TYPE:1][next_hop_node_id:8]
|
||||
CHAT_EVT_CALL_PHONE_BUSY 66 [call_id:8][reserved:1]
|
||||
```
|
||||
|
||||
Это идентификаторы `chat_event.h`; desktop использует соответствующие `GUI_EVT_CALL_*`
|
||||
из `gui_bridge.h` либо общую константу CHAT. Android-маршрут устройства приходит через
|
||||
платформенные callbacks, отдельного общего события `CHAT_EVT_CALL_AUDIO_ROUTE` нет.
|
||||
|
||||
## 10. Основные функции публичного API
|
||||
|
||||
`call.h`: сигналинг и отправка готового Opus выполняются в uasync-потоке.
|
||||
@@ -405,11 +528,15 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod
|
||||
void call_accept(struct UTUN_INSTANCE* inst, uint64_t call_id);
|
||||
void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id);
|
||||
void call_hangup(struct UTUN_INSTANCE* inst, uint64_t call_id);
|
||||
int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id);
|
||||
int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len,
|
||||
const struct call_capture_time* timing);
|
||||
```
|
||||
|
||||
Онлайн-проверка перед созданием сессии — внутренняя функция `call.c`, выполняется в uasync.
|
||||
Группы и их очереди принадлежат этому потоку; чтение из GUI/JNI напрямую недопустимо.
|
||||
Неиспользуемый синхронный Android online-API удалён. `call_start` проверяет доступность
|
||||
при обработке команды и сообщает отказ через `CHAT_EVT_CALL_ERROR`.
|
||||
|
||||
`call_audio.h`: движок сам кодирует PCM и ставит отправку Opus в uasync.
|
||||
Захват и воспроизведение используют по одному аудиопотоку на направление.
|
||||
|
||||
@@ -421,7 +548,9 @@ void call_audio_release(uint64_t call_id);
|
||||
```
|
||||
|
||||
`feed_pcm` принимает ровно 960 отсчётов; `feed_prepared_pcm` — 960 отсчётов после платформенного AEC
|
||||
и маску mute. `call_audio_release` вызывается в GUI-потоке после остановки обоих направлений I/O.
|
||||
и маску mute. Для raw capture `feed_pcm_muted` принимает настоящий микрофон и флаг mute;
|
||||
AEC/AGC обрабатывают микрофон, перед Opus применяется mute. Android передаёт этот флаг вместе с исходным
|
||||
кадром через JNI; раннего зануления нет. `call_audio_release` вызывается после остановки обоих направлений I/O.
|
||||
Создание движка — `call_audio_start`, для подготовленного PCM — `call_audio_start_prepared`.
|
||||
Видео настраивается через `call_video_configure`, кадры отправляются через `call_video_send_frame`
|
||||
в uasync-потоке; полный контракт — в `call_video_transport.h`.
|
||||
@@ -431,9 +560,13 @@ void call_audio_release(uint64_t call_id);
|
||||
|
||||
## 11. Платформенный I/O
|
||||
|
||||
- **Desktop:** `tools/chatgui/src/call_audio_engine.cpp` — miniaudio full-duplex
|
||||
(capture через VoiceAudioIo → `call_audio_feed_prepared_pcm`, `call_audio_pull_pcm` → playback) + окно звонка (UI)
|
||||
+ события в `gui_bridge_impl.cpp`.
|
||||
- **Desktop:** `CallAudioEngine` управляет отдельным miniaudio capture и двумя workers `VoiceAudioIo`.
|
||||
Capture callback только копирует PCM; capture worker выполняет stereo-reference AEC и передаёт
|
||||
prepared PCM с маской mute в core AGC/Opus. RX worker независимо вызывает `call_audio_pull_pcm`.
|
||||
`SoundManager` смешивает голос с остальными источниками на общем выходе и возвращает тот же итоговый
|
||||
stereo PCM как AEC-reference. Stop закрывает capture, ждёт capture worker, отсоединяет выход,
|
||||
ждёт RX worker и освобождает core.
|
||||
При неисправном capture async close сохраняет объекты и lease до завершения uninit; RX остаётся активным.
|
||||
- **Android:** JNI `nativeCall*` + Kotlin `AudioRecord`/`AudioTrack`.
|
||||
**Переключение источника:** `AudioManager` (API31+ `setCommunicationDevice` +
|
||||
`availableCommunicationDevices`; fallback `setSpeakerphoneOn` + `startBluetoothSco`),
|
||||
@@ -442,16 +575,15 @@ void call_audio_release(uint64_t call_id);
|
||||
|
||||
## 12. Сборка
|
||||
|
||||
- `src/Makefile.am` + `tests/Makefile.am`: `call.c` (C, без `.cpp`). `src/call/` в include path.
|
||||
- `tools/chatgui/libutun/CMakeLists.txt`: +`call_audio.c`, `call_tones.c`,
|
||||
`audio_jitter.cpp`, soundtouch sources (AAFilter, FIFOSampleBuffer, FIRFilter,
|
||||
InterpolateCubic/Linear/Shannon, RateTransposer, SoundTouch, TDStretch, cpu_detect_x86,
|
||||
mmx_optimized, sse_optimized) как C++. `call.c` попадёт через GLOB `src/*.c`.
|
||||
- `tools/chatgui-android/libutun_lite/CMakeLists.txt` + `utun_sources.cmake`:
|
||||
`LANGUAGES C CXX`, те же файлы.
|
||||
- `src/Makefile.am` + `tests/Makefile.am`: сигналинг, headless PCM и C-модули видео; общий C++ voice DSP сюда не входит.
|
||||
- `tools/chatgui/libutun/CMakeLists.txt`: рекурсивный GLOB C-ядра, включая `call.c`/`call_headless.c`;
|
||||
`call_audio.c`/`call_tones.c` исключены из core. `tools/chatgui/CMakeLists.txt` создаёт `utun_voice`
|
||||
по общему `src/call/voice_sources.cmake`: call_audio/tones, call_jitter/delay/timeline, radio/stream voice и SoundTouch.
|
||||
- Android `app/src/main/cpp/CMakeLists.txt` использует тот же `voice_sources.cmake`;
|
||||
`libutun_lite/utun_sources.cmake` исключает общий voice-контур из списка C-ядра.
|
||||
- `src/transport_layer/etcp_api.h`: `ETCP_RT_ID_CALL=0x36`, `ETCP_RT_ID_CALL_VIDEO_CONTROL=0x3D`,
|
||||
`ETCP_RT_ID_CALL_VIDEO=0x3E`, `ETCP_RT_ID_CALL_AUDIO=0x3F`.
|
||||
- `lib/debug_config.h`: `DEBUG_CATEGORY_CALL 31`, `DEBUG_CATEGORY_COUNT` → 32.
|
||||
- `lib/debug_config.h`: `DEBUG_CATEGORY_CALL`; актуальные категории и их число брать из этого header.
|
||||
|
||||
## 13. Порядок задач
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
Аудит config_parser — 26.10.2026
|
||||
|
||||
Полностью прочитаны config_parser.c/.h, config_parser_doc.md и общий config_ini.h.
|
||||
Для согласования строгого разбора также повторно сверены config_updater.c/.h/_doc.md.
|
||||
Разбор сохраняет модель и владение free_config; запуск ресурсов остаётся у instance.
|
||||
|
||||
Исправления
|
||||
- IPv6 /32 больше не подменяется /128; префиксы проверяются до сужения к uint8_t.
|
||||
Control allow=/0 задаёт нулевую маску без сдвига на 32; IPv6 control allow отклоняется.
|
||||
- Ошибки известных обработчиков и выделения памяти отклоняют всю модель.
|
||||
Все частичные списки освобождаются, включая client links и не завершённую секцию.
|
||||
- Числа проверяются целиком до преобразования: знаки, хвост, диапазон и overflow.
|
||||
Keepalive interval помещается в uint16_t; таймеры ограничены до умножения.
|
||||
Bool унифицирован: 0/1, no/yes, false/true. Версия — ровно три десятичных октета.
|
||||
- Адресные порты проверяются как decimal 0..65535, SOCKS — 1..65535.
|
||||
Control ip/port даёт одинаковый результат в обоих порядках; ошибки getaddrinfo не теряются.
|
||||
- Storage sizes проверяют UINT64_MAX, множитель M/G и полный хвост; -1 не становится безлимитом,
|
||||
переполнение не становится нулевым лимитом. Слишком длинные строковые настройки отклоняются.
|
||||
- Network ID ограничен 56 битами; требуются оба 32-byte HEX ключа.
|
||||
Предоставленные ключи идентичности/пиров проверяются целиком; пустые необязательные остаются незаданными.
|
||||
- CFG_CLIENT_LINK сохраняет имя local_srv. Повторный обычный addr сбрасывает режим dynamic IPv6.
|
||||
- Флаг tcp_proxy_client enabled=no не включается снова следующей опцией той же секции.
|
||||
Наличие service-секции задаёт её default enable и при отсутствии опций.
|
||||
- Print_config не печатает private/signing keys; Networks выведены за пределы firewall-цикла.
|
||||
Устаревший append ключей проверяет вход, запись и fclose; не объявляется атомарным updater.
|
||||
- Updater выбирает итоговую идентичность/REALITY по последнему вхождению, но исправляет также
|
||||
синтаксически плохие ранние дубли выбранными значениями. Из-за такого дубля ключи не генерируются заново.
|
||||
Это нужно, поскольку строгий парсер проверяет каждое вхождение. Булевы REALITY enabled согласованы.
|
||||
|
||||
Контракты и ограничения
|
||||
Обновлены заголовок и документ: порядок списков, необъединяемые named-секции, remote-only links,
|
||||
синхронный DNS и первый getaddrinfo-адрес, Windows socket init, default-значения и границы,
|
||||
повторные опции, поведение неизвестных секций/debug/gui и независимость parser/updater/start.
|
||||
Bootstrap invite printer использует конфигурационные адреса и нулевые channel_id/join_key;
|
||||
это не снимок фактических bind/NAT-адресов и не invite конкретного канала.
|
||||
Нативные сборки/runtime Windows и FreeBSD в этом этапе не выполнялись.
|
||||
|
||||
Проверки
|
||||
- Полный check.sh: 195/195 PASS, 0 failed/0 skipped, proxy9/9, burst1/1, load8/8,
|
||||
134217728 байт, 169 секунд; /tmp/config-instance-audit-first-check.log.
|
||||
- Профиль 6/6 PASS: config_parser, config_updater, config_debug, reality_config, ncd_config, services.
|
||||
- Test_config_parser: 53 ошибочных конфигурации отвергнуты одинаково из файла/буфера;
|
||||
10 отказов аллокаций с полным rollback, IPv4/IPv6 CIDR и control order, предельные размеры/порты,
|
||||
скрытие секретов и /dev/full при append; баланс памяти сохранён.
|
||||
- ASAN/UBSan/LSan: те же 6/6 PASS с detect_leaks=1/ halt_on_error=1.
|
||||
Все встроенные lib/src/Opus инструментированы согласованно в отдельной копии
|
||||
/tmp/utun-config-instance-asan-20261026; рабочие объекты не смешивались с sanitizer-объектами.
|
||||
Первый updater sanitizer-прогон обнаружил обход test mock через glibc __fread_chk;
|
||||
checked wrapper добавлен, повторный прогон всех файловых и allocation faults PASS.
|
||||
- Логи: /tmp/config-parser-audit-contract.log, /tmp/config-parser-audit-updater.log,
|
||||
/tmp/config-parser-audit-debug.log, /tmp/config-parser-audit-reality.log,
|
||||
/tmp/config-parser-audit-ncd.log, /tmp/config-parser-audit-services.log.
|
||||
Санитайзеры: соответствующие /tmp/config-parser-audit-asan-*.log,
|
||||
сборка /tmp/config-parser-audit-asan-build.log и /tmp/config-parser-audit-asan-tests-build.log.
|
||||
Общая сборка: /tmp/config-instance-audit-full-build.log.
|
||||
+150
-12
@@ -44,19 +44,35 @@ AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная пор
|
||||
Автор подписывает все поля до sig. conv связывает автора с получателем.
|
||||
Для `ct=file` перед подписью добавляется descriptor `[UUID:16][plaintext_size:8][SHA256(ciphertext):32]`.
|
||||
В зашифрованном теле находится `attachment_info`; пути хранения строятся только из UUID.
|
||||
Размер plaintext сообщения ограничен 1024 байтами. seq — положительное int64.
|
||||
Размер data в API отправки ограничен 1024 байтами; для file сюда входит открытый
|
||||
descriptor (56 байт), поэтому на зашифрованные метаданные остаётся до 968 байт.
|
||||
Парсер входящего wire допускает до 1024 байт ciphertext plaintext отдельно от descriptor.
|
||||
seq — положительное int64, ts — миллисекунды NTP-времени instance.
|
||||
|
||||
```text
|
||||
[kind:1][name_len:2][duration_ms:4][width:2][height:2][UTF8_name:name_len][waveform:100, только voice]
|
||||
```
|
||||
|
||||
Поля метаданных многобайтной длины имеют big-endian формат. kind: 1=file,
|
||||
2=voice, 3=video. Имя — basename до 255 байт, без разделителей, управляющих символов
|
||||
2=voice, 3=video, 4=JPEG, 5=PNG. Имя — basename до 255 байт, без разделителей, управляющих символов
|
||||
и некорректного UTF-8. FILE имеет нулевые duration/dimensions; VOICE — положительную
|
||||
длительность и 100 символов уровней waveform; VIDEO — длительность и размеры.
|
||||
JPEG/PNG имеют размеры с учётом EXIF, нулевую длительность и пустой waveform;
|
||||
MIME `image/jpeg`/`image/png` определяется kind и не зависит от имени файла.
|
||||
Desktop и Android проверяют формат и размеры средствами своего декодера до постановки
|
||||
в `attachment_send`; исходные байты изображения сохраняются без перекодирования.
|
||||
В сообщении показаны превью, разрешение и размер, нажатие открывает просмотр с масштабированием.
|
||||
Длительность ограничена 24 часами, размеры — 16384. Метаданные проверяются до
|
||||
локального commit и до подтверждения получателем; S/M видят только ciphertext.
|
||||
|
||||
Имя собеседника ограничено `DM_PEER_NAME_MAX` (63 байта без NUL): создание,
|
||||
загрузка и снимок бесед сохраняют только целые UTF-8 code points. Для 32 букв «Я»
|
||||
сохраняется 31 буква (62 байта). При повреждённой кодировке, включая NUL внутри
|
||||
SQL TEXT, остаётся корректный префикс; WARN содержит conv_id, смещение и длину.
|
||||
Снимок нормализует также имя из nodes при пустом peer_name. Чтение не меняет БД;
|
||||
при следующей штатной записи беседы нормализованное имя сохраняется вместе с ней.
|
||||
Подробности исправления: [dm_audit_findings.txt](dm_audit_findings.txt), раздел9.
|
||||
|
||||
Медиа ciphertext — последовательность порций с отдельным tag каждой порции.
|
||||
Последняя порция короче; пустой файл содержит один tag. Размер plaintext и SHA256
|
||||
ciphertext должны входить в подписанный manifest. Потоковая расшифровка проверяет все
|
||||
@@ -66,6 +82,7 @@ tags, точный размер и хеш; владелец публикует
|
||||
## Владение и данные
|
||||
|
||||
- `dm_core`: беседы, история, outbox и подтверждение получателя.
|
||||
- `dm_history`: поколения, hidden, точные отпечатки удалённых сообщений и манифест локальных путей.
|
||||
- `dm_mailbox`: промежуточные сообщения суперузла и квитанции.
|
||||
- `dm_crypto`: вывод conv_id, content_key и nonce сообщений без БД/сети.
|
||||
- `media_crypto`: общие CCM и потоковые файловые примитивы для DM и групповых вложений.
|
||||
@@ -83,6 +100,9 @@ dm_messages(conv_id, dir, seq, ts, author, ct, data, sig)
|
||||
dm_outbox(conv_id, seq, body, retry_at)
|
||||
dm_pending_messages(recipient, sender, seq, msg, group_id, retry_at)
|
||||
dm_mail_receipts(recipient, sender, seq, receipt, group_id)
|
||||
dm_history(conv_id, generation, hidden)
|
||||
dm_deleted_messages(conv_id, dir, seq, body_hash, media_id, media_receipt)
|
||||
dm_local_files(path, conv_id, generation, busy, retry_at)
|
||||
```
|
||||
|
||||
Запись исходящего сообщения, увеличение seq и запись неизменяемого body в outbox —
|
||||
@@ -93,10 +113,41 @@ Dedup — точный ключ (conv,dir,seq) с проверкой сохра
|
||||
статистика максимального номера, не курсор подтверждения: seq=99 после seq=100
|
||||
сохраняется. Тело с другой подписью для уже сохранённого seq отклоняется.
|
||||
|
||||
У каждой локальной очереди таймер 1с; порция обработки до 32 задач, повтор задачи
|
||||
через 5с. retry_at предотвращает голодание хвоста очереди. Перед отправкой проверяется
|
||||
backpressure router. Потеря пути сохраняет durable задачу; остановка отменяет таймеры,
|
||||
но сохраняет БД. При старте очередь продолжает работу.
|
||||
Локальное удаление (`dm_history_delete`) сохраняет запись беседы, ключи и счётчики,
|
||||
скрывает её (`dm_history.hidden`) и увеличивает постоянное `generation`.
|
||||
В одной транзакции удаляются история, outbox и локальные media-записи, а SHA256
|
||||
точных удалённых тел сохраняется в `dm_deleted_messages` по `(conv_id,dir,seq)`.
|
||||
Файловая квитанция сохраняется только если она уже была получена; удаление само её не создаёт.
|
||||
После commit отменяются незавершённые операции; физическая уборка файлов выполняется отдельно.
|
||||
Это локальная операция: история адресата и промежуточные копии ею не отзываются.
|
||||
|
||||
Точный входящий удалённый повтор получает квитанцию сообщения без повторного создания
|
||||
истории/файла и без открытия скрытой беседы. Иное тело с тем же ключом удалённой записи
|
||||
отклоняется. Впервые доставленное сообщение, включая меньший seq, открывает беседу;
|
||||
явный `dm_start` также снимает hidden без смены поколения. Отложенные отправки
|
||||
проверяют generation, чтобы старый запрос не попал в повторно открытую беседу.
|
||||
|
||||
Загрузка беседы различает `FOUND`, `NOT_FOUND` и `ERROR`: отсутствие означает
|
||||
только успешно завершённый SELECT без строки. Ошибка SQL, повреждённые ключи
|
||||
или отказ X25519 прекращают открытие/приём/отправку/приглашение без пересоздания
|
||||
беседы и сброса счётчиков. Полный снимок выдаётся только после успешной загрузки.
|
||||
Исправление и отказные проверки описаны в [dm_audit_findings.txt](dm_audit_findings.txt).
|
||||
|
||||
Таймеры работают раз в 1с: outbox A и текстовая очередь S обрабатывают до 32 задач;
|
||||
локальные файлы endpoint, custody M и media jobs S — до 8 задач каждый.
|
||||
Обычный retry_at — через 5с; исходящий файл с квитанцией проверяется через 3600с,
|
||||
неудачная уборка локального пути повторяется через 30с. Это время допуска следующей
|
||||
попытки, не гарантия доставки за указанный срок. Перед отправкой проверяется backpressure router.
|
||||
Потеря пути сохраняет durable задачу; stop отменяет таймеры, но сохраняет БД.
|
||||
При старте медиа сбрасывает retry_at/downloading; текстовые очереди используют сохранённый retry_at.
|
||||
|
||||
Все DM API вызываются в uasync-потоке. Порядок запуска в `chat_service_start`:
|
||||
mailbox (включая media coordinator), core/history, media/file_transfer; к этому времени
|
||||
SQLite, router и media_async уже живы. Порядок остановки: media_quiesce прекращает
|
||||
новые задачи и закрывает transfer/CM handles; media_async_destroy завершает workers
|
||||
и callbacks с живыми owners; затем уничтожаются media, core и mailbox, после них группы/чат.
|
||||
SQLite принадлежит ядру и остановкой DM не закрывается. `dm_media_destroy` при живой
|
||||
работе логирует ошибку и оставляет owner; заменять drain таким вызовом нельзя.
|
||||
|
||||
## Подтверждения и протокол
|
||||
|
||||
@@ -147,24 +198,69 @@ TTL текстовых сообщений не введён; недельный
|
||||
3. Суперузел сначала durable регистрирует сообщение и media job, затем выбирает M.
|
||||
M скачивает ciphertext у A и сообщает S подтверждённое хранение, место и expiry.
|
||||
4. Текстовая квитанция и квитанция медиа независимы. Получатель подтверждает медиа
|
||||
после проверки, публикации файла и commit. S хранит задачу удаления у M до DELETE_ACK;
|
||||
поздний STORED не возрождает завершённую доставку.
|
||||
после проверки, публикации файла и commit. S хранит задачу удаления у M до DELETE_ACK
|
||||
либо завершения подтверждённого TTL назначения; поздний STORED не возрождает доставку.
|
||||
5. TTL медиа `dm_media_ttl_sec` по умолчанию 604800 с; квота `dm_media_storage_mb`
|
||||
по умолчанию 1024 МиБ. Настройки на M. Новая custody отклоняется при
|
||||
недостатке квоты; уже подтверждённые копии не вытесняются молча раньше срока.
|
||||
6. Stop снимает новые задачи и закрывает transfer/CM handles, затем ждёт
|
||||
media_async с живыми owner-контекстами. Группы уничтожаются после owners.
|
||||
|
||||
Control `ETCP_RT_ID_DM_MEDIA=0x38`: OFFER, STORE, STORED, RECEIPT, DELETE,
|
||||
DELETE_ACK, EXPIRED, REJECT; точные форматы заданы в `dm_media.h` и реализациях owners.
|
||||
При проверке квоты M ошибки SQL чтения занятого места логируются и прекращают
|
||||
обработку STORE без `REJECT` и регистрации custody. Назначение M сохраняется на S;
|
||||
следующий STORE приходит через существующий таймер повторов. Квота сравнивается
|
||||
только после получения строки SUM и успешного finalize. Реальный недостаток места
|
||||
вызывает `REJECT` и выбор другого M. Исправление и регрессии:
|
||||
[dm_audit_findings.txt](dm_audit_findings.txt), раздел6.
|
||||
|
||||
Освобождение назначения на S по EXPIRED/REJECT и сброс текущего holder выполняются
|
||||
в одной транзакции. Ошибка prepare/bind/step/finalize/commit откатывает оба изменения.
|
||||
Подтверждённое назначение завершается также таймером по сохранённому expires:
|
||||
потерянный EXPIRED и offline B не оставляют stored/holder навсегда. После restart
|
||||
таймер берёт те же durable факты. Если STORE ещё не подтверждён, S продолжает повторять
|
||||
его; M возвращает STORED/EXPIRED из custody/tombstone. Квитанция B сохраняет приоритет
|
||||
над TTL; старый control другого M не сбрасывает новое текущее назначение.
|
||||
Текстовая история, подписи и сроки хранения M этим переходом не меняются.
|
||||
Подробности и регрессии: [dm_audit_findings.txt](dm_audit_findings.txt), раздел11.
|
||||
|
||||
Control `ETCP_RT_ID_DM_MEDIA=0x38`: перед body идёт один байт команды.
|
||||
Все многобайтные поля DM/control имеют native-endian порядок; исключение —
|
||||
зашифрованный attachment_info с big-endian полями, описанный выше.
|
||||
|
||||
| Команда | Body |
|
||||
| --- | --- |
|
||||
| OFFER=1 | recipient:8, holder:8, expires:8, file-message |
|
||||
| STORE=2 | recipient:8, file-message |
|
||||
| STORED=3 | UUID:16, expires:8 |
|
||||
| RECEIPT=4 / DELETE=5 | файловая квитанция:152 |
|
||||
| DELETE_ACK=6 / EXPIRED=7 | UUID:16 |
|
||||
| REJECT=8 | UUID:16, reason:1 (1 — недостаток квоты) |
|
||||
|
||||
Подпись router обязательна. Media receipt содержит `DMFILE01`, conv, seq, author,
|
||||
recipient, UUID и SHA256 полного подписанного сообщения; B подписывает первые 88 байт.
|
||||
Отдельные таблицы `dm_files`, `dm_custody`, `dm_custody_done`, `dm_media_jobs`,
|
||||
`dm_media_holders` сохраняют состояния и tombstones. Все выданные grants M
|
||||
учитываются при удалении; повторный STORE не продлевает TTL.
|
||||
|
||||
Qt и Android получают историю через события ядра: 41 — conv + JSON сообщений,
|
||||
45 — JSON бесед. Снимки ограничены 200 записями. Отправка вложения вызывает
|
||||
A предлагает собственный файл с holder=A и expires=0. S выбирает M с storage=yes
|
||||
в общей группе A/B, исключая A/B и все прежние назначения этого UUID. Назначение
|
||||
фиксируется до STORE; S/M могут быть одним узлом, local control обрабатывается синхронно.
|
||||
Успешная передача control в router или локальный обработчик не означает durable успех.
|
||||
|
||||
EXPIRED относится к промежуточной копии. B помечает ещё не готовый файл expired,
|
||||
но продолжает попытки получить оригинал у A. После проверки исходного ciphertext,
|
||||
публикации и сохранения квитанции файл снова ready; поздний EXPIRED его не меняет.
|
||||
Готовый файл A/B остаётся в локальной истории до явного удаления беседы.
|
||||
При удалении busy-счётчик каждого пути удерживает его до callback worker/transfer;
|
||||
уборщик работает по старым поколениям, использует busy=-1 для своего захвата
|
||||
и синхронизирует каталог после удаления. Пользовательский исходник не входит в уборку,
|
||||
если он не был явно передан как временный файл приложения.
|
||||
|
||||
Qt и Android получают историю через события ядра: 41 —
|
||||
`[conv_len:1][conv:decimal][generation:8][JSON сообщений]`, 45 — JSON бесед.
|
||||
Запрос истории с ненулевым generation читается только для текущей видимой беседы;
|
||||
0 захватывает текущее поколение в uasync. UI отбрасывает снимок другого поколения.
|
||||
Снимки ограничены 200 записями. Отправка вложения вызывает
|
||||
`dm_send_file` в uasync; длительная обработка выполняется в `media_async`.
|
||||
Android копирует выбранный URI и сохраняет полученный файл через `Dispatchers.IO`;
|
||||
Qt сохраняет файл из контекстного меню в отдельном потоке. Выбор «как файл» сохраняет
|
||||
@@ -194,6 +290,23 @@ Android использует общие `ConversationComposer`/`ConversationMess
|
||||
|
||||
## Проверки
|
||||
|
||||
Аудит контрактов DM завершён 27.10.2026: все семь заголовков `src/dm/`, этот документ,
|
||||
`pm.txt` и `pm_invites.md` сверены с реализациями owners и относящимися к ним callers.
|
||||
Это проверка соответствия документации; свежая интерактивная проверка клиентов и
|
||||
новые платформенные сборки не выполнялись. Подробности и границы проверки —
|
||||
[dm_audit_findings.txt](dm_audit_findings.txt), раздел 12.
|
||||
Первоначальная проверка отказа SQLite выявила сброс счётчиков; дефект исправлен.
|
||||
`test_dm_load` проверяет 79 отказных сценариев: чтение, bind/step/finalize, короткие
|
||||
ключи и X25519; открытие/приём/отправку/JSON/invite до и после удаления; отдельные
|
||||
отказы подготовки файла, отсутствие беседы, restart и продолжение seq. При отказе
|
||||
сравниваются все поля DM-таблиц и отсутствие SQL-записей/событий успеха.
|
||||
|
||||
`test_dm_names`: 68 случаев с точным сравнением имени в SQL и JSON при создании,
|
||||
загрузке, штатной записи, fallback из nodes и перезапуске. ASCII и 2/3/4-байтовые
|
||||
символы проверяются до/на/за границей 63 байт; отдельно — overlong, surrogate,
|
||||
значения выше U+10FFFF, неверные/оборванные последовательности, NUL и JSON-экранирование.
|
||||
Снимки не меняют SQL; 273 полных JSON сохраняются для независимого строгого парсера.
|
||||
|
||||
`test_dm`: симметрия ключа target, CCM round-trip, пустое тело, пределы длины,
|
||||
неверный ключ/tag, разделение nonce; потоковые файлы на границах 32 КиБ,
|
||||
неверные author/UUID/hash, обрезание и лишние байты.
|
||||
@@ -210,17 +323,42 @@ ACK чужого тела, отказ mailbox без роли, точное уд
|
||||
SQL rollback регистрации, состояния JSON и отмена операции с CM handle.
|
||||
Голосовое передаётся напрямую; метаданные видео сохраняются после offline custody
|
||||
и restart S/M при выключенном источнике.
|
||||
В Linux дополнительно проверяются prepare/step/finalize и неожиданный SQLITE_DONE
|
||||
при подсчёте места, отсутствие REJECT/STORED и SQL-записей при ошибке; три сбоя
|
||||
в первых трёх STORE, успешный четвёртый STORE на том же M и последующая доставка
|
||||
после restart. Проверка настоящей квоты требует один REJECT до регистрации custody.
|
||||
Дополнительно — 18 отказов освобождения EXPIRED/REJECT: prepare/bind/step/finalize
|
||||
обоих UPDATE и commit, точный rollback назначения/job, успешный повтор, дубликат
|
||||
и поздний STORED. Сетевой сценарий теряет EXPIRED, блокирует SQL-сброс, перезапускает S
|
||||
и проверяет восстановление из сохранённого expires, состояние M и отсутствие ложной квитанции.
|
||||
На M проверяются 11 SQL-отказов retirement: rollback сохраняет custody и не выдаёт ACK;
|
||||
после restart M/S потерянный DELETE_ACK восстанавливается по tombstone и повтору DELETE.
|
||||
После EXPIRED и возврата A проверяются точный оригинал на B, настоящая файловая
|
||||
квитанция, очистка S и невлияние позднего EXPIRED на готовый файл.
|
||||
|
||||
`test_dm_worker`: повторное шифрование 8 МиБ не менее 0,5 с и проверка расшифровки;
|
||||
work выполняется вне uasync, done в его потоке, heartbeat продолжает работать.
|
||||
Destroy отменяет уже posted completion, callback вызывается ровно один раз.
|
||||
|
||||
Linux `test_dm_media_cleanup`: 13 отказов SQL при чтении назначений, записи ACK
|
||||
и очистке body; сохранность квитанции, повтор, restart S, потерянный/повторный/чужой ACK,
|
||||
несколько M и поздний STORED. Сеть в этом component-тесте перехвачена; подпись B и
|
||||
реальное удаление M проверяет `test_dm_media`. Ошибка finalize после успешного
|
||||
autocommit не означает rollback уже выполненной одношаговой записи.
|
||||
|
||||
`test_attachment`: строгий формат, повреждения и граничные длины, реальное
|
||||
кодирование/декодирование 50 Opus-кадров, длительность и waveform.
|
||||
`test_attachment_send`: выбранная беседа после смены текущего target, освобождение
|
||||
PCM в worker, остановка до completion без регистрации и удаление отклонённого cache.
|
||||
Qt `test_voice_file`: реальные PCM/Opus, повреждённые файлы и отмена async decoder.
|
||||
|
||||
Заключительный прогон 27.10.2026: штатные `test_dm`, `test_dm_history`, `test_dm_names`,
|
||||
`test_dm_load`, `test_dm_e2e`, `test_dm_invite`, `test_dm_media`, `test_dm_worker`
|
||||
и Linux `test_dm_media_cleanup` — 9/9 PASS.
|
||||
Отдельный строгий JSON-парсер прочитал все 273 снимка имён. Заголовки проверены в C99/C++20;
|
||||
в заключительном этапе менялись только документация, комментарии и форматирование деклараций.
|
||||
Результаты старых сборок ниже относятся к указанной дате, а не к этому прогону.
|
||||
|
||||
Проверка 01.10.2026: чистые сборки Qt и Android APK; полный `check.sh` —
|
||||
117 PASS, 0 FAIL, 1 SKIP (auto_socket_dynamic требует root), proxy/burst/load PASS.
|
||||
Qt CTest: 7 PASS, 0 FAIL; Android APK собран после `clean`.
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
DM — аудит документации и контрактов, 26–27.10.2026
|
||||
|
||||
База: /home/user/utun2, HEAD 03b050949cf36cc4868324be94a54eaddfdd494c.
|
||||
Задание: /home/user/utun-doc-audit-checklist.txt, раздел src/dm/ и связанные документы.
|
||||
|
||||
Текущий статус на 27.10.2026: аудит контрактов DM завершён, 10/10 файлов [V].
|
||||
Итог — раздел12; разделы1–11 сохраняют историю промежуточных проверок и исправлений.
|
||||
|
||||
1. Объём и статус
|
||||
|
||||
Полностью прочитаны dm_core.c/.h, dm_crypto.c/.h и dm_history.c/.h, doc/dm_arch.md,
|
||||
doc/pm.txt и doc/pm_invites.md. Просмотрены заголовки media/mailbox owners, lifecycle
|
||||
в utun_instance и участок media_forget_prepare/committed. Это не полный аудит этих owners.
|
||||
dm_crypto.h [V]; dm_core.h/dm_history.h/doc/dm_arch.md [~].
|
||||
dm_mailbox.h/dm_mailbox_media.h/dm_media.h/dm_media_priv.h сохраняют [ ].
|
||||
doc/pm_invites.md сохраняет [~]; doc/pm.txt [ ]: нормативные требования прочитаны,
|
||||
но соответствие всей доставки и хранения пока не проверено.
|
||||
|
||||
Уточнены контракты nonce, native-endian wire/длин, lifecycle, commit исходящей задачи,
|
||||
JSON и локального удаления/поколений. В pm_invites исправлен формат события результата:
|
||||
36 байт с generation вместо прежних 28. Платформенные реализации целиком не проверялись.
|
||||
|
||||
2. Исходный дефект: ошибка загрузки сбрасывает счётчики существующей беседы
|
||||
|
||||
dm_conv_load возвращает -1 при отсутствии строки и при ошибках prepare/step,
|
||||
повреждённых ключах либо отказе X25519. dm_start проверяет только ==0, а иначе
|
||||
обнуляет dm_conv, заполняет ключи и сохраняет через dm_conv_save.
|
||||
ON CONFLICT обновляет last_out_seq/last_in_seq/last_ts из обнулённой структуры.
|
||||
Ошибка чтения тем самым превращается в успешную запись с потерей счётчиков.
|
||||
|
||||
Проверка использует настоящие utun_core/chat_service, SQLite и OpenSSL, отдельные
|
||||
БД в /tmp, штатные dm_start/dm_send/dm_accept_message. Исходные сообщения принимаются
|
||||
обычным публичным API. SQLite authorizer однократно отказывает в чтении
|
||||
dm_conversations.peer_node_id; последующие операции разрешены. Production-файлы,
|
||||
архивы и объекты для воспроизведения не подменялись.
|
||||
|
||||
Положительный контроль: повторный dm_start без отказа сохраняет out=1,in=2.
|
||||
При одном SQLITE_DENY: dm_conv_load пишет ERROR с точной причиной, затем dm_start
|
||||
пишет INFO started, возвращает 0; out=1->0,in=2->0, обе строки dm_messages остаются.
|
||||
Проверка ожидаемого отказа/сохранности даёт FAIL, exit2; tracked allocations0->0.
|
||||
Существующей диагностики вместе с журналом инъекции и SQL-снимком достаточно
|
||||
для однозначного вывода: это ошибка ветвления, не ошибка расшифровки или транспорта.
|
||||
|
||||
Последствия: пока исходящая строка seq=1 сохранена, новая отправка того же номера
|
||||
будет конфликтовать с историей. После локального удаления история очищается,
|
||||
а сброшенный счётчик сохраняется; повтор seq при неизменном ключе воспроизведёт
|
||||
детерминированный nonce. Это вывод из кода, повторная отправка после сбоя в стенде
|
||||
не выполнялась. Раскрытие содержимого/эксплуатация не проверялись.
|
||||
|
||||
В dm_accept_message и dm_invite_trampoline также смешаны отсутствие и ошибка
|
||||
dm_conv_load; их отказные сценарии в этом этапе отдельно не воспроизводились.
|
||||
|
||||
Простой план исправления: выделить результаты FOUND/NOT_FOUND/ERROR для загрузки;
|
||||
считать NOT_FOUND только завершённый SELECT без строки, ошибки логировать и
|
||||
передавать без UPSERT/события успеха. Просмотреть все callers, включая dm_media;
|
||||
при найденной беседе не обновлять seq из пустой структуры. Регрессии должны
|
||||
проверить prepare/step/короткие ключи/crypto, сохранность полей и generation/hidden,
|
||||
повторный запуск, успешное создание отсутствующей беседы и нормальное продолжение seq.
|
||||
Новый формат протокола или БД для исправления не требуется.
|
||||
|
||||
3. Проверки и артефакты первоначального аудита, до исправления
|
||||
|
||||
Сборка штатных шести целей через tests/Makefile — PASS.
|
||||
test_dm/test_dm_history/test_dm_e2e/test_dm_invite/test_dm_media/test_dm_worker:6/6 PASS.
|
||||
Полные логи прочитаны; ERROR в штатных отрицательных проверках ожидаемы.
|
||||
Worker:24 запуска,49 heartbeat,max_gap11.3мс. Media:phase9 PASS.
|
||||
Эти тесты не покрывают подтверждённый отказ загрузки беседы.
|
||||
|
||||
/tmp/dm-audit-baseline-build.log
|
||||
/tmp/dm-audit-baseline-results.log
|
||||
/tmp/dm-audit-baseline-test_dm{,_history,_e2e,_invite,_media,_worker}.log
|
||||
/tmp/dm-audit-load-failure.c и /tmp/dm-audit-load-failure.mk
|
||||
/tmp/dm-audit-load-failure-build.log
|
||||
/tmp/dm-audit-load-failure.log (контроль PASS, сохранность при отказе FAIL, exit2)
|
||||
|
||||
Первый запуск стенда в sandbox остановился до DM на запрете netlink-сокета.
|
||||
Окончательные стенд и штатные тесты запущены с разрешением вне sandbox;
|
||||
сетевые настройки хоста не менялись. Пользовательская БД не использовалась.
|
||||
|
||||
Рабочий алгоритм, протокол, БД и штатные тесты не менялись: только документы/комментарии.
|
||||
Новые полный check.sh, ASAN/UBSan/LSan, GUI/Android/Windows/FreeBSD не запускались.
|
||||
По AGENTS.md:10 аудит остановлен на подтверждённом дефекте до его исправления;
|
||||
открытая задача внесена в doc/tasks.md. Коммит/push не выполнялись.
|
||||
|
||||
4. Исправление по запросу пользователя — 26.10.2026
|
||||
|
||||
dm_conv_load теперь возвращает FOUND/NOT_FOUND/ERROR. Только SQLITE_DONE без строки
|
||||
и с успешным finalize означает NOT_FOUND; SQL-ошибки prepare/bind/step/finalize,
|
||||
короткие ключи и отказ X25519 означают ERROR. Полная структура выдаётся только
|
||||
после успешного завершения загрузки/вывода ключа. Лог содержит conv/rc/SQL-причину,
|
||||
успешная загрузка с seq доступна в TRACE, отсутствие записи — DEBUG.
|
||||
|
||||
Проверены все5 callers: dm_start и dm_accept_message возвращают ошибку без
|
||||
создания/сохранения/открытия беседы, dm_invite_trampoline завершает запрос
|
||||
DM_INVITE_START_ERROR без повторного dm_start; dm_send и JSON требуют FOUND.
|
||||
В dm_start также перестала игнорироваться ошибка сохранения выбранной области.
|
||||
Медиа не вызывает dm_conv_load и не создаёт беседу вместо ошибочной загрузки:
|
||||
dm_send_file отклоняет ошибки чтения/ключей до регистрации worker/файлов.
|
||||
Протокол и формат БД не менялись.
|
||||
|
||||
Постоянная Linux-регрессия test_dm_load включена в check_PROGRAMS.
|
||||
35 комбинаций5 callers×7 отказов для видимой беседы, ещё35 после локального удаления,
|
||||
4 отказа отдельной media-подготовки и5 отказов открытия отсутствующей беседы:79 PASS.
|
||||
Отказы:prepare/bind/step/finalize,короткие X25519/Ed25519 BLOB,EVP_PKEY_derive.
|
||||
Проверяются SHA256 всех полей7 DM-таблиц,sqlite3_total_changes,autocommit,
|
||||
отсутствие событий успеха и неизменённый буфер квитанции при отказе приёма.
|
||||
Нормальные контроли:создание отсутствующей беседы,повторное открытие после restart,
|
||||
продолжение исходящего seq1->2 и приём впервые доставленного seq1 после seq2.
|
||||
Запрос invite проверяется на настоящей собственной CHAT-группе; общая доставка
|
||||
и создание беседы при первом входящем сообщении покрываются штатными E2E/media-тестами.
|
||||
|
||||
Новая регрессия с прежним dm_core.c из HEAD ожидаемо FAIL на первом prepare-отказе:
|
||||
ошибка -> dm_start0,дополнительные SQL-write/CONV_UPDATED;cleanup allocations0->0.
|
||||
Использовались отдельные /tmp/old-core.o и бинарник; рабочий архив не подменялся.
|
||||
Первоначальный стенд с настоящим SQLITE_DENY теперь PASS:dm_start=-1,out1->1,in2->2,
|
||||
две строки истории сохранены,allocations0->0. Оба контрольных лога прочитаны полностью.
|
||||
|
||||
Сборка Linux PASS; DM-профиль test_dm_load/dm/history/e2e/invite/media/worker7/7 PASS.
|
||||
ASAN/UBSan/LSan тот же профиль7/7 PASS с detect_leaks=1/halt_on_error=1.
|
||||
Отдельная копия /tmp/utun-dm-load-sanitizers:новая сборка core/libuasync/Opus
|
||||
с согласованными -fsanitize=address,undefined,без смешивания обычных объектов.
|
||||
dm_core.c,test_dm_load.c,tests/Makefile.am побайтно совпадают с рабочими.
|
||||
Проверены все sanitizer-логи:нет отчётов ASAN/UBSan/LSan;test_dm_load allocations0->0.
|
||||
|
||||
Первый полный check.sh:202 passed,1 failed,0 skipped (203 total).
|
||||
Отказ test_chat_join_e2e:happy A!=C,J не декодировал приглашение,потом A/C не нашли J;
|
||||
degraded и invalid-key PASS. Отдельный диагностический повтор3/3 PASS.
|
||||
Код теста создаёт link.txt до записи/закрытия;читатель ждёт только access(F_OK).
|
||||
Это возможная гонка тестовой публикации,но конкретная причина исходного отказа
|
||||
не доказана без размеров/результатов чтения. Отдельная задача в doc/tasks.md;
|
||||
production JOIN и критерии/код теста не менялись,первый отказ сохранён.
|
||||
|
||||
Окончательный повтор полного check.sh:203 passed,0 failed,0 skipped;exit0,175с.
|
||||
Интеграции proxy9/9,burst1/1,load8/8 PASS,134217728 проверенных байт.
|
||||
В этом прогоне новый test_dm_load PASS;код после профильных проверок не менялся.
|
||||
Повторный успех не закрывает отдельную задачу диагностики исходного отказа JOIN.
|
||||
|
||||
Логи исправления:
|
||||
/tmp/dm-load-fix-build.log (окончательная сборка PASS)
|
||||
/tmp/dm-load-fix-profile-results.log и /tmp/dm-load-fix-test_dm*.log
|
||||
/tmp/dm-load-fix-controls-results.log и /tmp/dm-load-fix-control-*.log
|
||||
/tmp/dm-load-fix-negative-build.log и /tmp/dm-load-fix-original-build.log
|
||||
/tmp/dm-load-fix-sanitizers-build.log,/tmp/dm-load-fix-sanitizers-results.log,
|
||||
/tmp/dm-load-fix-sanitizers-test_dm*.log,/tmp/dm-load-fix-final-verification.log
|
||||
/tmp/dm-load-fix-full-check-first.log,/tmp/dm-load-fix-join-first.log,/tmp/dm-load-fix-join-repeat.log
|
||||
/tmp/dm-load-fix-full-check.log,/tmp/dm-load-fix-netns.log,/tmp/dm-load-fix-repeat-netns.log
|
||||
|
||||
Полный набор запускается в отдельном IPv6 network namespace без изменения сети хоста.
|
||||
Начальные ошибки подготовки sanitizer-копии (недостающие generated auxiliary files/
|
||||
etcpmon header и ограничение LSan в sandbox) устранены до успешной сборки/прогона.
|
||||
В первом native build не хватало openssl/evp.h в новом тесте;добавлен до успешной сборки.
|
||||
GUI/Android/Windows/FreeBSD не запускались;регрессия с linker wrappers — Linux.
|
||||
Дефект счётчиков исправлен;исходные частичные отметки аудита остальных DM owners сохраняются.
|
||||
Коммит/push не выполнялись.
|
||||
|
||||
5. Исходный отказ mailbox/media — 26.10.2026: transient SQL становится постоянным отказом M
|
||||
|
||||
Прочитаны полностью dm_mailbox.c,dm_mailbox_media.c,dm_media.c и четыре заголовка,
|
||||
связанные file_transfer/media_async контракты,lifecycle instance и штатный test_dm_media.
|
||||
Уточнены uasync/ownership/lifecycle,семантика принятия PUT,транзакция регистрации media,
|
||||
предусловия размеров control,ограничение рассылки до64 S на группу и SQL-helper ownership.
|
||||
Четыре новых заголовка отмечаются[~]:чтение завершено,но полный аудит контрактов/отказов
|
||||
и документация media API не завершены. Прежние core/history/dm_arch[~],crypto[V] сохраняются.
|
||||
|
||||
Дефект в dm_custody_store:результат SELECT COALESCE(sum(cipher_size),0) FROM dm_custody
|
||||
при любом rc!=SQLITE_ROW превращается в used=UINT64_MAX. Следующая ветка used>quota
|
||||
выдаёт DM_MEDIA_REJECT reason1,то есть утверждает реальное переполнение квоты.
|
||||
Оригинальная причина SQL не логируется;WARN показывает фиктивный used18446744073709551615.
|
||||
dm_mailbox_media_recv на S сохраняет released=1 и holder=0. dm_job_storage исключает
|
||||
все прежние назначения через NOT IN(SELECT node_id FROM dm_media_holders WHERE id=?),
|
||||
включая released. Поэтому повторные STORE на этом M для UUID больше не возникают.
|
||||
|
||||
Доказательство:/tmp/dm-quota-audit.c/.mk,временный вариант штатного test_dm_media.
|
||||
Настоящие четыре utun/chat ядра A/B/S/M,разные БД,подписанные channel/member/admin-блоки,
|
||||
реальные UDP-транспорты и file_transfer. Сначала штатная прямая доставка и прежние
|
||||
проверки rollback/реальной квоты;затем B останавливается,A отправляет новое вложение.
|
||||
Прикладной алгоритм,архивы,production-объекты и штатный тест не подменялись.
|
||||
Linker-wrapper однократно возвращает SQLITE_IOERR только для used-space SELECT в БД M.
|
||||
В обеих проверках настроено1024МиБ,текущая custody пуста;ciphertext запрашивает98385байт.
|
||||
|
||||
Контроль без отказа:назначение M->custody reserved->файл сохранён->STORED на S.
|
||||
Через16 календарных секунд тестовых часов:ready1,released0,waiting0,holders1,used98385;
|
||||
phase9 PASS,exit0,allocations0->0.
|
||||
Fault:SQLITE_IOERR->quota refused usedUINT64_MAX requested98385 quota1073741824,
|
||||
затем storage refused reason1 на S. Через16с:ready0,released1,waiting1,holders1,used0;
|
||||
quota-запрос STORE выполнен только один раз (финальный диагностический SELECT в счётчик не включён).
|
||||
phase4 ожидаемо FAIL,exit1,allocations0->0. После однократного отказа SQLite работает;
|
||||
возобновления назначения нет. Persistent-исключение после restart следует из таблицы/SQL,
|
||||
отдельный restart в этом стенде не выполнялся.
|
||||
|
||||
Логи:/tmp/dm-quota-audit-build.log,/tmp/dm-quota-audit-results.log,
|
||||
/tmp/dm-quota-audit-control.log и /tmp/dm-quota-audit-fault.log.
|
||||
Ключевые строки всех переходов сохранены в /tmp/dm-quota-audit-diagnostics.log.
|
||||
В baseline protocol_checks ожидаемы отдельный SQL-trigger rollback и настоящий
|
||||
quota-отказ2МиБ при квоте1МиБ:они происходят до включения fault и не являются новым дефектом.
|
||||
Работающие transport/media callbacks и SQL-снимки однозначно показывают механизм;
|
||||
production-диагностика должна дополнительно различать SQL-ошибку и настоящую квоту.
|
||||
|
||||
Простое исправление:проверять результат чтения занятого места отдельно,логировать
|
||||
ошибки SQL с rc/причиной и возвращаться без REJECT. Сохранённый holder/stored0 на S
|
||||
уже обеспечивает повтор STORE через существующий таймер. Реально недостаточная
|
||||
квота сохраняет нынешний REJECT,выбор другого M и учёт всех назначений.
|
||||
Также проверить bind/finalize,не выдавать operational-ошибку за quota refusal.
|
||||
Постоянная регрессия:однократные SQL-отказы не исключают свободный M,последующий
|
||||
STORE сохраняет ciphertext;реальная квота по-прежнему отклоняет запрос до загрузки.
|
||||
Новый протокол/формат БД/специальный control для retry не требуется.
|
||||
|
||||
По AGENTS.md:10 аудит остановлен до исправления;открытая задача в doc/tasks.md.
|
||||
В этом этапе изменены только комментарии/документы/отметки;исправление dm_conv_load
|
||||
и новая постоянная регрессия из предыдущего этапа сохранены. Новые fullcheck/ASAN/UBSan/LSan,
|
||||
GUI/Android/Windows/FreeBSD не запускались;прежние203/203 и sanitizer7/7 относятся
|
||||
к предыдущему исправлению,а не к новому отказу custody. Коммит/push не выполнялись.
|
||||
|
||||
6. Исправление quota-admission — 26.10.2026
|
||||
|
||||
По запросу пользователя dm_custody_store проверяет чтение занятого места и finalize
|
||||
до сравнения с квотой. Ошибка prepare уже возвращалась без ответа;теперь ошибка step,
|
||||
неожиданный SQLITE_DONE и ошибка finalize также прекращают STORE без REJECT/регистрации.
|
||||
Лог step/finalize содержит rc,SQL-причину и указание на сохранённый повтор STORE.
|
||||
Прежнее назначение M остаётся на S;его штатный таймер повторяет STORE. Настоящий
|
||||
недостаток квоты по-прежнему выдаёт reason1. У агрегатного SELECT нет bind-параметров.
|
||||
Протокол,формат БД и алгоритм назначения S не менялись.
|
||||
|
||||
Постоянный test_dm_media расширен fault wrappers только для Linux:
|
||||
- Отдельные prepare/step/finalize/неожиданный DONE проверяют один отказ за вызов,
|
||||
отсутствие REJECT/STORED,отсутствие изменений SQL в S/M,пустые custody/tombstones,
|
||||
autocommit. Достаточная квота настроена1024МиБ;отказ не заменяется успешной регистрацией.
|
||||
- Настоящее превышение квоты требует ровно один REJECT без STORED/регистрации custody.
|
||||
- Сетевой сценарий использует прежние4 ядра,подписанные роли,настоящие UDP/router/transfer.
|
||||
После отключения B первые3 STORE получают разные SQL-ошибки на M:prepare,step,finalize.
|
||||
Четвёртый STORE сохраняет файл на том же M;REJECT0,STORED1,одно назначение,released0.
|
||||
Последующие штатные проверки перезапуска S/M при выключенном A,точного файла у B,
|
||||
квитанций,удаления промежуточной копии/тела S,поздних STORE/STORED и TTL сохранены.
|
||||
Исходные4 сбоя проверяют обработчик после router,сетевой retry идёт обычным транспортом.
|
||||
|
||||
Нативный и sanitizer-профили7/7 PASS:test_dm_load/dm/history/e2e/invite/media/worker.
|
||||
В test_dm_media phase9 PASS,allocations0->0;в native/sanitizer одинаковая последовательность
|
||||
hits3,STORE4,REJECT0,STORED1,holders1. ASAN/UBSan/LSan7/7 PASS,detect_leaks=1,
|
||||
halt_on_error=1;ядро,libuasync и встроенный Opus собраны с одинаковыми sanitizer-флагами.
|
||||
Использована отдельная копия /tmp/utun-dm-quota-sanitizers;обычные объекты не подменялись.
|
||||
Побайтно сверены dm_media.c/test_dm_media.c/tests/Makefile.am и прежние dm_core.c/test_dm_load.c.
|
||||
|
||||
Контроли:
|
||||
- Новый test_dm_media с dm_media.c из HEAD ожидаемо FAIL,exit1:step-отказ даёт ложный
|
||||
REJECT,который ловит новая проверка. allocations0->0. Старый модуль собран отдельным
|
||||
объектом и включён в отдельный бинарник;рабочий libutun.a не подменялся.
|
||||
- Исходный /tmp/dm-quota-audit.c с одним SQLITE_IOERR и исправленным core:phase9 PASS,exit0.
|
||||
- test_dm_media без DM_QUOTA_FAULT_INJECTION:штатный сценарий phase9 PASS,exit0.
|
||||
Это Linux-компиляция без wrappers;runtime других ОС не проверялся.
|
||||
Все3 контроля восстанавливают tracked allocations до0. Полные логи изменённого
|
||||
media-теста/native/sanitizer/контролей прочитаны;ожидаемые ERROR относятся к инъекциям.
|
||||
|
||||
Артефакты:
|
||||
/tmp/dm-quota-fix-build.log
|
||||
/tmp/dm-quota-fix-profile-results.log,/tmp/dm-quota-fix-test_dm*.log
|
||||
/tmp/dm-quota-fix-sanitizers-build.log,/tmp/dm-quota-fix-sanitizers-results.log,
|
||||
/tmp/dm-quota-fix-sanitizers-test_dm*.log
|
||||
/tmp/dm-quota-fix-controls-build.log,/tmp/dm-quota-fix-controls-results.log,
|
||||
/tmp/dm-quota-fix-control-{test_dm_media_old,dm-quota-fixed-original,test_dm_media_no_injection}.log
|
||||
/tmp/dm-quota-fix-final-verification.log
|
||||
/tmp/dm-quota-fix-full-check.log,/tmp/dm-quota-fix-netns.log
|
||||
|
||||
Полный check.sh203/203 PASS,0 failed/0 skipped;proxy9/9,burst1/1,load8/8 PASS,
|
||||
134217728 проверенных байт,exit0,176с. Этот прогон включает новую fault/retry-проверку media.
|
||||
Набор выполнен в отдельном IPv6 network namespace;сеть хоста не менялась.
|
||||
Первый сборочный запуск отдельного negative-control не включал test_clock.o в линковку;
|
||||
временная make-цель исправлена до контрольных прогонов. Основная сборка/код от этого не менялись.
|
||||
Документация обновлена,задача quota-admission закрыта. Остальные отметки аудита DM остаются
|
||||
частичными;GUI/Android/Windows/FreeBSD не запускались. Коммит/push не выполнялись.
|
||||
|
||||
7. Commit/rebase/push по запросу пользователя — 26.10.2026
|
||||
|
||||
Все накопленные изменения DM сохранены в2ae1990d. Fetch обнаружил8 новых коммитов
|
||||
origin/master поверх03b05094:изменения headless PCM,документации звонков и их проверок.
|
||||
Rebase на03be46c3 завершён с единственным конфликтом doc/tasks.md:сохранены обе
|
||||
полные группы задач. tests/Makefile.am объединился автоматически,цели test_dm_load,
|
||||
quota wrappers test_dm_media и upstream test_call_headless_queue присутствуют.
|
||||
DM .c/.h и обе новые/расширенные регрессии побайтно сохранены относительно исходного коммита.
|
||||
|
||||
После rebase коммит стал b6588d059455ed430c902a325af7d010f15f3475.
|
||||
Нативная сборка PASS;новый полный check.sh204/204 PASS,0 failed/0 skipped,
|
||||
proxy9/9,burst1/1,load8/8 PASS,134217728 байт,exit0,175с. Включён новый upstream
|
||||
test_call_headless_queue (11/11) и все DM-регрессии. Сеть — отдельный IPv6 namespace.
|
||||
Push origin master:03be46c3..b6588d05 PASS,без force. git ls-remote подтвердил hash,
|
||||
HEAD==origin/master,рабочее дерево после push было чистым.
|
||||
Логи:/tmp/dm-audit-rebase-build.log,/tmp/dm-audit-rebase-full-check.log,
|
||||
/tmp/dm-audit-rebase-netns.log. Санитайзеры после rebase заново не запускались;
|
||||
исходные DM-файлы по сравнению с прошедшим sanitizer-профилем не менялись.
|
||||
|
||||
8. Продолжение: UTF-8 имя повреждается при построении беседы
|
||||
|
||||
Продолжены сверка core/history,передачи поколения и путей из attachment_send,
|
||||
участков headless/GUI/JNI вызывающих функций. Это чтение кода,не аппаратная проверка UI.
|
||||
Уточнён dm_send_file:0 означает вызов submit,который вправе синхронно вызвать callback
|
||||
ошибки;результат подготовки сообщается событием. Это исправление описания возврата.
|
||||
|
||||
Новый подтверждённый дефект:dm_conv.peer_name[64],snprintf в dm_start/dm_conv_load
|
||||
режут имя по63байтам,без проверки границы UTF-8. json_escape передаёт получившиеся
|
||||
байты в JSON без проверки/исправления. Корректное входное имя тем самым повреждается
|
||||
в SQLite,а dm_list_conversations_json возвращает0 с некорректным UTF-8 JSON.
|
||||
dm_start_req допускает до127байт имени;отсутствует предусловие ASCII/<=63байт.
|
||||
|
||||
Стенд:/tmp/dm-utf8-audit.c/.mk. Настоящие core/chat_service/SQLite/X25519,
|
||||
отдельные ключи для трёх собеседников,новая БД/tmp;публичные dm_start/list API.
|
||||
Валидные входы:ASCII63байта;31×«Я»=62байта;32×«Я»=64байта.
|
||||
Контроли ASCII63/UTF8_62 дают корректный UTF-8 JSON. UTF8_64 сохраняется как63байта,
|
||||
последний байт0xd0 без продолжения;start=0,list=0. Строгий Python decode JSON
|
||||
даёт invalid continuation byte на позиции537;проверка exit2. SQL-readonly-снимок
|
||||
подтверждает тот же обрыв в сохранённом peer_name,tail_hex=afd0afd0afd0afd0.
|
||||
Tracked allocations0->0;собственно C-стенд exit0,отказ обнаруживает независимый валидатор.
|
||||
Сам runtime-лог тоже содержит этот некорректный байт:валидатор читает диагностический
|
||||
лог с replacement только для поиска ASCII-путей,но JSON и SQL BLOB проверяет строго.
|
||||
|
||||
Причина однозначна по точным входным/сохранённым длинам и байтам;это не ошибка JSON
|
||||
парсера,шифрования или вывода терминала. Повторная загрузка имени через dm_conv_load
|
||||
имеет то же побайтовое усечение;этот путь в новом стенде отдельно не запускался.
|
||||
|
||||
Простой план:определить единое ограничение имени и копировать только целые UTF-8
|
||||
code points. Проверять входы и уже сохранённые некорректные строки с диагностикой;
|
||||
увеличение массива само по себе не решает обрыв на новой границе.
|
||||
Постоянные проверки:ASCII и2/3/4-байтовые символы до/на/за границей,SQL round-trip,
|
||||
повторная загрузка и строгая декодируемость JSON. До выбора обработки уже повреждённых
|
||||
имён (безопасное усечение/отказ) рабочая реализация не менялась.
|
||||
|
||||
Логи:/tmp/dm-utf8-audit-build.log,/tmp/dm-utf8-audit-runtime.log,
|
||||
/tmp/dm-utf8-audit-validation.log;валидатор:/tmp/dm-utf8-audit-validate.py.
|
||||
По AGENTS.md:10 дальнейший аудит остановлен,задача открыта в doc/tasks.md.
|
||||
В продолжении только комментарии/документы,все текущие частичные отметки сохранены.
|
||||
Предыдущий check204 проверял объединённые исправления,а не обработку нового имени;
|
||||
новые sanitizer/GUI/Android/Windows/FreeBSD проверки не запускались.
|
||||
|
||||
9. Исправление UTF-8 имени собеседника
|
||||
|
||||
Предел имени явно задан DM_PEER_NAME_MAX=63 байта без NUL. Единый dm_copy_peer_name
|
||||
сохраняет целые UTF-8 code points при dm_start, dm_conv_load, автосоздании во входящем
|
||||
dm_accept_message и построении снимка бесед. Входящее имя из nodes сначала читается
|
||||
в128байт, чтобы прежнее предварительное усечение в64байта не портило символ.
|
||||
JSON нормализует как SQL peer_name,так и fallback из nodes при пустом имени.
|
||||
|
||||
Некорректная последовательность,overlong,surrogate,значение выше U+10FFFF или NUL
|
||||
внутри SQL TEXT обрывают имя перед первым недопустимым code point. WARN содержит
|
||||
conv_id,смещение и исходную длину;обычное усечение длинного имени отмечается DEBUG.
|
||||
Чтение/снимок не исправляют SQL автоматически. Следующая штатная запись беседы
|
||||
сохраняет уже нормализованное имя;формат БД/wire и счётчики сообщений не меняются.
|
||||
|
||||
Постоянный test_dm_names добавлен в штатный набор tests/Makefile.am:
|
||||
68 случаев с реальными core/chat_service/SQLite и отдельными ключами собеседников.
|
||||
ASCII0/1/62/63/64/127байт;границы2/3/4-байтовых символов до/на/за63байтами,
|
||||
повторяющиеся символы,включая U+10FFFF;12 видов некорректных последовательностей
|
||||
с пустым/непустым префиксом;SQL TEXT с NUL;кавычки/слеш/newline и кириллица.
|
||||
Каждый случай проверяет точные SQL/JSON имена при создании,чтении повреждённого
|
||||
сохранённого имени,load через чтение сообщений,load/save через dm_send и fallback.
|
||||
total_changes и исходные SQL-байты подтверждают отсутствие скрытых записей при чтении.
|
||||
Отдельно проверен restart с той же БД/идентичностью;allocations0->0.
|
||||
|
||||
Независимый строгий Python decode UTF-8 + json.loads проверил273 полных снимка,
|
||||
9452 строки бесед и предел63байта на каждое имя;PASS в native и sanitizer сборках.
|
||||
Контроль с прежним core:новая регрессия FAIL,exit1,на чтении длинного SQL-имени
|
||||
после первых4 случаев (прежний JSON обходил предел);allocations0->0.
|
||||
Исходный стенд раздела8 повторён на исправленном core:ASCII63/UTF8_62/UTF8_64 PASS,
|
||||
32×«Я» сохраняются как62байта,строгий JSON всех3 снимков корректен,allocations0->0.
|
||||
Исходные доказательства дефекта сохранены в прежних /tmp/dm-utf8-audit-*.log.
|
||||
|
||||
Нативная сборка PASS;DM-профиль8/8 PASS: names/load/crypto/history/e2e/invite/media/worker.
|
||||
ASAN/UBSan/LSan8/8 PASS,detect_leaks=1,halt_on_error=1;отчётов о памяти/UB/утечках нет.
|
||||
Изолированная копия /tmp/utun-dm-utf8-sanitizers синхронизирована с текущими исходниками,
|
||||
включая upstream call_headless;ASAN/UBSan-ссылки проверены в core/libuasync/Opus.
|
||||
Полный check.sh205/205 PASS,0 failed/0 skipped,proxy9/9,burst1/1,load8/8,
|
||||
134217728байт,exit0,172с. Сеть — отдельный IPv6 namespace,сеть хоста не менялась.
|
||||
|
||||
Логи:/tmp/dm-utf8-fix-{build,profile-results,test_dm_names,negative-build,negative}.log,
|
||||
/tmp/dm-utf8-fix-{original-build,original,original-validation,json-validation}.log,
|
||||
/tmp/dm-utf8-fix-{sanitizers-build,sanitizers-results,sanitizers-test_dm*,full-check,netns}.log.
|
||||
Задача UTF-8 закрыта;общий аудит DM остаётся частичным:crypto[V],остальные6headers
|
||||
и dm_arch[~]. Платформенные GUI/Android/Windows/FreeBSD проверки не выполнялись.
|
||||
|
||||
Финальная сверка перед отправкой:fetch принёс4 Android mute-коммита до fa76447f.
|
||||
Rebase прошёл без конфликтов;DM исходники/headers,Makefile и регрессия побайтно
|
||||
сохранены. Коммиты исправления/отчёта после rebase:d09ed031/87d24b72.
|
||||
Повторная native сборка PASS;check.sh205/205 PASS,0 failed/0 skipped,proxy9/9,
|
||||
burst1/1,load8/8 PASS,134217728байт,exit0,175с. Строгие273 JSON/9452строки снова PASS.
|
||||
Логи:/tmp/dm-utf8-fix-rebase-{build,full-check,json-validation,netns}.log.
|
||||
Санитайзеры после rebase не повторялись:DM runtime не менялся,общий call_audio.h
|
||||
в upstream изменяет только комментарий;новые платформенные Android callers этим
|
||||
DM-профилем не проверяются. Upstream задачи/отчёты Android mute сохранены целиком.
|
||||
|
||||
10. Продолжение mailbox: SQL-ошибка оставляет завершённое TTL-назначение активным
|
||||
|
||||
База продолжения:3dc5eb502db6964fc6467385cc65db9d1df83c44,рабочее дерево было чистым.
|
||||
Повторно сверены dm_mailbox.c/.h,dm_mailbox_media.c/.h и относящиеся к квитанциям,
|
||||
контролю и TTL функции dm_media. Приём PUT проверяет точное тело,роль S и общую
|
||||
группу;квитанция текста и удаление pending объединены транзакцией. Это чтение
|
||||
соответствующих путей,не полная проверка всех SQL/allocator/платформенных отказов.
|
||||
|
||||
Подтверждён новый дефект:dm_mailbox_media_recv в EXPIRED сначала делает
|
||||
UPDATE dm_media_holders SET released=1,затем отдельным statement обновляет job:
|
||||
holder=0,stored=0,expired=1,retry_at=0. При отказе второго statement первое изменение
|
||||
остаётся в БД. Таймер при недоступном B видит прежние holder и stored=1,поэтому
|
||||
не выбирает нового M,не повторяет STORE и не сверяет expires с текущим временем.
|
||||
На M custody уже удалена;dm_custody_done хранит tombstone,но таймер эту таблицу
|
||||
не сканирует и не повторяет уведомление EXPIRED сам по себе.
|
||||
|
||||
Стенд:/tmp/dm-expiry-audit.c/.mk — отдельный вариант штатного test_dm_media.
|
||||
Настоящие4 ядра A/B/S/M с подписанными мемберскими/административными блоками,
|
||||
разными SQLite,реальными UDP/BGP/file_transfer и прежними проверками доставки.
|
||||
Штатные quota-fault проверки идут в обоих прогонах одинаково;новая инъекция только
|
||||
на S и только на UPDATE завершения EXPIRED,однократно SQLITE_IOERR в sqlite3_step.
|
||||
Рабочие C,штатные тесты,объекты и libutun не подменялись;данные только в/tmp.
|
||||
|
||||
Оба прогона сначала проходят прямую доставку,offline custody и восстановление
|
||||
S/M с выключенным A,побайтную доставку B и удаление промежуточной копии.
|
||||
Затем B снова выключен;для третьего файла настроен TTL1с на M. M успевает принять
|
||||
98385байт ciphertext,подтвердить хранение,по TTL удалить custody и послать EXPIRED.
|
||||
|
||||
Контроль без новой инъекции:phase9 PASS,exit0,allocations0->0. Для seq3 на S
|
||||
holder0,stored0,expired1;назначение released1. На M нет custody,есть expired tombstone.
|
||||
Fault:UPDATE завершается rc10,hit1,ошибка логируется вместе с точным SQL. После
|
||||
ещё16 календарных секунд тестовых часов и нескольких успешных повторов PUT:
|
||||
на S holder723190674248656275,stored1,expired0,expires1793032708,receiptNULL,body сохранён;
|
||||
для того же UUID E20AC64A2660882CE73D68D48E2AFFA0 назначение этого M released1.
|
||||
На M custody0,expired tombstone1. Phase8 FAIL,exit1,allocations0->0.
|
||||
В runtime released=2 считает также прежнее успешно доставленное назначение;
|
||||
независимый readonly SQL JOIN отдельно подтвердил released1 для проверяемого UUID.
|
||||
Остановка/освобождение owners штатные,не маскируют сохранённое расхождение.
|
||||
|
||||
Окно отсутствия восстановления16с проверено исполнением;отсутствие пути восстановления
|
||||
при продолжающемся offline B следует также из ветки таймера. Restart после этой
|
||||
инъекции,возврат B и дополнительный storage не запускались;потеря до TTL не заявляется.
|
||||
Это нарушение согласованности durable состояния после SQL-отказа,а не ошибка самого
|
||||
удаления по TTL:копия M удалена в предусмотренный срок.
|
||||
|
||||
Направление исправления:идемпотентный восстанавливаемый переход EXPIRED с согласованным
|
||||
состоянием job/holder;подтверждённое назначение проверять таймером по сохранённому expires.
|
||||
Одного BEGIN/COMMIT вокруг двух UPDATE недостаточно:при rollback событие уже потреблено,
|
||||
а M не повторит EXPIRED после удаления custody. Нужны отказные проверки обоих statement,
|
||||
commit и восстановления после restart;поздний STORED не должен отменить завершение.
|
||||
Сходная последовательность REJECT/DELETE_ACK замечена при чтении,но отдельный runtime
|
||||
её отказов в этом продолжении не выполнен. Конкретная реализация пока не менялась.
|
||||
|
||||
Сборка отдельного стенда PASS. Логи:/tmp/dm-expiry-audit-{build,control,fault,validation}.log;
|
||||
полные журналы сохранены,DM-выборки без транспортного шума — *-control-dm.log/*-fault-dm.log.
|
||||
Прежний check205/санитайзеры относятся к исправлению UTF-8 и не покрывали этот отказ.
|
||||
Новые fullcheck/sanitizer/GUI/Android/Windows/FreeBSD проверки не выполнялись.
|
||||
По AGENTS.md:10 дальнейший аудит остановлен;задача открыта в doc/tasks.md.
|
||||
В продолжении только комментарий контракта и отчёт/задача;частичные отметки сохранены.
|
||||
|
||||
11. Исправление восстанавливаемого завершения TTL
|
||||
|
||||
На S добавлен единый dm_job_release для EXPIRED/REJECT:BEGIN IMMEDIATE,освобождение
|
||||
назначения и сброс текущего holder/stored,COMMIT. Prepare/bind/step/finalize обоих
|
||||
statement и COMMIT проверяются;при ошибке ROLLBACK,без сообщения об успешном переходе.
|
||||
Лог ошибки содержит M,rc/finalize,точный SQL и причину;успех — M,причину expired/rejected
|
||||
и отдельные счётчики изменения назначения/job. Идемпотентный повтор не пишет released
|
||||
заново;старый M не сбрасывает нового holder. WHERE receipt IS NULL сохраняет приоритет
|
||||
подписанной квитанции B. DELETE_ACK остаётся отдельным одношаговым освобождением.
|
||||
|
||||
Таймер S независимо проверяет сохранённый expires подтверждённого stored-назначения.
|
||||
При истечении выполняет тот же переход и перечитывает job после commit;ошибка остаётся
|
||||
на следующем retry_at. Это работает при offline B,потерянном EXPIRED и после restart.
|
||||
Для ещё не подтверждённого хранения STORE продолжает повторяться;M отвечает из
|
||||
custody/tombstone. Поэтому для отсутствующего STORED новый механизм не требует
|
||||
выдуманного срока хранения. Формат таблиц/wire,подписи и TTL на M не менялись.
|
||||
Лог удаления тела S уточнён:освобождены все назначения. Завершение TTL не утверждает,
|
||||
что worker M уже закончил физическое удаление в тот же момент;его владелец и повторы
|
||||
сохраняются на M. Неистёкшие назначения продолжают удаляться до DELETE_ACK.
|
||||
|
||||
Штатный Linux test_dm_media расширен,без отдельной подмены production:
|
||||
18 component-сценариев на настоящем job S:EXPIRED и REJECT × prepare/bind/step/finalize
|
||||
обоих UPDATE и COMMIT. После каждого отказа сравниваются все6 полей назначения/job
|
||||
(holder,stored,expired,expires,retry_at,released),проверяется autocommit;нормальный повтор
|
||||
завершает переход,дубликат и поздний STORED не меняют результат и не создают receipt.
|
||||
Фикстура возвращается синхронно до следующего uasync callback;это component-проверка
|
||||
SQL/control,не утверждение о реальном удалении M на каждом из18 искусственных событий.
|
||||
|
||||
Сетевой TTL-сценарий использует настоящие4 ядра A/B/S/M и прежний полный путь
|
||||
offline-файла с restart S/M при выключенном A,точным файлом на B и удалением копий.
|
||||
Затем B отключён,на M TTL1с. EXPIRED от M намеренно теряется,SQL-сброс job на S
|
||||
блокируется до удаления custody M. Проверены сохранённый holder/stored и released0
|
||||
после rollback;S перезапускается. Без получения EXPIRED таймер S восстанавливает
|
||||
holder0,stored0,expired1,released1. Поздний STORED с будущим expires и STORE не
|
||||
возрождают назначение/файл и не выдают ложную квитанцию доставки.
|
||||
После restart поздний component-control ждёт реального восстановления ETCP-линка.
|
||||
Native и ASAN:18 checks,один заблокированный сброс,два потерянных EXPIRED (включая
|
||||
ответ на поздний STORE),restart1,phase9 PASS,allocations0->0.
|
||||
|
||||
Независимое readonly чтение SQLite по точному UUID подтвердило на M custody0,
|
||||
expired tombstone1;на S holder0/stored0/expired1,receiptNULL,body сохранён и released1.
|
||||
Прежний job seq2 сохранил квитанцию и удалённое тело. Проверено и в sanitizer-копии.
|
||||
Прежний координатор в отдельном /tmp/dm-expiry-old-coordinator.o/бинарнике ожидаемо FAIL
|
||||
новой регрессии на bind-отказе первого UPDATE:частичный переход,phase4,exit1,
|
||||
allocations0->0. Рабочий libutun не подменялся. Исходный one-shot IOERR-стенд раздела10
|
||||
повторён на исправлении:matching SQL дополнен receipt IS NULL,hit1,затем успешный
|
||||
переход,phase9 PASS,exit0,allocations0->0;исходные доказательства дефекта сохранены.
|
||||
|
||||
Нативная сборка PASS;DM-профиль names/load/crypto/history/e2e/invite/media/worker8/8 PASS.
|
||||
ASAN/UBSan/LSan8/8 PASS,detect_leaks=1,halt_on_error=1;отчётов памяти/UB/утечек нет.
|
||||
Изолированная /tmp/utun-dm-expiry-sanitizers побайтно совпадает с проверяемыми
|
||||
dm_mailbox_media.c/.h,tests/Makefile.am/test_dm_media.c;core/libuasync/Opus содержат
|
||||
ASAN/UBSan-инструментацию. В media-логе при restart один packet too small for init
|
||||
size68;прогон восстановил линк и завершился,это не отчёт санитайзера.
|
||||
Первую сборку запустил до завершения изменения linker flags,она отказала на
|
||||
__real_sqlite3_bind_blob. После окончания правок повторная полная сборка и отдельная
|
||||
сборка media PASS;проверки выполнялись на согласованных окончательных объектах.
|
||||
|
||||
Полный check.sh205/205 PASS,0 failed/0 skipped;proxy9/9,burst1/1,load8/8 PASS,
|
||||
134217728байт,exit0,173с. Сеть — отдельный IPv6 namespace,настройки хоста не менялись.
|
||||
Логи:/tmp/dm-expiry-fix-{final-build,media-build,profile-results,test_dm*,validation}.log,
|
||||
/tmp/dm-expiry-fix-{negative-build,negative,original-build,original}.log,
|
||||
/tmp/dm-expiry-fix-{sanitizers-build,sanitizers-results,sanitizers-test_dm*,full-check,netns}.log.
|
||||
Задача завершения TTL закрыта. Общий аудит DM частичный;прежние отметки сохраняются.
|
||||
GUI/Android/Windows/FreeBSD не проверялись;DELETE_ACK fault matrix отдельно не добавлялась.
|
||||
|
||||
12. Завершение аудита контрактов DM — 27.10.2026
|
||||
|
||||
База заключительного этапа: /home/user/utun4, master, исходный HEAD07f63c3047b4a9bef6c568ca590d3fefde95816f.
|
||||
Полностью сверены все семь заголовков src/dm/ с реализациями core, crypto, history,
|
||||
mailbox, mailbox_media и media; doc/dm_arch.md, doc/pm.txt и doc/pm_invites.md.
|
||||
Это завершение проверки описаний/API, не утверждение об отсутствии всех возможных
|
||||
ошибок программы. Новых подтверждённых дефектов выполнения в этом этапе нет.
|
||||
|
||||
В заголовках уточнены:
|
||||
- Заимствование аргументов, один освобождаемый блок запросов UI, жизнь instance,
|
||||
поток uasync и запрет доступа workers к SQLite/сети.
|
||||
- Проверка тела/подписи отдельно от области router/ролей, точные повторы и конфликты,
|
||||
границы транзакций при send/accept, подпись квитанции после commit входящего сообщения.
|
||||
- Независимые квитанции сообщения и файла; STORED не удаляет исходящий outbox.
|
||||
- Форматы DM/mailbox/media control, native-endian чисел и big-endian attachment_info,
|
||||
лимиты send data с descriptor и размеры выходных буферов квитанций.
|
||||
- Поколения/hidden, сохранение ключей и seq, отпечатки удалённых тел, отмена старых
|
||||
invite/transfer и отбрасывание завершения worker старого поколения.
|
||||
- Роли S/M, выбор нового storage без прежних назначений, фиксация назначения до STORE,
|
||||
local S/M control и отличие передачи пакета от durable приёма.
|
||||
- File holds, манифест путей, busy=-1 уборщика, повтор удаления и fsync каталога;
|
||||
пользовательский исходник не передаётся уборке без явного remove_source.
|
||||
- Запуск mailbox/core/media и остановка quiesce -> media_async drain -> destroy
|
||||
с живыми SQLite/группами, сохранение данных между stop/restart.
|
||||
|
||||
dm_arch согласован с порциями таймеров32/8, обычным retry5с и отдельными retry3600с/30с,
|
||||
сохранённым retry_at текстовых очередей и сбросом медиа при restart. Добавлены wire
|
||||
таблица, порядок владельцев и восстановление оригинала после EXPIRED: срок копии M
|
||||
не запрещает B получить исходный файл A; поздний EXPIRED не портит готовый файл.
|
||||
pm.txt дополнен привязкой требований к текущим owners/ролям/настройкам, dm_arch
|
||||
больше не называет этот аудит частичным. Исторические результаты сборок помечены датой.
|
||||
|
||||
Сверены относящиеся к контрактам callers: chat_service_start/stop, attachment_send,
|
||||
file_transfer lookup/cancel, Qt DmInviteDialog/MainWindow/MessageList и bridge,
|
||||
Android JNI/ViewModel/PmInviteDialog, headless DM handlers, регистрация JOIN-ключей
|
||||
и планирование member_sync после JOIN_READY/принятия участника.
|
||||
Клиентский код просмотрен для описанных DM-событий/выбора адресата/подготовки файла;
|
||||
полный аудит Qt/Android и остальных общих модулей этим не заявляется.
|
||||
pm_invites описывает владение запросом, поколение до регистрации и отсутствие
|
||||
отката открытой беседы при последующей ошибке; отмена PM не отзывает JOIN-ключ.
|
||||
|
||||
Проверки текущего этапа:
|
||||
- make -C src -j4 и сборка всех девяти существующих DM-целей: PASS.
|
||||
- Native test_dm/history/names/load/e2e/invite/media/worker/media_cleanup:9/9 PASS.
|
||||
test_dm_load:79 проверок; names:68 случаев и273 снимка, независимый Python JSON
|
||||
parser прочитал каждый полный снимок. Worker:25 запусков,50 heartbeat,max_gap11.3мс.
|
||||
Media:phase12,6266 heartbeat,allocations0->0; проверены11 retirement SQL faults,
|
||||
18 release faults, потерянные control/restart и возврат точного оригинала после TTL.
|
||||
- Linux cleanup component:13 SQL faults для scan/ACK/body, повтор после restart,
|
||||
несколько M, потерянный/повторный/чужой ACK и поздний STORED; exit0.
|
||||
Сеть перехвачена, receipt задан фикстурой; криптографию и физическое удаление M
|
||||
проверяет отдельный test_dm_media. Одношаговый autocommit может уже завершиться
|
||||
до искусственного finalize failure; тест проверяет это состояние и идемпотентный повтор.
|
||||
- Каждый из семи заголовков: C99 и C++20 с -Wall -Wextra -Werror,14/14 PASS.
|
||||
Для private/media C API C++ использует extern "C", как существующие клиенты;
|
||||
include path lib/ соответствует проекту и предоставляет sqlite3.h.
|
||||
- Все C-файлы DM побайтно совпадают с начальным снимком; C-токены всех заголовков
|
||||
совпадают после исключения комментариев/пробелов. Алгоритмы, API/ABI, wire и SQL
|
||||
этим этапом не менялись. git diff --check PASS; новые тесты не добавлялись.
|
||||
|
||||
Логи и снимки: /tmp/dm-contract-finish-before/sha256.json,
|
||||
/tmp/dm-contract-finish-{build,final-core-build,final-tests-build,headers,validation}.log,
|
||||
/tmp/dm-contract-finish-native-{results,test_dm*}.log; исходные тела/JSON — в test artifacts.
|
||||
Сетевой профиль выполнен вне sandbox. Первый sandbox-запуск cleanup вернул exit0,
|
||||
но uasync сообщил Operation not permitted при создании служебного fd; окончательный
|
||||
повтор вне sandbox прошёл без этих сообщений. Сетевые настройки хоста не менялись.
|
||||
Новые full check.sh, ASAN/UBSan/LSan, сборки/интерактив Qt/Android/Windows/FreeBSD
|
||||
не выполнялись; прежние результаты не приписываются заключительному прогону.
|
||||
Изменения radio и doc/tasks.md сохранены отдельно от DM-коммитов; push не выполнялся.
|
||||
|
||||
13. Rebase перед push — 27.10.2026
|
||||
|
||||
После отказа push (origin содержал новые коммиты) пользователь запросил rebase.
|
||||
Получен origin/master492f6adc; все шесть DM-коммитов перенесены на эту базу.
|
||||
Единственный конфликт — dm_messages_req в dm_core.h: сохранены generation из origin
|
||||
и проверенные комментарии. Остальные пять патчей перенесены без изменения по range-diff.
|
||||
Старые хеши и результаты раздела12 относятся к состоянию до переноса.
|
||||
|
||||
В origin появился generation в запросе истории и CHAT_EVT_DM_MESSAGES:
|
||||
[conv_len:1][conv][generation:8][JSON]. Сверены полный trampoline, проверка current,
|
||||
новая регрессия test_dm_history и получение снимка Qt/Android. dm_arch уточнён под
|
||||
этот формат. Все C-файлы DM побайтно совпадают с новым origin; токены семи заголовков
|
||||
совпадают после исключения комментариев/пробелов. C99/C++20:14/14 PASS.
|
||||
|
||||
Перед rebase сохранены21 незакоммиченных radio-файл, включая untracked, в stash
|
||||
и отдельной резервной копии с SHA256/режимами. После переноса восстановлены все21:
|
||||
18 файлов побайтно, doc/tasks.md/tests/Makefile.am/tools/chatgui/CMakeLists.txt
|
||||
слиты с origin; списки добавленных/удалённых строк radio точно совпали с сохранёнными.
|
||||
Повторная проверка после сборки также PASS; radio не включается в DM-коммиты.
|
||||
|
||||
Свежий ./build.sh --no-gui -j4:PASS. После пересборки native DM9/9 PASS,
|
||||
включая скрытые/устаревшие запросы истории и точное поколение опубликованного снимка.
|
||||
Дополнительно radio/radio_mesh/radio_headless/radio_headless_queue4/4 PASS.
|
||||
Это профиль13 бинарников, не полный check.sh; новые GUI/Android/sanitizer прогоны
|
||||
не выполнялись. Радио EOS C++-профиль не повторялся, его исходники сохранены побайтно.
|
||||
Логи:/tmp/utun4-dm-rebase-{build,results,radio-preservation,test_*}.log.
|
||||
Резервная копия указана в /tmp/utun4-dm-rebase-backup-path.txt.
|
||||
@@ -14,11 +14,33 @@ Android показывает зашифрованное вложение отд
|
||||
Список чатов сохраняет общее название зашифрованного вложения вместо manifest.
|
||||
|
||||
Автор генерирует независимые случайные crypto_UUID и ключ AES-256 на каждую
|
||||
подготовку. Ключ записывается в локальную SQLite до шифрования. Готовый контейнер
|
||||
передаётся через обычные media_index/media_delivery. UUID транспортного медиа
|
||||
подготовку. Ключ, UUID, автор, пути и метаданные записываются в локальную SQLite
|
||||
собственной короткой транзакцией до передачи подготовленного файла worker.
|
||||
Проверяются prepare, все bind, step, ровно одна добавленная строка, finalize и commit.
|
||||
Внешняя транзакция отклоняется без изменений. До принятия ошибка возвращает `-1`:
|
||||
файл остаётся у вызывающего, worker и событие отправки не создаются.
|
||||
После commit возвращается `0`, файл принадлежит задаче и удаляется также при её отказе;
|
||||
ненулевой request получает один `MSG_SUBMITTED`, в том числе синхронно при отказе запуска worker.
|
||||
Исходный пользовательский файл сохраняется: общий frontend передаёт сюда собственную подготовленную копию.
|
||||
|
||||
Неудачный rollback с оставшейся открытой транзакцией отключает модуль вложений и
|
||||
запрашивает остановку ядра. Desktop и Android учитывают запрос в своих poll-циклах;
|
||||
владелец освобождает ядро и закрывает SQLite после возврата из poll. Текущий poll может
|
||||
доставить другие callbacks до выхода. Политика общей БД остаётся WAL/synchronous=NORMAL:
|
||||
завершённый commit до worker не является гарантией сохранности после отключения питания.
|
||||
История дефекта и проверок — в [chat_audit_findings.txt](chat_audit_findings.txt).
|
||||
|
||||
Готовый контейнер передаётся через обычные media_index/media_delivery. UUID транспортного медиа
|
||||
и crypto_UUID имеют разные назначения: первый идентифицирует сетевые блоки,
|
||||
второй — контейнер и ключ. Повтор доставки передаёт прежний контейнер.
|
||||
|
||||
Связь авторского ключа с `message_id` и SHA256 контейнера фиксируется собственной
|
||||
транзакцией до публикации сообщения. Проверяются все SQL-этапы и одна строка;
|
||||
точный повтор допустим, замена сообщения или hash запрещена. Внешняя транзакция
|
||||
остаётся нетронутой, SQL-отказ сохраняет прежнюю пару. UUID-префикс принимает только
|
||||
канонический base64url с правильными padding/завершающими битами и lowercase hex;
|
||||
при отказе выходной UUID не изменяется.
|
||||
|
||||
## Подписанное сообщение и контейнер
|
||||
|
||||
В подписанном сообщении `ct=application/x-utun-encrypted`, базовое имя тела —
|
||||
@@ -68,6 +90,18 @@ Nonce метаданных использует `part=UINT64_MAX`; файл —
|
||||
Completion не удаляет файлы по заранее вычисленному имени: очистка учитывает,
|
||||
какие артефакты задача действительно создала или опубликовала.
|
||||
|
||||
Перед расшифровкой собственная SQL-транзакция фиксирует `decrypting=1` ровно для
|
||||
одного подходящего файла с выбранным ключом. Worker запускается только после commit.
|
||||
Завершение также владеет транзакцией: отличает удалённую строку от ошибки чтения,
|
||||
проверяет все bind/step/changes/finalize и сохраняет метаданные/ready либо ошибку.
|
||||
До commit оно остаётся в списке задач instance; SQL-отказ повторяется таймером
|
||||
с готовым результатом, без повторного worker. Внешняя транзакция остаётся нетронутой.
|
||||
Лимит16 включает workers и такие завершения; отказ новой отправки сохраняет source
|
||||
у вызывающего. Событие/публикация сообщения следуют после commit результата.
|
||||
Quiesce останавливает повторы; после завершения workers destroy освобождает остаток
|
||||
списка и принадлежащие ему результаты. При restart незавершённая расшифровка может
|
||||
выполниться заново по сохранённому ключу и контейнеру.
|
||||
|
||||
Реестр `file_io` защищает активные temp от очистки кеша, включая время после close.
|
||||
Восстановление до запуска workers и очистка после их завершения удаляют оставшиеся
|
||||
`<UUID>.uenc.tmp.<UUID>` / `<UUID>.file.tmp.<UUID>` в `media/decrypted`.
|
||||
@@ -90,11 +124,61 @@ instance того же процесса остаются защищены общ
|
||||
Квота медиакеша учитывает обе копии; очистка кеша не удаляет ключ. При повторном
|
||||
открытии удалённая открытая копия восстанавливается с сохранённым ключом.
|
||||
|
||||
Производная запись контейнера создаётся из проверенной истории собственной
|
||||
транзакцией: новая строка проверяется целиком, точный дубликат сохраняет ключ,
|
||||
метаданные и флаги. SQL/OOM-отказ (`-1`) допускает повтор; неверный manifest или
|
||||
коллизия (`-2`) его не запускают. Ошибка этого индекса не отменяет событие уже
|
||||
сохранённого сообщения. Общая очередь backfill удерживает до16 каналов, через2с
|
||||
повторяет чтение истории; переполнение ставит полный scan. Обрабатывается отделённый
|
||||
batch, поэтому SQL-отказ во время прохода может безопасно поставить следующий.
|
||||
Startup и ручная загрузка также восстанавливают индекс, включая сохранённый ciphertext.
|
||||
Секретный ключ из истории не восстанавливается.
|
||||
|
||||
`wanted` и сброс `ready/error` при ручной загрузке фиксируются вместе. Состояние
|
||||
`downloaded` и автоматический запрос обычного участника также сохраняются отдельной
|
||||
проверенной транзакцией, событие следует после commit; неизменный повтор молчит.
|
||||
Новая проверенная передача сбрасывает прежний error для повторной расшифровки,
|
||||
простое обнаружение старого cache сохраняет error. Внешняя транзакция запрещена.
|
||||
JSON истории вместе с manifest ограничен `DB_SYNC_MESSAGE_MAX` (4000байт), как
|
||||
для остальных сообщений. Ручной обработчик использует тот же предел; отдельного
|
||||
достижимого усечения на4096байтах не было.
|
||||
|
||||
При локальном удалении канала SQLite-trigger удаляет ключи, запросы и outbox в той
|
||||
же транзакции, что `channels`; незавершённый каскад, включая `RAISE(IGNORE)`, отменяет
|
||||
удаление. Также проверяется отсутствие канала и Merkle-хешей после DELETE.
|
||||
SQL-ошибка сохраняет все записи и файлы; сетевые ресурсы к этому моменту уже могут
|
||||
быть остановлены, поэтому требуется повтор команды. Внешняя транзакция отклоняется
|
||||
до отмены ресурсов. Открытая после отказа rollback транзакция требует stop владельцем.
|
||||
Только после commit удаляются ciphertext через media_index и собственные канонические
|
||||
`decrypted/<channel>/<UUID>.file`. Посторонние имена, подкаталоги и temp этот проход
|
||||
сохраняет. I/O-ошибка не возвращает удалённый канал: она логируется, очистку можно
|
||||
повторить. Завершение активного worker отдельно удалит свой результат, увидев отсутствие строки.
|
||||
|
||||
После полной проверенной загрузки обычный клиент автоматически запрашивает
|
||||
ключ автора. Фоновая копия узла с подписанной ролью `storage=yes` запрос не
|
||||
создаёт; ручная пользовательская загрузка на таком узле запрашивает доступ.
|
||||
Сетевая классификация `peers.storage` для этого не используется.
|
||||
|
||||
Таймер создаёт исходящий REQUEST собственной BEGIN IMMEDIATE/COMMIT: повторно
|
||||
проверяет, что файл скачан и нужен, ключ отсутствует и прежнего запроса нет;
|
||||
затем сохраняет ровно один исходящий запрос и команду outbox. Все SQL-этапы,
|
||||
число строк и commit проверяются. Уведомление и отправка следуют после commit.
|
||||
Отказ сохраняет прежние строки, следующий проход повторяет подготовку.
|
||||
При внешней транзакции весь проход файлов и отправки откладывается, не меняя её.
|
||||
Если rollback не закрыл собственную транзакцию, таймер не запускается повторно:
|
||||
модуль снимается и владелец останавливает ядро после poll.
|
||||
|
||||
Перед каждой сетевой попыткой очередь собственной транзакцией проверяет команду и
|
||||
фиксирует `retry_at=now+5с`. Только после commit выполняются проверка допуска и сеть;
|
||||
SQL-отказ сохраняет команду/дату и не отправляет пакет. За проход обрабатываются до16
|
||||
команд, периодический tick —10с. Пустая очередь освобождает свою транзакцию без
|
||||
вызова commit hook. Отсутствие маршрута, backpressure и отказ отправки сохраняют очередь.
|
||||
|
||||
Очистка неподтверждённых подготовок выполняется до workers и после их завершения.
|
||||
Если SQLite-транзакция открыта, весь recovery откладывается, файлы сохраняются:
|
||||
незакоммиченное удаление истории не доказывает отсутствие публикации. Инициализация
|
||||
модуля также отклоняет внешнюю транзакцию до изменения таблиц/таймера/сети.
|
||||
|
||||
В шапке desktop-клиента кнопка «Запросы расшифровки» видна при наличии запросов без решения
|
||||
и показывает их число.
|
||||
Список содержит участника, вложение, дату и отдельные кнопки выдачи/отказа.
|
||||
@@ -121,9 +205,43 @@ REQUEST=1, REQUEST_ACK=2, GRANT=3, DENY=4, RESULT_ACK=5
|
||||
ответ только от автора, по своему существующему запросу и точной идентичности
|
||||
контейнера. Конфликтующие ключи и результаты отклоняются.
|
||||
|
||||
REQUEST_ACK отправляется после сохранения запроса. GRANT/DENY появляются
|
||||
в outbox только после сохранения решения. Полученный ключ сохраняется до
|
||||
RESULT_ACK и до запуска расшифровки. Повторы возвращают прежнее решение и не
|
||||
Приём REQUEST использует собственную BEGIN IMMEDIATE/COMMIT. Внешняя транзакция
|
||||
отклоняется без изменений до чтения идентичности/прав. Под блокировкой проверяются
|
||||
UUID/hash контейнера, допуск участника и авторство, затем читается существующий запрос.
|
||||
Отсутствующий запрос вставляется со state=0 и проверкой одной строки; дубликат сохраняет
|
||||
решение и created_at. Только incoming=1 и state=0/2/3/4 допустимы:0 ожидает автора,
|
||||
2/4 повторяют GRANT в outbox,3 повторяет DENY. Все SQL-этапы проверяются; ACK/событие
|
||||
отправляются после commit. При отказе прежние запрос/очередь сохраняются, ACK не отправляется.
|
||||
Если после commit ACK не доставлен, клиент повторяет REQUEST; сохранённый запрос
|
||||
позволяет повторно подтвердить приём и прежнее решение. Неудачный rollback с открытой
|
||||
транзакцией требует quiesce/stop и закрытия ядра владельцем после poll.
|
||||
История дефекта и исправления — [chat_audit_findings.txt](chat_audit_findings.txt).
|
||||
|
||||
GRANT/DENY появляются
|
||||
в outbox в одной собственной BEGIN IMMEDIATE/COMMIT с решением автора. Проверка прав
|
||||
также выполняется внутри неё. Изменяется ровно один входящий запрос в state=0;
|
||||
уже принятое решение повторной командой не заменяется. Общий помощник записи outbox
|
||||
проверяет prepare, все bind, step, ровно одну строку и finalize, транзакцией управляет вызывающий.
|
||||
Внешняя транзакция отклоняется до проверки прав и остаётся нетронутой. SQL-отказ,
|
||||
включая пропуск записи через RAISE(IGNORE), сохраняет прежние состояние и очередь.
|
||||
Возврат0 и единственное уведомление UI следуют после проверенного commit; доставка
|
||||
выполняется позже. Trampoline при ошибке отправляет событие с success=0, позволяя повторить команду.
|
||||
Неудачный rollback с открытой транзакцией отключает модуль и запрашивает stop ядра после poll.
|
||||
История дефекта и исправления — в [chat_audit_findings.txt](chat_audit_findings.txt).
|
||||
|
||||
Ответы REQUEST_ACK/GRANT/DENY/RESULT_ACK
|
||||
применяются собственной короткой транзакцией: чтение запроса и очереди, запись ключа
|
||||
при GRANT, изменение состояния и удаление ожидаемой команды. Все prepare/bind/step,
|
||||
числа строк, finalize и commit проверяются. Удаляемая команда может уже отсутствовать
|
||||
после REQUEST_ACK либо предыдущего результата; если она существовала, пропуск DELETE
|
||||
считается ошибкой. Внешняя транзакция отклоняется без изменений.
|
||||
|
||||
RESULT_ACK для GRANT/DENY и уведомление интерфейса отправляются после commit.
|
||||
При SQL-отказе прежние ключ/состояние/outbox сохраняются, подтверждение не отправляется,
|
||||
автор повторяет выдачу. Если rollback оставил открытую транзакцию, модуль отключается
|
||||
и запрашивает остановку ядра владельцем после poll. Найденный ранее bind-дефект и
|
||||
его исправление описаны в [chat_audit_findings.txt](chat_audit_findings.txt).
|
||||
Повторы возвращают прежнее решение и не
|
||||
создают новых строк. Таймер обрабатывает ограниченную порцию очереди с
|
||||
backpressure и повтором через 5 секунд; restart продолжает очереди.
|
||||
|
||||
@@ -146,9 +264,76 @@ backpressure и повтором через 5 секунд; restart продол
|
||||
`test_attachment_send` проверяет восемь отказов MIME до ключей/worker/файлов,
|
||||
затем останавливает сервис после настоящего crypto rename до completion.
|
||||
|
||||
`test_chat_attachment` проверяет 13 SQL-отказов с сохранением source/прежних строк/файлов,
|
||||
отсутствием worker и события до принятия, затем настоящую публикацию при каждом повторе.
|
||||
Отдельное readonly-соединение проверяет точные ключ, автора, UUID, метаданные и пути до worker.
|
||||
Дополнительно проверяются настоящий commit veto, INSERT ABORT/IGNORE, внешняя транзакция,
|
||||
синхронный отказ запуска, restart с опубликованным ключом и отказ rollback с закрытием БД владельцем.
|
||||
Дополнительно:9 SQL-отказов захвата расшифровки,15 записи успешного результата и8
|
||||
записи ошибки настоящего worker. Проверяются ABORT/IGNORE, commit veto, внешняя
|
||||
транзакция, точные ключ/метаданные/флаги через reader перед worker и в событии,
|
||||
один worker при повторе completion, отмена ожидающего результата при delete/stop,
|
||||
restart сервиса и rollback fault без повторного таймера. 16 готовых авторских задач
|
||||
удерживаются при отказе чтения; семнадцатая сохраняет source, снятие отказа публикует
|
||||
все16 ровно по одному разу. Баланс выделений восстанавливается.
|
||||
Удаление проверяется14 отказами: ABORT/IGNORE каналов/трёх локальных таблиц/Merkle,
|
||||
prepare/bind/step/finalize удаления метаданных; дополнительно настоящий commit veto,
|
||||
внешняя транзакция и rollback fault с остановкой владельцем. На отказе reader видит
|
||||
прежние точные ключ/метаданные/request/state/created/outbox/retry, файлы сохранены;
|
||||
в единственном CHANNEL_DELETED — канал/история/локальные записи/Merkle уже отсутствуют.
|
||||
Дополнительные проверки:13 SQL-отказов привязки авторской пары,25 наблюдения новой/существующей
|
||||
идентичности и60 операций wanted/downloaded. Сверяются все поля через независимый reader,
|
||||
в том числе в событии; старые ключи/метаданные/соседняя запись сохраняются.
|
||||
Открытое удаление истории при shutdown сохраняет опубликованные файлы; rollback и
|
||||
restart восстанавливают прежний снимок. Внешняя транзакция также запрещает повторный init.
|
||||
Проверяются канонический UUID,коллизия/внешняя транзакция/commit veto/ABORT/IGNORE,
|
||||
повторная ошибка в отделённом batch,восстановление при startup без сети и17 каналов
|
||||
с fallback после заполнения очереди16. Ручной обработчик передаёт все23 подписанных
|
||||
блока manifest длиной3845байт в порт загрузки; отказ порта не удерживает контекст.
|
||||
|
||||
`test_attachment_requests` проверяет247 SQL-отказов/повторов новой записи и дубликатов
|
||||
0/2/3/4, с пустой/существующей очередью. Подписанный мембер и сообщение создаются
|
||||
штатными API, локальная запись вложения — фикстурой. Readonly reader в точке ACK
|
||||
и события проверяет committed key/error/request/state/date/command/retry и соседний ключ.
|
||||
Дополнительно: ABORT/IGNORE при INSERT запроса и INSERT/UPDATE outbox, настоящий commit veto,
|
||||
внешняя транзакция, неверные состояние/направление/идентичность/флаги/мембер,
|
||||
потеря ACK из-за маршрута/backpressure/send failure и повтор с прежней датой,
|
||||
отказ rollback с остановкой и закрытием БД владельцем.
|
||||
Тот же тест проверяет52 SQL-отказа создания исходящего REQUEST с пустой/существующей
|
||||
outbox, ABORT/IGNORE, commit veto, внешнюю транзакцию до создания и до отправки,
|
||||
отсутствие повторного события/запроса, rollback fault без повторного таймера.
|
||||
Readonly reader видит обе строки до события и перед REQUEST; при отсутствии маршрута,
|
||||
backpressure и ошибке send настоящий restart сохраняет очередь и исходную дату запроса.
|
||||
|
||||
`test_attachment_decisions` проверяет116 SQL-отказов/повторов выдачи и отказа, с пустой
|
||||
и существующей очередью: чтение подписанного мембера, допуск автора, запись состояния,
|
||||
outbox, BEGIN/COMMIT. Участник имеет настоящие join/invitation подписи, сообщение
|
||||
сохранено штатным chat_core; запись локального вложения создаётся фикстурой.
|
||||
Readonly-соединение в событии проверяет committed ключ/состояние/команду/retry_at/created_at
|
||||
и сохранность соседнего ключа. Дополнительно: настоящий commit veto, ABORT/IGNORE для
|
||||
UPDATE состояния и INSERT/UPDATE очереди, внешняя транзакция, удалённый мембер,
|
||||
неверное направление и повторное решение, один GUI event, остановка при отказе rollback.
|
||||
|
||||
`test_encrypted_attachment`: реальные A–S–B/C, скачивание с S при выключенном A,
|
||||
перезапуски автора с запросами и получателя с ключом, выдача B и отказ C,
|
||||
отсутствие ключа на S/C, отрицательные control-пакеты, повтор GRANT,
|
||||
восстановление удалённого plaintext без нового согласия и удаление канала.
|
||||
На Linux первый настоящий REQUEST от B обрабатывается внутри внешней транзакции:
|
||||
проверяются её сохранность, отсутствие ACK/события/авторской записи и сохранение очереди B.
|
||||
После rollback владельцем и restart автора настоящий повтор REQUEST принимается;
|
||||
readonly reader видит сохранённый pending-запрос до отправки реального REQUEST_ACK.
|
||||
Первый bind hash при создании индекса на B возвращает NOMEM. В событии сообщения
|
||||
reader видит committed историю и отсутствие индекса,очередь восстановления уже создана;
|
||||
перед настоящим REQUEST reader подтверждает восстановленные UUID/hash/downloaded/wanted.
|
||||
На Linux пропуск GRANT outbox и внешняя транзакция автора отвергаются без изменения
|
||||
ожидающего запроса и без события. После restart автора повтор решения сохраняет
|
||||
state+outbox; отдельное readonly-соединение подтверждает их в момент единственного события UI.
|
||||
На Linux первый bind полученного GRANT намеренно возвращает SQLITE_NOMEM: проверяются
|
||||
полный откат и отсутствие ACK, сохранение GRANT у автора, затем реальная повторная выдача
|
||||
и расшифровка. Перед каждым RESULT_ACK получателя отдельное readonly-соединение видит ключ.
|
||||
`test_attachment_results` отдельно проверяет 105 SQL-отказов/повторов четырёх ответов,
|
||||
commit veto, ABORT/IGNORE для key/state/outbox, внешнюю транзакцию, backpressure,
|
||||
дубликаты, конфликт ключей/решений и остановку при отказе rollback. В точке ACK/события
|
||||
readonly-соединение проверяет committed ключ/состояние/очередь и сохранность соседней записи.
|
||||
При startup/restart дополнительно проверяются оба дерева: stale temp удаляются,
|
||||
открытый и закрытый активные temp, конечные файлы и чужие имена сохраняются.
|
||||
@@ -3,6 +3,20 @@ ETCP: независимая обратная связь каналов и до
|
||||
|
||||
Статус
|
||||
|
||||
24.10.2026, объединение с актуальным master: удаление чужого member_files temp
|
||||
уже исправлено0f42f92f, ранняя отмена —20b4975d; повторный фикс не требуется.
|
||||
Описание ниже185/186 относится к прежней базе a198cfb9, до этих исправлений.
|
||||
Актуальный аудит и проверки: doc/member_files_audit_findings.txt.
|
||||
Небольшой недобор до условных85% — диагностическая метрика; он сам по себе
|
||||
не оправдывает усложнения BBR. Дальше приоритет STCP backpressure, смена RTT/BW
|
||||
нескольких путей и повторный отказ с проверкой доставки, пауз и роста очередей.
|
||||
Объединённая версия897cc007: ./build.sh --no-gui -j4 PASS; полный check.sh186/186,
|
||||
proxy9/9, burst1/1, load8/8 PASS;134217728 проверенных байт,162с проверки.
|
||||
test_member_files_network PASS phase21: EXISTS/max_bytes/ранний cancel сохраняют
|
||||
прежнее назначение и чужой temp, во всех трёх случаях target=1, foreign_temp=1.
|
||||
Логи: /tmp/etcp-master-integration-build.log, /tmp/etcp-master-integration-check.log;
|
||||
подробные результаты: /tmp/utun-etcp-solo-20261024/tests/logs/.
|
||||
|
||||
24.10.2026, продолжение после push: отдельно исследован одиночный дуплекс2Мбит/с.
|
||||
Все прежние15 локальных коммитов вошли в origin/master после rebase; патчи сохранены.
|
||||
Из-за параллельных изменений основного каталога продолжение изолировано в ветке
|
||||
@@ -105,6 +119,13 @@ DATA EWMA остался25.353мс, RTO34.135мс, хотя timestamp-RTT пок
|
||||
Новых очередей/таймеров/полей нет. Ранняя уже назначенная проверка применяет текущий RTO.
|
||||
Контрольный временный бинарник восстановил DATA-измерения примерно за200мс и дал5 переходных повторов.
|
||||
|
||||
29.10.2026: BLE обнаружил отдельную ошибку верхней границы: при измеренном KA RTT2..6с
|
||||
RTO оставался1с и удалял физическую попытку до ACK. Потолок1с снят, формула и минимум10мс
|
||||
сохранены; без RTT начальное значение1с. uint64-расчёт ограничивается UINT32_MAX с WARN
|
||||
при насыщении. test_etcp_windows проверяет RTT2с/ACK1.5с без повтора и с обновлением BBR,
|
||||
таймер7.8672с, обе оценки RTT, jitter и предельные значения.63 сценария PASS.
|
||||
Очередь BLE ниже локального completion этим изменением не ограничивается; см.doc/ble_etcp.txt.
|
||||
|
||||
Пересчёт прежнего таймера attempts вызывается также при изменении timestamp-RTT,
|
||||
включая сокращение без новых DATA/ACK. Точная регрессия123->27мс до этого оставляла
|
||||
старый срок, опаздывающий на96мс. Теперь срок сокращается сразу; раннюю проверку при росте RTO можно сохранить.
|
||||
|
||||
+23
-2
@@ -18,6 +18,18 @@ SHA256 считается по картинке; при одинаковой в
|
||||
Публикация одинаковой картинки не создаёт новую версию. Удаление — подписанная
|
||||
запись с size=0 и новой версией; подпись/версия сохраняются, старый пир не может
|
||||
восстановить удалённую картинку. Чтение проверяет сохранённый хеш и подпись.
|
||||
Отказ SQL/проверки оставляет выход group_avatar_get неизменным. Приватный ключ
|
||||
читается целиком:ровно32 байта BLOB,с проверкой bind/step/finalize;временные копии
|
||||
очищаются на всех выходах. Сломанный/отсутствующий ключ не даёт права публикации.
|
||||
|
||||
set/apply владеют BEGIN IMMEDIATE:допуск,выбор/сравнение версии и UPDATE выполняются
|
||||
под одной блокировкой. Все bind/step/changes=1/finalize и COMMIT проверяются.
|
||||
Чужая транзакция отклоняется до действия,включая no-op;без изменения выполняется
|
||||
ROLLBACK без commit hook. Ошибка сохраняет запись и не отправляет CHANNEL_UPDATED/
|
||||
STATE. Если сам rollback оставляет транзакцию открытой,запрашивается stop;
|
||||
владелец завершает остановку/закрытие БД вне callback. Миграция схемы использует
|
||||
savepoint и допускает внешнюю транзакцию:ошибка ROLLBACK TO не сопровождается
|
||||
RELEASE частичных ALTER;откат/закрытие остаются владельцу.
|
||||
|
||||
Транспорт
|
||||
---------
|
||||
@@ -38,8 +50,10 @@ DATA header=112 байт. Картинка разбивается на блок
|
||||
|
||||
Порядок блоков гарантирует router. Сборка проверяет offset, постоянные version,
|
||||
total_size/hash/signature и допустимые размеры. До полной сборки прежняя картинка
|
||||
остаётся в БД. После SHA256/Ed25519 вся запись заменяется одним UPDATE, затем
|
||||
остаётся в БД. После SHA256/Ed25519 вся запись заменяется одним committed UPDATE, затем
|
||||
CHANNEL_UPDATED доставляет изменение GUI и STATE распространяется соседям.
|
||||
Приём завершает работу со сборкой/peer и только затем уведомляет интерфейс:
|
||||
обработчик вправе отменить задачи группы,не оставляя обращения к освобождённой сборке.
|
||||
Промежуточные блоки не публикуются, ACK/retransmit каждого блока — только router.
|
||||
|
||||
Отправка заполняет router до нижнего порога send_q count<=0 и ждёт send_ready;
|
||||
@@ -59,7 +73,8 @@ ChatGUI
|
||||
Настройки группы: Upload group avatar/Remove group avatar, предпросмотр и статус.
|
||||
Кнопки доступны только при локальном приватном ключе. Применение сразу, отдельно
|
||||
от Save локальных настроек. Worker обрезает центральный квадрат, создаёт JPEG
|
||||
128×128, снижает качество/размер до лимита 16 КиБ. Вход ограничен 16 мегапикселями.
|
||||
128×128,при необходимости96×96/64×64,снижает JPEG quality85/70/50/35 до лимита16КиБ.
|
||||
Вход ограничен16*1024*1024 пикселями;SVG отклоняется.
|
||||
GUI отправляет байты в uasync; ядро повторно проверяет размер/членство/ключ.
|
||||
|
||||
SQLite — постоянный кеш. GroupAvatarManager хранит QImage до 32 МиБ, ключ кеша:
|
||||
@@ -76,6 +91,12 @@ test_group_avatar: настоящие два ядра/UDP/router, 16 КиБ че
|
||||
перезапуск канала, stale/conflict, редактор с ключом вне creator UID, подписанное
|
||||
удаление, повторное описание группы и restart БД. Schema failure откатывает
|
||||
частичные ALTER, сохраняет прежние данные/ключи, повторное добавление идемпотентно.
|
||||
Приём также проверен с отменой peer-задач прямо из CHANNEL_UPDATED.
|
||||
test_group_avatar_sql:27 SQL-отказов публикации и16 чтения аватарки/ключей,
|
||||
9 отказов схемы;каждый с успешным повтором. Проверены bind подписи/хеша,signing,
|
||||
ABORT/IGNORE/commit veto,чужая транзакция/no-op,предельная версия,неизменность
|
||||
выходов/readonly-записи,ошибка rollback и закрытие владельцем. Ключи NULL/31/33/
|
||||
TEXT отклоняются;неудачный ROLLBACK TO не фиксирует частичную схему.
|
||||
test_group_avatars (Qt): нормализация, лимит/отображение, кеш, права кнопок и отказ
|
||||
ядра без ключа, удаление. chat=debug показывает состояния и каждый 4КиБ блок;
|
||||
chat=info — публикации и жизненный цикл. Ошибки содержат group/peer/version/offset.
|
||||
@@ -0,0 +1,69 @@
|
||||
Аудит документации Local_transport — 26.10.2026
|
||||
|
||||
Объём и статус
|
||||
|
||||
Полностью прочитаны src/local_transport/local_transport.c и platform.c,
|
||||
сверены все объявления и комментарии трёх заголовков local_transport.h,
|
||||
platform.h и protocol.h. Все три заголовка готовы к отметке [V] в чек-листе.
|
||||
Для проверки интеграции прочитан chat_local.c; прочитаны три профильных теста
|
||||
и local_transport_ipv6.sh. Полный аудит src/chat этой записью не заявляется.
|
||||
LAN-раздел doc/node_snapshot.md уточнён; общая отметка документа остаётся [~].
|
||||
|
||||
Исправленные описания
|
||||
|
||||
1. Доставка и дедупликация. Убрано безусловное обещание отсутствия дублей:
|
||||
кеш хранит последние 128 успешно принятых (peer, epoch, request) до 30с,
|
||||
без продления повтором. Отказы received не кешируются. Request уникален
|
||||
среди pending, а повторное использование ID также ограничено кешем получателя.
|
||||
FAILED и отсутствие ACK не доказывают, что приложение не получило данные.
|
||||
Описаны 8 pending, немедленная отправка и до двух повторов по секундному tick.
|
||||
|
||||
2. Активность и callbacks. Уточнены синхронные уведомления из API, порядок
|
||||
activity_changed перед открытием/закрытием сокетов, завершение pending
|
||||
через FAILED и сохранение эпохи/дедупликации при паузе того же handle.
|
||||
Отказы открытия сокетов не означают отказ set_active: повтор при сверке;
|
||||
невозможность создать tick-таймер выключает транспорт. peers() очищает TTL
|
||||
и может вызвать routes_changed; route() callbacks не вызывает.
|
||||
|
||||
3. Broadcast и TTL. Изменение ника рассылается только при active+broadcasts;
|
||||
тот же ник не вызывает отправку. Отключение broadcasts сохраняет unicast,
|
||||
приём, сверку интерфейсов и TTL. Только обычный ANNOUNCE продлевает endpoint.
|
||||
Жизнь кандидата дополнительно ограничена его собственным TTL, интерфейсом
|
||||
и discovery-endpoint. Offline-gate применяется к подписанному входящему
|
||||
обмену, но не повторяется при route() и не нужен для ответа на CHALLENGE.
|
||||
|
||||
4. Wire. Описаны все смещения 60-байтного заголовка, NUL в 16-байтном magic,
|
||||
длина тела, BE, типы сообщений, неподписанные MESSAGE/ACK и ограничения
|
||||
discovery-порта. Уточнены подпись/nonce, scope, допустимые protocol,
|
||||
уникальные socket_id, ненулевые port/generation и пустой PROOF.
|
||||
|
||||
5. Платформа. Зафиксированы различия отбора POSIX/Windows, дедупликация
|
||||
адресов, усечение снимка, ответственность за ifindex/scope, допустимый
|
||||
источник и результаты local_receive (включая пустой UDP/would-block/-2).
|
||||
Добавлены предусловия встроенных BE/UTF-8 helpers и категория discovery.
|
||||
|
||||
Проверки
|
||||
|
||||
make -C src -j4 libutun.a и сборка трёх профильных тестов — PASS.
|
||||
Логи /tmp/local-transport-audit-build.log и
|
||||
/tmp/local-transport-audit-tests-build.log. Существующие предупреждения
|
||||
db_sync/etcp_connections/dm_media не относятся к изменённым комментариям.
|
||||
|
||||
Пять сценариев PASS, последовательно вне sandbox из-за запрета сокетов:
|
||||
- test_local_transport: UDP discovery, ACK retry/dedup, TTL, настройки,
|
||||
остановка/повторная активация; /tmp/local-transport-audit-discovery.log.
|
||||
- test_local_transport_packets: миллион некорректных пакетов, ограничения
|
||||
flood/памяти/events и восстановление; /tmp/local-transport-audit-packets.log.
|
||||
- test_local_routes: подписи, nonce/replay, BGP gate, поколения сокетов,
|
||||
TTL, NCD UDP/STCP и сохранность snapshot; /tmp/local-transport-audit-routes.log.
|
||||
- sh tests/local_transport_ipv6.sh: изолированная IPv6-only LAN;
|
||||
/tmp/local-transport-audit-ipv6-discovery.log.
|
||||
- sh tests/local_transport_ipv6.sh test_local_routes: IPv6 scope и LAN/NCD;
|
||||
/tmp/local-transport-audit-ipv6-routes.log.
|
||||
|
||||
В заголовках изменены только комментарии; логика, ABI и wire не менялись.
|
||||
Новых подтверждённых дефектов реализации в этом объёме не обнаружено.
|
||||
Прежняя задача о нестабильном ACK в test_local_transport остаётся открытой:
|
||||
успешный отдельный прогон не подтверждает устранение флака под общей нагрузкой.
|
||||
Полный check.sh, санитайзеры, native Windows/FreeBSD и GUI/Android runtime
|
||||
не запускались. [V] означает завершённую сверку документации по исходникам.
|
||||
+36
-8
@@ -25,7 +25,8 @@
|
||||
member_files_path=/путь/к/хранилищу (опционально, до 255 байт UTF-8)
|
||||
|
||||
Путь, квота и TTL читаются при старте чат-сервиса; изменение требует его restart.
|
||||
enabled проверяется на каждом запросе. Лимит одинаков для каждого UID отдельно,
|
||||
enabled проверяется на каждом серверном запросе; клиент доступа к другим storage остаётся доступен.
|
||||
Лимит одинаков для каждого UID отдельно,
|
||||
размер UID суммируется по всем группам на этом сервере. Значение в МиБ (1024*1024 байт).
|
||||
Счётчики used/reserved/files/directories хранятся в памяти дискового worker и восстанавливаются с диска только при старте.
|
||||
Каталоги не занимают байтовую квоту. files/directories считают опубликованные файлы и каталоги внутри деревьев UID
|
||||
@@ -86,7 +87,7 @@ Wire, версия 4
|
||||
Данные максимум 8192 байт; вместе с полным router-путём и crypto укладываются
|
||||
в лимит normalizer 16384 байта. Размер файла/смещение ограничены INT64_MAX.
|
||||
Пути — строгий UTF-8, относительные, разделитель '/'. Запрещены пустые компоненты,
|
||||
компоненты с ведущей '.', завершающей точкой/пробелом, управляющие символы,
|
||||
компоненты с ведущей '.', завершающей точкой/пробелом, ASCII C0/DEL,
|
||||
Windows device names и символы \ : * ? " < > |. Корень внутри UID — пустой путь.
|
||||
POSIX разрешает путь через *at/O_NOFOLLOW; regular-файлы с hardlink отвергаются.
|
||||
Windows удерживает родительские каталоги без SHARE_DELETE и не принимает reparse points.
|
||||
@@ -168,11 +169,19 @@ Windows удерживает родительские каталоги без SH
|
||||
Статусы (0..14): OK, ACCESS_DENIED, NOT_FOUND, INVALID_PATH, INVALID_RANGE,
|
||||
EXISTS, BUSY, STALE, NO_SPACE, IO_ERROR, BAD_PACKET, HASH_MISMATCH, CANCELLED, TIMEOUT,
|
||||
DIRECTORY_NOT_EMPTY.
|
||||
Мутации COMMIT/MKDIR/RENAME/REMOVE сохраняют успешный ответ на 120 секунд
|
||||
Мутации COMMIT/MKDIR/RENAME/REMOVE сохраняют успешный ответ до истечения 120 секунд от начала обработки
|
||||
(64 квитанции); тот же request_id с другим содержимым — BAD_PACKET.
|
||||
COMMIT дополнительно узнаётся по upload UUID. При заполнении квитанций новая
|
||||
мутация возвращает BUSY до истечения старых. Клиент повторяет запрос с прежним
|
||||
request_id раз в 5 секунд, общий срок 120 секунд. Повторы после этого срока
|
||||
мутация возвращает BUSY до истечения старых. В таком ответе cursor содержит
|
||||
остаток до ближайшего освобождения квитанции в timebase 0.1мс (1..1200000).
|
||||
Нулевой cursor означает BUSY другой причины с обычной задержкой 5 секунд.
|
||||
Клиент COMMIT/MKDIR/RENAME/REMOVE сохраняет pending и прежний request_id/параметры,
|
||||
ставит отменяемый таймер на этот срок и повторяет команду; промежуточный BUSY не завершает callback.
|
||||
При потере ответа обычный повтор идёт через 5 секунд. Общий срок от создания
|
||||
запроса остаётся 120 секунд и не продлевается даже при BUSY; pump не отправляет
|
||||
команду после этого срока. Поэтому повторы остаются внутри серверного окна
|
||||
квитанции, не требуя её досрочного вытеснения. Остальные команды возвращают BUSY caller.
|
||||
Повторы после этого срока
|
||||
не дают гарантии того же результата; перед новой мутацией требуется STAT.
|
||||
|
||||
Потоки, таймауты и остановка
|
||||
@@ -221,9 +230,15 @@ continuations; только затем worker освобождает storage. О
|
||||
|
||||
Transfer владеет CM handle до окончания/отмены; DOWN/TIMEOUT handle не освобождает.
|
||||
quiesce запрещает новые команды, отменяет таймеры, waiter и запросы; destroy
|
||||
вне callbacks дожидается только своих работ/completions и закрывает дисковый
|
||||
поток до уничтожения CHAT-групп. Контексты не освобождаются под работающим I/O.
|
||||
в потоке uasync вне callbacks самого модуля дожидается только своих работ/completions
|
||||
и закрывает дисковый поток до уничтожения CHAT-групп. Внешняя команда stop допустима.
|
||||
Drain ждёт завершения публикации completion под mutex worker и отменяет её до освобождения работы.
|
||||
uasync_cancel_post удаляет и освобождает ожидающий task из общей очереди либо активного batch.
|
||||
Drain доставляет done ровно один раз, включая cleanup continuations; отдельного обезвреживания
|
||||
callback/arg не требуется. Контексты не освобождаются под работающим I/O.
|
||||
Отмена не прерывает текущий системный I/O или хеширование, а ждёт его completion.
|
||||
Немедленная отмена после start снимает starting: поздний CM UP не запускает STAT/подготовку заново.
|
||||
После перехода к финализации публикации повторный cancel не меняет уже выбранный результат.
|
||||
|
||||
Desktop
|
||||
-------
|
||||
@@ -262,7 +277,14 @@ Stop сервиса/группы завершает все принадлежа
|
||||
|
||||
Все API/callbacks выполняются в uasync-потоке экземпляра. GUI trampolines используют
|
||||
тот же API и превращают callback в существующие события прогресса; Qt не делает I/O.
|
||||
Один локальный файл назначения не может одновременно принадлежать двум download.
|
||||
Два download с одной строкой local одновременно не допускаются; физические aliases
|
||||
и подмену локальных путей исключает вызывающий. Upload читает стабильный regular source;
|
||||
изменение снимка возвращает STALE. На POSIX O_NONBLOCK позволяет отвергнуть FIFO
|
||||
до блокирующего open/read единственного дискового worker.
|
||||
Download создаёт соседний temp эксклюзивно и получает право cleanup только после успеха open.
|
||||
Отказ из-за существующего temp, max_bytes, STALE или ранняя отмена не удаляют чужой путь.
|
||||
После проверки SHA256 выполняются sync/close и заменяющий rename; до публикации прежний local сохраняется.
|
||||
POSIX-публикация локального download не делает fsync родительского каталога, в отличие от серверного COMMIT.
|
||||
|
||||
Аватарки пользователей
|
||||
---------------------
|
||||
@@ -350,12 +372,18 @@ tests/test_member_files: wire/UTF-8, произвольные блоки, пов
|
||||
резервирование/повторы/TTL, инкрементальные files/directories, замены пустых файлов,
|
||||
создание/удаление/rename каталогов, отсутствие runtime-rescan, стартовый scan и его ошибки, ссылки, expiry/restart, 10-секундный
|
||||
fd idle и принадлежность work/done потокам.
|
||||
На POSIX отдельный процесс с границей 2с проверяет неблокирующее открытие FIFO
|
||||
и последующий отказ проверки типа; fork выполняется до создания pthread.
|
||||
tests/test_member_files_network: настоящие A/B/M, подписанная storage-роль,
|
||||
параллельные upload/download одного пира, local loopback, пустые файлы, побайтная
|
||||
проверка, отмена одного handle, restart router и destroy с активным server I/O.
|
||||
Шестисекундная задержка worker проверяет receive backpressure и busy ACK.
|
||||
Также проверяются чужая запись, виртуальный корень и подписанное удаление читателя.
|
||||
QUOTA проверяется на настоящем подписанном/зашифрованном канале; удалённый читатель не получает счётчики.
|
||||
Проверены сохранность чужого temp/прежнего назначения при EXISTS, max_bytes и немедленном cancel,
|
||||
поздний CM UP после отмены, 76 созданий и 76 удалений с исчерпанием квитанций.
|
||||
Один успешный ответ отброшен перед клиентским handler: повтор с прежним ID возвращает квитанцию,
|
||||
счётчики остаются точными (76 каталогов после создания, 0 после удаления).
|
||||
test_services — stop/start общего чат-сервиса.
|
||||
Диагностика файлового сервиса — категория media, маршрутизатора — etcp_route.
|
||||
Настройка для полного трасса этих модулей:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
Аудит C-модуля member_files — 2026-10-24
|
||||
|
||||
Статус: полностью прочитаны шесть реализаций и шесть заголовков src/member_files/.
|
||||
Заголовки сверены; четыре подтверждённых дефекта исправлены и проверены.
|
||||
Серверная/клиентская части doc/member_files.txt согласованы с кодом. Общий документ
|
||||
остаётся [~]: его desktop/AvatarManager/Android-подсистемы целиком в этот аудит не входили.
|
||||
Ниже результаты относятся к C-сервису, а не к полному аудиту всех файлов GUI/аватарок.
|
||||
|
||||
1. Удаление чужого локального temp
|
||||
|
||||
До исправления TRANSFER_FINISH безусловно удалял вычисленный путь temp при ошибке,
|
||||
включая отказ эксклюзивного open из-за существующего файла и отказ до открытия.
|
||||
Настоящий test_member_files_network: preexisting target/temp по37 проверенных байт,
|
||||
скачивание с подписанного storage, MF_EXISTS; target=1, foreign_temp=0, FAIL phase23.
|
||||
Корректное воспроизведение: /tmp/member-files-temp-repro5.log, строки391..395.
|
||||
|
||||
Исправление: один temp_owned у handle, устанавливается только после успешного
|
||||
эксклюзивного создания. Finish закрывает fd, публикует/удаляет только свой temp.
|
||||
После успешного rename владение именем temp снято; source/target не подменяются caller.
|
||||
Новый FILE-слой или второй файловый владелец не добавлены: disk API работает с native fd.
|
||||
Обычный и sanitizer-прогоны: EXISTS, max_bytes, немедленная отмена сохраняют
|
||||
прежние37 байт target и чужого temp, finished ровно один раз.
|
||||
|
||||
2. Ранняя отмена возобновляла передачу и приводила к UAF
|
||||
|
||||
После cancel сразу вслед за start поздний CM UP вызывал wake. Starting оставался1,
|
||||
поэтому pump после начала cleanup снова посылал STAT. Finish освобождал handle,
|
||||
а ответ STAT вызывал transfer_response с уже освобождённым arg.
|
||||
Лог: /tmp/member-files-temp-fixed.log; GDB: /tmp/member-files-cancel-gdb.log,
|
||||
SIGSEGV в transfer_response → mf_pending_finish → mf_dispatch.
|
||||
|
||||
Переход stopping теперь снимает starting; wake без готового финального ответа
|
||||
ничего не планирует. Обязательная cleanup-задача остаётся единственным продолжением,
|
||||
затем отправляется финальный CANCEL/END и освобождается handle до callback.
|
||||
Не добавлены новые ссылки/таймеры или зависимость от скорости ответа CM.
|
||||
Регрессия early cancel прошла вместе с обычной отменой, параллельными потоками,
|
||||
router restart и destroy при активном server I/O.
|
||||
|
||||
3. Заполнение64 квитанций обрывало массовые мутации
|
||||
|
||||
Прежнее воспроизведение в doc/tasks.md: из76 удалений выполнены64, затем MF_BUSY.
|
||||
Серверный предел/окно нужны для идемпотентности и остаются: до64 успешных
|
||||
COMMIT/MKDIR/RENAME/REMOVE, MF_REPLAY_TB=1200000 (120с) от начала обработки.
|
||||
Квитанции не вытесняются досрочно. Полный кеш возвращает BUSY с cursor=остаток
|
||||
до ближайшего освобождения в timebase0.1мс; это значение кодируется обычным LE-полем.
|
||||
Нулевой/недопустимый hint использует обычную задержку5с; недопустимый логируется.
|
||||
|
||||
Клиент этих четырёх команд сохраняет тот же pending/ID/параметры и ожидает
|
||||
отменяемый retry timer. Остальные BUSY возвращаются callback как раньше.
|
||||
Общий срок запроса остаётся120с от создания; ни BUSY, ни потеря ответа его не продлевают.
|
||||
Перед повторной отправкой pump также проверяет срок. Сервер начинает обработку
|
||||
не раньше создания клиентского запроса, поэтому допустимые повторы не выходят
|
||||
за окно первой успешной квитанции. Ошибка/timeout не обещают отката серверной мутации.
|
||||
При постоянной конкуренции запрос может завершиться TIMEOUT; unbounded waiting нет.
|
||||
|
||||
Вариант с продлением срока клиента отклонён при архитектурной сверке до итогового
|
||||
прогона: поздний BUSY и потеря результата могли вывести повтор за жизнь квитанции.
|
||||
Один секундный tick также недостаточен для точного освобождения; введён отдельный
|
||||
retry timer, который снимается до free/cancel/quiesce, с tick fallback при OOM.
|
||||
Формат125-байтного заголовка не менялся; смысл mutation BUSY.cursor описан в protocol.h.
|
||||
|
||||
Новая сетевая регрессия проходит настоящий клиентский API/router/storage:
|
||||
-76 MKDIR, включая исчерпание кеша; QUOTA подтверждает ровно76 каталогов.
|
||||
-Первый успешный ответ намеренно отброшен до клиентского handler; повтор с прежним
|
||||
ID возвращает успех, не создавая второй объект и не выдавая EXISTS.
|
||||
-76 STAT/REMOVE, включая новое исчерпание квитанций; QUOTA подтверждает0 объектов/байт/резерва.
|
||||
Qt recursiveDeletion(server-busy) вызывает storage напрямую через stub bridge:
|
||||
его сырой MF_BUSY остаётся правильным серверным ответом. Поведение полного
|
||||
клиентского ожидания и массового удаления проверено C-сетевой регрессией, не этим stub.
|
||||
|
||||
4. FIFO блокировал общий disk worker до проверки типа
|
||||
|
||||
POSIX mf_disk_local_open(source,0) использовал блокирующий open. FIFO без writer
|
||||
зависал ещё до mf_disk_info, который должен отвергнуть не-regular источник.
|
||||
Регрессия запускает отдельный процесс до создания pthread, alarm2с ограничивает
|
||||
только диагностический child. До фикса он завершился SIGALRM (status14), failures=1:
|
||||
/tmp/member-files-fifo-repro.log. Добавлен O_NONBLOCK, как у серверного open:
|
||||
FIFO открывается без ожидания, проверка типа даёт INVALID_PATH, fd закрывается.
|
||||
Обычный и sanitizer-прогоны: child status0, failures0; invalid mode/links имеют WARN.
|
||||
Произвольный уже выполняющийся системный I/O/хеширование всё ещё не прерывается cancel.
|
||||
|
||||
Сверка архитектуры и описаний
|
||||
|
||||
-Uasync владеет запросами, streams, local FIFO, ACL, CM handles, waiter/pump/timers.
|
||||
Единственный worker выполняет файловое I/O последовательно; work FIFO, порядок
|
||||
done при синхронном drain не обещается. Данные живут до completion.
|
||||
-Обычных работ до64; заранее зарезервированные cleanup jobs обходят лимит.
|
||||
Quiesce запрещает producers; drain завершает I/O и cleanup, затем storage destroy
|
||||
выполняется в worker, после него join/free, пока ядро/группы ещё живы.
|
||||
-Storage принимает только уже проверенные членство/роли. Сам проверяет UID мутации
|
||||
и токены temp. Квота общая для UID во всех группах; обычные операции не делают rescan.
|
||||
-Server COMMIT и local download имеют разные гарантии: локальный POSIX rename
|
||||
не синхронизирует каталог. Ошибка server sync после rename не откатывает публикацию.
|
||||
-Пути disk API доверенные и уже проверены protocol/storage. Callback LIST получает
|
||||
заимствованные name/info; порядок файловой системы, не сортировка. Source/target
|
||||
aliases локальных download исключает caller; одинаковая строка local запрещена.
|
||||
-Codec проверяет формат и размеры, не ACL и не UTF-8 policy пути. Ошибка decode
|
||||
может изменить output; encode не проверяет семантику команды. Эти границы описаны.
|
||||
-Все поля/функции шести .h получили соответствующие контракты и комментарии.
|
||||
|
||||
Проверки и границы
|
||||
|
||||
./build.sh --no-gui -j4 PASS: /tmp/member-files-audit-final-build.log.
|
||||
Профиль7/7 PASS: member_files, member_files_network, services, encrypted_attachment,
|
||||
dm_media, attachment_send, member_adapter. Результат: /tmp/member-files-audit-profile-results.log,
|
||||
подробности: /tmp/member-files-audit-profile-logs/.
|
||||
|
||||
ASAN/UBSan/LSan4/4 PASS, detect_leaks=1, halt_on_error=1: member_files,
|
||||
member_files_network, services, encrypted_attachment. Отдельная копия:
|
||||
/home/user/utun4/.cache/member-files-asan-b87xwg24, все встроенные зависимости
|
||||
инструментированы через CPPFLAGS; системные OpenSSL/FFmpeg не пересобирались.
|
||||
Логи: /tmp/member-files-sanitizer-build.log, /tmp/member-files-sanitizer-tests-build.log,
|
||||
/tmp/member-files-sanitizer-results.log, /tmp/member-files-sanitizer-test_*.log.
|
||||
После этого прогона добавлены только уточнения описаний и WARN без изменения логики.
|
||||
|
||||
В sanitizer-сборке остаётся прежнее предупреждение snprintf в disk_parent_open:
|
||||
strlen(component)<=MF_NAME_MAX проверяется до записи в leaf[MF_NAME_MAX+1];
|
||||
воспроизведённого усечения здесь нет. Остальные предупреждения сборки относятся
|
||||
к существующему коду других модулей/зависимостей, не скрывались фильтрами компилятора.
|
||||
|
||||
Первые диагностические прогоны до repro5 недействительны для temp: тест был
|
||||
собран с новым utun_instance.h и старой libutun.a, в startup читались0xaaaaaaaa.
|
||||
Общая сборка устранила несовпадение; ненужное изменение маршрутного условия отменено.
|
||||
Один промежуточный старт остановился из-за полного /tmp; очищены только
|
||||
воспроизводимые объекты предыдущей sanitizer-копии, исходники/журналы сохранены.
|
||||
|
||||
Новый полный make check, Windows/FreeBSD runtime и GUI/Android проверки не выполнялись.
|
||||
Четыре подтверждённых дефекта закрыты; новых подтверждённых незакрытых в этом объёме нет.
|
||||
@@ -0,0 +1,940 @@
|
||||
member_sync / merkle_sync — аудит контрактов, 26.10.2026
|
||||
|
||||
Текущий статус: согласованный перенос истории на MESSAGES Merkle реализован и проверен (раздел13).
|
||||
После rebase на b0bf27de:check.sh206/206 PASS,0 failed/0 skipped;
|
||||
proxy9/9,burst1/1,load8/8,exit0,197с; STUN и stream PASS. Подробности — раздел15.
|
||||
ASAN/UBSan/LSan10/10 PASS с detect_leaks=1. Крупная история:5002 записи на каждом узле,
|
||||
полные корни одинаковы; изменение между CHECK/DONE проверено отдельно нативно и с sanitizers.
|
||||
История исключённого автора сохраняется в MESSAGES; обратная политика отображения FEEDBACK отдельно.
|
||||
Полный аудит остальных контрактов чата остаётся частичным; GUI/Android/Windows/FreeBSD не запускались.
|
||||
Сверка документов этого блока закрыта: member_sync.h/_doc.md, merkle_sync.h/_doc.md,
|
||||
db_sync.h/_arch.md и исторического merkle_tz.md отмечены [V] в общем чек-листе.
|
||||
Предыдущие [~] в исторических разделах ниже описывают состояние соответствующего этапа.
|
||||
Разделы1–12 сохраняют исходные обнаружения, планы и результаты прежних этапов;
|
||||
их старые счётчики тестов не являются проверкой нового переноса.
|
||||
|
||||
1. Статус и объём
|
||||
|
||||
Базовый HEAD: 962a2df3. Рабочее дерево перед проверкой было чистым.
|
||||
Полностью прочитаны member_sync.c (1262 строки), merkle_sync.c (687), merkle_tree.c (122),
|
||||
их три заголовка, member_sync_doc.md, merkle_sync_doc.md и историческое merkle_tz.md.
|
||||
Прочитаны test_member_adapter.c, test_merkle_protocol.c, test_member_sync.c;
|
||||
для test_merkle_sync.c и test_chat_feedback.c проверены относящиеся к контрактам части.
|
||||
Сверены связанные DB-функции, граница внешнего commit JOIN и интерфейс ll_queue.h.
|
||||
Полный аудит chat_sync, chat_feedback, db_sync, дерева авторизации и всех callers не заявляется.
|
||||
|
||||
На первом этапе найден подтверждённый дефект очистки; по AGENTS.md:10 аудит был остановлен.
|
||||
На том этапе реализация .c, протокол и штатные тесты не менялись; стенд находится в /tmp.
|
||||
member_sync.h/_doc.md и merkle_sync.h/_doc.md остаются [~]; merkle_tree.h сверён [V].
|
||||
merkle_tz.md остаётся [~]: исторический статус исправлен, db_sync отдельно не сверялся.
|
||||
Остальные отметки общего чек-листа сохранены.
|
||||
|
||||
2. Подтверждённый дефект: очистка неатомарна и теряет ошибки
|
||||
|
||||
src/chat/member_sync.c:844–908, member_sync_verify_and_purge.
|
||||
На строках 888 и 890 не проверяются результаты topo_node_sqlite_member_del и
|
||||
merkle_sync_recompute_path. Между удалением строки и пересчётом индекса нет общего savepoint.
|
||||
Затем безусловно очищается mute, вызываются props callbacks, обрабатывается удаление
|
||||
прямого соединения из CHAT-группы и увеличивается purged.
|
||||
Ошибки prepare/step исходной выборки также не имеют отдельного результата ошибки.
|
||||
verify_and_purge_all возвращает void, а member_sync_start игнорирует результат очистки.
|
||||
|
||||
Диагностика использует настоящие member_sync, merkle_sync, merkle_tree и SQLite :memory:.
|
||||
Локальный доверенный put создаёт синтетическую повреждённую запись node_id/X25519;
|
||||
такая запись выбрана для проверки штатной ветви очистки, сетевые пакеты не используются.
|
||||
Корень до очистки ненулевой. SQL-триггеры адресно моделируют отказ DELETE записи
|
||||
либо DELETE строки производного индекса. Остальные SQL-запросы остаются рабочими.
|
||||
Подписчик props фиксирует число реально оставшихся строк и autocommit в момент события.
|
||||
|
||||
Случай Возврат Строки после Старый корень Props
|
||||
Без отказа 1 0 нет, нулевой 1 PASS
|
||||
Отказ DELETE участника 1 1 да 1 FAIL
|
||||
Отказ пересчёта индекса 1 0 да 1 FAIL
|
||||
|
||||
Во втором случае лог DB содержит rc=19/audit delete failure, но затем очистка пишет
|
||||
«purged 1» и уведомляет подписчика с tags=NULL, хотя запись всё ещё существует.
|
||||
В третьем случае лог Merkle показывает audit index failure, но строка уже удалена,
|
||||
корень остаётся ненулевым и очистка снова пишет «purged 1».
|
||||
Общее число tracked allocations после трёх случаев восстановлено; exit=1, 2/3 FAIL.
|
||||
Удаление реального соединения, радио и полный сетевой результат этим стендом не проверялись.
|
||||
|
||||
Стенд: /tmp/member-merkle-audit-purge.c
|
||||
Дополнительная цель штатного Makefile: /tmp/member-merkle-audit.mk
|
||||
Сборка: make -C tests -f Makefile -f /tmp/member-merkle-audit.mk /tmp/member-merkle-audit-purge
|
||||
Сборочный лог: /tmp/member-merkle-audit-purge-build.log
|
||||
Полный диагностический лог: /tmp/member-merkle-audit-purge.log
|
||||
|
||||
3. Уточнённый план до реализации
|
||||
|
||||
Предыдущий вариант был неполон: не выбран контракт внешней транзакции и не прослежен
|
||||
результат crypto-проверки до нижнего уровня. После перепроверки выбран следующий вариант.
|
||||
|
||||
А. Проверка данных до удаления
|
||||
Весь путь join/update/owner/tree должен различать недействительную подпись и отказ
|
||||
самой проверки. member_sync_verify_local_record:791–801 сейчас возвращает 0 также
|
||||
при отказе EVP_PKEY_new_raw_public_key/EVP_MD_CTX_new/EVP_DigestVerifyInit.
|
||||
sc_ed25519_verify в secure_channel.c тоже объединяет отказ VerifyInit и неверную подпись
|
||||
в SC_ERR_AUTH_FAILED; одного нового результата в верхнем member_sync недостаточно.
|
||||
Нужно сохранить SC_ERR_CRYPTO для allocation/init/internal failures и AUTH_FAILED
|
||||
для отрицательного результата законченной проверки подписи; unknown/internal outcome
|
||||
никогда не должен становиться основанием DELETE. Аналогично SQL_ERROR нельзя выдавать
|
||||
за отсутствие родительского ключа. Отсутствие ожидаемой зависимости оставляет запись
|
||||
до последующей проверки, операционный отказ прерывает текущую очистку.
|
||||
Публичный результат локальной проверки может остаться простым: 1=сохранить,
|
||||
0=доказанная порча, <0=ошибка; callers должны явно сравнивать с 0, а не использовать !result.
|
||||
Формат записи, подписи, wire и смысл отсутствующих подтверждений не меняются.
|
||||
Это статически установленные ветви; runtime crypto/OOM fault-injection ещё не выполнялся.
|
||||
|
||||
Б. Одна транзакция принадлежит очистке одного канала
|
||||
verify_and_purge требует sqlite3_get_autocommit(db)==1; чужая транзакция отклоняется
|
||||
до чтения и изменений. Рабочий вызов start идёт из cs_sync_timer_cb без DB-транзакции;
|
||||
init также не требует вложенного вызова. Сложный механизм отложенной внешней публикации не нужен.
|
||||
BEGIN IMMEDIATE выполняется до SELECT, чтение/проверка должны завершиться SQLITE_DONE.
|
||||
Размер каждого используемого BLOB проверяется до чтения; список повреждённых node_id
|
||||
принадлежит операции, не содержит заимствованных SQLite указателей. После отбора SELECT закрывается.
|
||||
В той же транзакции удаляются найденные строки и пересчитываются затронутые листья/предки.
|
||||
На этом этапе recompute_path не делает changed, поскольку autocommit выключен.
|
||||
Все bind/step/finalize/COMMIT проверяются. На ошибке statements закрываются и выполняется ROLLBACK;
|
||||
учитывается автоматический rollback SQLite. Не выполнять RELEASE/COMMIT после неудачного отката.
|
||||
Если восстановить состояние транзакции не удалось, продолжать работу синхронизации с БД нельзя.
|
||||
|
||||
В. Публикация только после успешного COMMIT
|
||||
Уведомить Merkle об изменённом namespace и выполнить именно существующие последствия удаления:
|
||||
снять mute, удалить членство конкретной CHAT-группы через её API, уведомить props-потребителей.
|
||||
Не закрывать общий ETCP/NCD транспорт, принадлежащий другим группам/сервисам.
|
||||
Не заменять удаление member_sync_record_committed: тот предназначен для обновления
|
||||
и пытается присоединить существующий UP-транспорт к группе.
|
||||
Не держать SQLite cursor и заимствованные group/conn указатели через callbacks;
|
||||
повторный вход и время жизни подписчиков проверяются отдельно.
|
||||
Ошибка публикации после commit не означает откат уже сохранённой очистки.
|
||||
|
||||
Г. Ошибки и граница атомарности
|
||||
verify_and_purge возвращает <0 при отказе до commit, иначе число подтверждённых удалений.
|
||||
verify_and_purge_all возвращает ошибку и останавливается на первом неудачном канале;
|
||||
уже успешно очищенные каналы остаются согласованными и не откатываются.
|
||||
init освобождает созданные ресурсы при отказе; start не создаёт Merkle-запрос после отказа очистки.
|
||||
Результат <0 из start не сопровождается done callback. Эти правила фиксируются в .h.
|
||||
|
||||
Д. Критерии проверки
|
||||
Нормальная очистка; read/bind/delete/hash/commit/allocation/crypto failures; отказ на второй записи;
|
||||
сохранность корректных записей, source placeholders и отложенных зависимостей;
|
||||
неизменность данных/индекса и отсутствие props/mute/group-изменений при rollback;
|
||||
освобождение statements/памяти/транзакции, отказ при чужой транзакции, init/start и повтор после отказа.
|
||||
После commit проверить реальные stream/radio/group потребители и чужого владельца транспорта.
|
||||
Затем профильные наборы, полный check.sh и согласованная sanitizer-сборка.
|
||||
До реализации и этих тестов утверждать, что исправление доказанно корректно, было нельзя.
|
||||
|
||||
Семантика ошибок сверена с официальными документами:
|
||||
https://docs.openssl.org/3.0/man3/EVP_DigestVerifyInit/#return-values
|
||||
https://www.sqlite.org/lang_transaction.html
|
||||
На момент перепроверки до реализации HEAD оставался 962a2df3; исправления .c ещё не было.
|
||||
|
||||
4. Исправленные описания
|
||||
|
||||
member_sync.h/_doc.md: область атомарности put/apply, предупреждение об очистке,
|
||||
различие проверки наличия строки и допуска CHAT, точный verified_ed25519 без обещания
|
||||
рекурсивного обхода предков, эпоха join_ts/update_ts, независимый owner ver,
|
||||
дополнение сертификата без смены профиля, проверка подписи owner и для локального put.
|
||||
Уточнены игнорируемый вход storage, COUNT(*) с placeholders/deleted, уведомления после
|
||||
внешнего commit через record_committed (одного merkle_sync_changed недостаточно).
|
||||
|
||||
merkle_sync.h/_doc.md: необязательные четыре staged-pull callbacks, ненулевой контекст,
|
||||
commit в конце своего прохода и abort после commit/restart/отказа, сброс общего индекса
|
||||
при init, NULL done callback, REINIT, 30-секундный progress timeout и отсутствие WAL-таймера.
|
||||
Полная пересверка дополнительных разделов feedback/опросов не завершена.
|
||||
|
||||
merkle_tree.h: владение общей таблицей, namespace dataset:channel, обязательные аргументы,
|
||||
нормализованные prefix, возможное изменение выходов при ошибке, domain хеша,
|
||||
транзакция caller, удаление пустых узлов и остановка на неизменившемся предке.
|
||||
|
||||
merkle_tz.md: прежний out_q/push/WAL описан как исторический этап, а не текущая реализация.
|
||||
Требования и старые ссылки оставлены с явной пометкой; аудит db_sync здесь не заявлен.
|
||||
|
||||
5. Выполненные проверки
|
||||
|
||||
make -C src -j4 libutun.a — PASS: /tmp/member-merkle-audit-core-build.log.
|
||||
Сборка четырёх штатных целей — PASS: /tmp/member-merkle-audit-tests-build.log.
|
||||
test_member_adapter — PASS: /tmp/member-merkle-audit-adapter.log.
|
||||
test_merkle_protocol — PASS: /tmp/member-merkle-audit-protocol.log.
|
||||
test_chat_feedback — PASS: /tmp/member-merkle-audit-feedback.log.
|
||||
test_merkle_sync — 28/28 PASS: /tmp/member-merkle-audit-sync-unrestricted.log.
|
||||
В этих четырёх наборах нет проверки потерянных ошибок verify_and_purge.
|
||||
|
||||
Первый test_merkle_sync в sandbox остановился на создании UDP-сокетов (EPERM),
|
||||
после чего повторён вне sandbox с результатом PASS. Первый лог:
|
||||
/tmp/member-merkle-audit-sync.log. Это ограничение среды, не ошибка Merkle-протокола.
|
||||
Лог успешного mock-набора содержит ошибки отсутствующих peers_1002/1003 от планировщика
|
||||
тестовых CHAT-групп; совпадение mock-данных и 28 PASS не подтверждают работу admission.
|
||||
|
||||
В первом этапе полный check.sh, sanitizers, native Windows/FreeBSD, GUI и Android не запускались.
|
||||
После тех документационных правок git diff --check и неизменность C-токенов трёх .h — PASS.
|
||||
Проверка документов не равнозначна доказательству отсутствия дефектов реализации.
|
||||
Коммит и push в первом этапе не выполнялись.
|
||||
|
||||
6. Согласованное исправление и окончательные проверки
|
||||
|
||||
По подтверждённому пользователем уточнённому плану исправлены member_sync.c/.h,
|
||||
secure_channel.c/.h и SQL-getters каналов/Ed25519 в topo_node_sqlite.c/.h.
|
||||
Изменений wire, подписываемых тел и схемы БД нет.
|
||||
|
||||
Одна очистка канала владеет BEGIN IMMEDIATE от SELECT до общего COMMIT удаления и индекса.
|
||||
Чужая транзакция отклоняется до изменений. До любых мутаций собираются собственные node_id,
|
||||
проверяются размеры BLOB/строк и SQLITE_DONE, закрывается cursor. Все отказы приводят к rollback
|
||||
без props/mute/group-изменений. При автоматическом rollback SQLite лишний ROLLBACK не требуется.
|
||||
Если откат не удался и транзакция осталась открытой, логируется требование restart, inst.running=0;
|
||||
восстановление после реального постоянного I/O-отказа хранилища этим набором не проверялось.
|
||||
|
||||
После commit изменяется Merkle revision, снимается mute, вызывается API удаления
|
||||
членства конкретной CHAT-группы и props. Общий ETCP/NCD другого владельца не закрывается.
|
||||
Удаление не подменено record_committed, который обрабатывает обновление/подключение.
|
||||
Props-подписка снимается с отложенным free при вложенном dispatch; callback может
|
||||
снять себя и повторно вызвать очистку. Stop/destroy по-прежнему выполняются вне callbacks.
|
||||
verify_and_purge_all больше не держит schema cursor через публикацию и возвращает ошибку;
|
||||
атомарность по каналу, ранее завершённые каналы остаются согласованными.
|
||||
init освобождает созданный Merkle-движок при отказе очистки, start не создаёт запрос и не вызывает done.
|
||||
|
||||
Верификация возвращает сохранить/доказанно повреждена/ошибка операции.
|
||||
Отсутствующий ключ/сертификат/ожидаемая зависимость не вызывает удаления.
|
||||
SQL-getters различают LOOKUP_MISSING=-1 и LOOKUP_ERROR=-2; прежние callers по !=0/<0 сохраняют смысл.
|
||||
sc_ed25519_verify различает VerifyInit/internal failure и завершённую проверку неверной подписи.
|
||||
SHA256 node_id проверяется на отказ; неопределённый hash не читается.
|
||||
Эта классификация используется также в приёме записей, без расширения доверия к входящим данным.
|
||||
|
||||
test_member_adapter включает новый test_member_purge.c и linker-wrap faults настоящих SQLite/OpenSSL API.
|
||||
Проверены11 отказов read/finalize/bind/begin/commit/allocate/delete/index/автоматического rollback,
|
||||
отказ второй аллокации списка,24 crypto faults в join/update/owner/tree, отказ identity hash,
|
||||
ошибки SQL-getters, сохранение placeholders и отложенного подписанта, внешняя транзакция,
|
||||
init cleanup/retry, отсутствие запроса/callback при отказе start и успешное повторное подключение.
|
||||
Настоящие неверные join/update/owner/tree и короткий ключ удаляются с пустым корнем.
|
||||
Логи: /tmp/member-purge-fix-test_member_adapter.log и ...-asan-final-test_member_adapter.log.
|
||||
В test_stream_service/test_stream_member добавлены настоящие DELETE/index faults:
|
||||
rollback сохраняет reader/mute/READY и корень; успешный commit удаляет членство,
|
||||
сохраняя другой канал и внешний NCD handle/UP-транспорт.
|
||||
Лог отдельного повтора: /tmp/member-purge-fix-member-ownership.log.
|
||||
|
||||
Первая проверка tracked allocations в новом helper сработала до освобождения call_soon/пула uasync.
|
||||
Контроль приведён к контракту владельца: immediate queue обрабатывается, число активных timers
|
||||
восстанавливается, полный счётчик аллокаций проверяется после uasync_destroy штатным main.
|
||||
Повторный набор и sanitizers проходят без утечек. Попытка использовать private topo_group_peer
|
||||
в интеграционном тесте заменена публичным topo_group_peer_ready до окончательных прогонов.
|
||||
|
||||
Обычная сборка: PASS. Профиль11/11 PASS; после дополнения ownership-теста stream_member ещё раз PASS.
|
||||
Логи: /tmp/member-purge-fix-{build,tests-build,core-final-build,profile-build,profile-results}.log.
|
||||
Первый check.sh вне sandbox:197/200, три отказа bind(::1) при disable_ipv6=1 на внешнем lo.
|
||||
Полные первичные логи сохранены /tmp/member-purge-fix-first-test_{ncd_faults,ipv6_sockets,route_connectivity}.log.
|
||||
Повтор общего набора в user/network namespace с IPv6:200/200 PASS,0 failed/0 skipped.
|
||||
Лог: /tmp/member-purge-fix-full-check-ipv6.log. Следующий шаг check.sh (proxy) в этом user namespace
|
||||
остановился на fopen pidfile: Permission denied в /run, поэтому единый check.sh не дал exit0.
|
||||
Три штатные интеграции отдельно выполнены в root network/mount namespace с собственным /run:
|
||||
proxy9/9 PASS,burst1/1 PASS (8388608 байт),load8/8 PASS (134217728 байт).
|
||||
Лог: /tmp/member-purge-fix-proxy-integrations.log. Таким образом все составляющие check.sh проверены;
|
||||
настройки IPv6, маршруты, iptables и /run хоста этими повторными namespace-запусками не менялись.
|
||||
|
||||
ASAN/UBSan/LSan:11/11 PASS, ASAN_OPTIONS=detect_leaks=1:halt_on_error=1,
|
||||
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1. Изолированная копия:
|
||||
/tmp/utun-member-purge-sanitizers. Сначала configure потребовал запуск вне sandbox из-за LSan/ptrace;
|
||||
затем проверка символов выявила отключённый sanitizer в локальном Makefile Opus.
|
||||
Окончательная сборка через build.sh с MAKEFLAGS=-e инструментирует lib/src/Opus согласованно;
|
||||
проверены __asan_ и __ubsan_ во всех трёх архивах.
|
||||
Логи /tmp/member-purge-fix-asan-{consistent-build,consistent-tests-build,consistency,final-results}.log;
|
||||
полные результаты каждого теста /tmp/member-purge-fix-asan-final-test_*.log.
|
||||
Предварительные5 PASS с неинструментированным Opus не считаются окончательным sanitizer-набором.
|
||||
|
||||
Ключевые кодовые коммиты:55050a9e,deda3c58,a1ef68da,4e0d6174;
|
||||
регрессии0c28503e,1cacf693,8442ddc2. Коммиты локальные, push не выполнялся.
|
||||
Задача очистки закрыта; частичные отметки member/Merkle-документов сохранены,
|
||||
поскольку исправление дефекта не заменяет оставшийся аудит всех контрактов и callers.
|
||||
Native Windows/FreeBSD, GUI и Android в этом исправлении не запускались.
|
||||
|
||||
7. Продолжение аудита: история исключённого автора блокирует запуск
|
||||
|
||||
База продолжения5616be8c; рабочее дерево в начале было чистым.
|
||||
Полностью прочитаны chat_feedback.c/.h, повторно сверены части Merkle-движка,
|
||||
относящиеся к data_ops/сессиям/транзакциям и границы member/chat_sync startup.
|
||||
Остальные интеграционные контракты, разделы poll и db_sync ещё не закрыты.
|
||||
Согласно AGENTS.md:10 аудит вновь остановлен; реализация .c, wire и БД не менялись.
|
||||
В chat_feedback.h добавлено предупреждение, merkle_sync_doc.md уточнён.
|
||||
chat_feedback.h — [~]; прежние member/Merkle [~], merkle_tree.h[V] сохраняются.
|
||||
|
||||
Причина:
|
||||
chat_feedback_init (chat_feedback.c:384) для каждой сохранённой записи вызывает
|
||||
member_sync_verified_ed25519. SELECT этой функции (member_sync.c:1322) требует
|
||||
source=0 AND deleted=0. Подписанное решение владельца deleted=yes сохраняет строку,
|
||||
ключи, join и сертификат, но исключает её из SELECT. Проверка возвращает -1,
|
||||
chat_feedback_init также -1. chat_sync_init (chat_sync.c:865) прерывает запуск;
|
||||
chat_service_start (utun_instance.c:843) передаёт этот отказ выше.
|
||||
В runtime-стенде непосредственно проверены feedback_init и chat_sync_init;
|
||||
полный внешний chat_service_start/GUI runtime этим стендом не запускались.
|
||||
|
||||
Сценарий использует неизменённую fixture штатного tests/test_chat_feedback.c,
|
||||
настоящие публичные member/feedback API и общий Makefile для линковки.
|
||||
Два независимых узла, два канала, SQLite :memory:, никаких пользовательских данных.
|
||||
Автор отличается от локального узла. Его join и приглашение подписаны штатно;
|
||||
реакция подписана автором и успешно принята вторым узлом до исключения.
|
||||
Затем владелец подписывает adm_tags ver=1/deleted=yes для этого автора только в1001.
|
||||
SQL-проверка подтверждает сохранённую строку source=0/deleted=1, join/tree по64 байта.
|
||||
Собственный участник канала1001 и автор в другом канале1002 успешно проходят проверку.
|
||||
|
||||
Результаты:
|
||||
— До исключения: member_sync_init=0, chat_feedback_init=0, records=1.
|
||||
— После исключения: member_sync_init=0, chat_feedback_init=-1.
|
||||
— Отдельный вызов chat_sync_init=-1, chat_sync/msync освобождены.
|
||||
— Feedback после отказа читается, подпись и ver не изменились: физической потери записи нет.
|
||||
— Подписанное ver=2/deleted=no восстанавливает успешный member/feedback init с records=1.
|
||||
— Итог2/2 FAIL ожидаемого запуска при исключённом авторе,exit1; tracked allocations восстановлены.
|
||||
Полный повтор с дополнительной проверкой строки дал тот же результат.
|
||||
|
||||
Стенд: /tmp/member-merkle-feedback-restart.c
|
||||
Цель: /tmp/member-merkle-feedback-restart.mk
|
||||
Сборка: make -C tests -f Makefile -f /tmp/member-merkle-feedback-restart.mk /tmp/member-merkle-feedback-restart
|
||||
Лог сборки: /tmp/member-merkle-feedback-restart-build.log (PASS)
|
||||
Полный диагностический лог: /tmp/member-merkle-feedback-restart.log
|
||||
Категории member_sync/chat_sync/debug включены наINFO; причина отказа явно видна в логе.
|
||||
|
||||
Архитектурная граница, требующая выбора:
|
||||
Текущий допуск действий узла и проверка ранее сохранённого подписанного состояния
|
||||
используют одну функцию, поэтому изменение членства ломает восстановление истории.
|
||||
fb_apply_row также проверяет текущий допуск автора, а fb_hash/fb_page перечисляют
|
||||
сохранённые записи без фильтра deleted. Поэтому исправление только init не задаёт
|
||||
согласованную модель репликации/корня при разном порядке получения исключения и feedback.
|
||||
Это статически установленное расхождение путей; отдельный сетевой replay/round не запускался.
|
||||
|
||||
Вариант сохранения истории требует отдельного контракта исторической подписи/сертификата
|
||||
и правил учёта состояний исключённого автора; актуальный допуск peer/локальных действий
|
||||
при этом остаётся отдельной проверкой. Следует явно определить отношение новых ver
|
||||
и исключения: feedback не содержит подписанного времени или отсечки версии при отзыве.
|
||||
Вариант исключения таких состояний из активного набора требует одинаковой фильтрации
|
||||
init/apply/hash/page и атомарного изменения производного индекса при смене членства.
|
||||
Политику хранения/подсчёта нельзя заменять молчаливым пропуском ошибки старта или
|
||||
глобальным снятием deleted-фильтра с общего verifier, используемого также radio/attachments.
|
||||
Это направления решения, не подтверждённый реализацией план исправления.
|
||||
|
||||
Для регрессии нужны исключённый другой автор, реакция/ответ/сохранённая отмена,
|
||||
два канала, restart и поздний догон нового пира, удаление повреждённого участника,
|
||||
восстановление членства, сохранность отмен и одинаковые корни при разном порядке событий.
|
||||
Прежний purge-дефект закрыт; прежние200/200 и sanitizer11/11 не включают этот сценарий.
|
||||
Новый полный check/sanitizers/native Windows/FreeBSD/GUI/Android не запускались,
|
||||
поскольку runtime-код не менялся и аудит остановлен на подтверждённом дефекте.
|
||||
|
||||
8. Проверка архитектуры плана: source=0 не равен подтверждённому допуску
|
||||
|
||||
Проверка выполнена на f6e54478, до неё рабочее дерево было чистым.
|
||||
Пользователь выбрал: после исключения реакции/голоса скрываются из результатов,
|
||||
сохранённые записи, версии и отмены остаются в БД. Runtime-реализации нового плана пока нет.
|
||||
Ни его чистота, ни корректность готового diff этим исследованием не подтверждаются.
|
||||
|
||||
Прежний план предполагал общий активный набор по source=0 AND deleted=0 и пересчёт
|
||||
feedback при изменении соответствующего участника. Проверка нашла два пробела:
|
||||
|
||||
1) member_apply_record (member_sync.c:579) разрешает отложенную проверку приглашения,
|
||||
когда нет доверенного ключа signed_by. При корректном join/идентичности запись может
|
||||
сохраняться как source=0/deleted=0. member_finish проверяет зависимость в конце раунда.
|
||||
member_sync_verified_ed25519 (member_sync.c:1320) в этом состоянии возвращает -1;
|
||||
тот же -1 означает неактивность, неверную подпись и операционную ошибку.
|
||||
Значит, SQL-фильтр не является доказательством полного допуска. Если немедленную
|
||||
проверку feedback сделать обязательной для commit каждой записи MEMBERS,
|
||||
корректное обновление может быть отклонено до поступления его зависимости.
|
||||
Отложенную зависимость нельзя приравнивать к повреждению или отзыву членства.
|
||||
|
||||
2) Ключ пригласившего ищется в общей nodes, а не только в peers данного канала.
|
||||
Появление ключа при обработке другого участника/канала меняет результат проверки
|
||||
уже сохранённого автора. Уведомление и пересчёт только для изменённого node_id
|
||||
в текущем namespace не охватывают такую зависимость. Если активный набор зависит
|
||||
от полного допуска, его индекс, выдача и результаты должны меняться согласованно
|
||||
также при разрешении зависимости. Это касается автора и его signed_by,
|
||||
а не только полей deleted/source непосредственно изменённой строки.
|
||||
|
||||
Runtime-контрпример использует неизменённую fixture tests/test_chat_feedback.c,
|
||||
три изолированных экземпляра SQLite :memory:, подлинные Ed25519-подписи и публичные API.
|
||||
Сначала feedback автора сохранён с корректным корневым сертификатом.
|
||||
Затем удалённый member_sync_apply_record принимает корректный join_ts=2 того же автора
|
||||
с приглашением от ещё неизвестного signer. SQL_active=1, apply=MS_APPLY_CHANGED,
|
||||
но verified_ed25519=-1 с причиной invalid or unresolved invitation signature.
|
||||
После member_sync_destroy/init: member_init=0, feedback_init=-1, сохранённый feedback не менялся.
|
||||
Затем в1002 добавляется только пригласивший; автор и feedback в1001 не изменяются.
|
||||
verified_ed25519 для автора1001 становится0; следующий restart member/feedback даёт0/0.
|
||||
Tracked allocations восстановлены. Проектные C-файлы, wire и схема БД не менялись.
|
||||
|
||||
Стенд: /tmp/member-merkle-feedback-plan-review.c
|
||||
Цель: /tmp/member-merkle-feedback-plan-review.mk
|
||||
Сборка: make -C tests -f Makefile -f /tmp/member-merkle-feedback-plan-review.mk /tmp/member-merkle-feedback-plan-review
|
||||
Логи: /tmp/member-merkle-feedback-plan-review-build.log и /tmp/member-merkle-feedback-plan-review.log.
|
||||
Exit0 означает подтверждённый контрпример с выполненными assertions, а не прохождение
|
||||
исправленной реализации: план на исходный runtime не накладывался.
|
||||
|
||||
Обязательные уточнения перед реализацией:
|
||||
— Явный результат проверки автора: допущен, исключён/отсутствует, ожидает зависимость,
|
||||
недействительные данные, операционная ошибка. Один -1 для всех причин недостаточен.
|
||||
— Единая модель допустимости для init/hash/page/результатов, с отдельной обработкой
|
||||
ожидания зависимости; неподтверждённые состояния не выдаются за проверенные.
|
||||
— Проверка зависимостей учитывает границу всей пачки/раунда MEMBERS, а также ключи,
|
||||
появившиеся через другой канал. Ожидание feedback не блокирует корректную запись участника.
|
||||
— При выборе зависимого от допуска Merkle-набора нужны явные пути пересмотра всех
|
||||
затронутых авторов/namespace. Их индекс обновляется до публикации изменения,
|
||||
changed/UI-события — после внешнего commit, а не из props callbacks.
|
||||
— Неактивные сохранённые версии не удаляются; после восстановления выбираются
|
||||
по прежнему правилу ver/data. Формат не доказывает создание до исключения:
|
||||
обещать запрет любых подписанных в период исключения версий после восстановления нельзя.
|
||||
— Регрессии дополняются неизвестным signer, разным порядком/страницами доставки,
|
||||
поступлением ключа через другой канал и восстановлением без изменения самого автора.
|
||||
|
||||
Для чистоты реализации проверки удостоверения принадлежат member_sync, состояние и индекс
|
||||
feedback — chat_feedback, представление опросов — chat_poll. Независимые копии признака
|
||||
допуска, снятие общей проверки radio/attachments и запись индекса из post-commit callbacks
|
||||
не являются согласованным решением. Полная реализация и её проверка остаются отдельным этапом.
|
||||
|
||||
9. Реализация feedback: активный набор, зависимости и восстановление
|
||||
|
||||
Реализован выбранный пользователем контракт: неактивные реакции/ответы исчезают
|
||||
из результатов, выдачи и Merkle-набора, но сохраняются в feedback_<channel>
|
||||
вместе с ver, подписью и нулевым состоянием отмены. При восстановлении допуска
|
||||
сохранённое состояние заново проверяется и может распространяться новым пирам.
|
||||
Wire v3/feedback v1 и формат подписанной записи не менялись; добавлен индекс (node_id,bucket).
|
||||
|
||||
Проверки удостоверения сосредоточены в member_sync_author_key: ACTIVE, INACTIVE,
|
||||
PENDING, INVALID, ERROR. Только ACTIVE записывает выходной ключ. Недостающий сертификат
|
||||
или доверенный ключ отличается от недействительной подписи и отказа SQL/OpenSSL.
|
||||
Старый verified_ed25519 по-прежнему допускает radio/attachments только при ACTIVE.
|
||||
source=0/deleted=0 — необходимый фильтр, но не доказательство полного допуска.
|
||||
|
||||
chat_feedback использует один fb_read_active для восстановления, хеша и страницы:
|
||||
проверяются текущий допуск, подпись состояния, канонические record_key/bucket.
|
||||
Неактивная/ожидающая история не включается в индекс; ошибка проверки не маскируется пустым листом.
|
||||
Init строит активный индекс в одной транзакции после начального purge MEMBERS.
|
||||
Входящие INACTIVE/PENDING не сохраняются и не ломают остальные корректные записи страницы.
|
||||
Локальный set проверяет допуск до сравнения со старым значением, включая no-op.
|
||||
chat_poll использует тот же author_key до декодирования и учёта ответа/реакции.
|
||||
|
||||
При изменении удостоверения, общего Ed25519-ключа или deleted пересчитываются
|
||||
feedback-листья автора и зависимых от его ключа signed_by во всех каналах.
|
||||
Пересчёт входит в транзакцию изменения MEMBERS/общего ключа; ошибка откатывает оба индекса.
|
||||
PENDING не блокирует корректную запись участника, даже если signer приходит позднее.
|
||||
До регистрации FEEDBACK hook ничего не делает; startup rebuild затем учитывает результат purge.
|
||||
Сохранённая активность автора отдельно в БД не дублируется.
|
||||
|
||||
MS_APPLY_FEEDBACK переносит факт изменения удостоверения/ключа/допуска через внешнюю транзакцию.
|
||||
Страница и JOIN сохраняют полную маску apply до вызова record_committed после внешнего commit.
|
||||
Обычные profile/name/stream options меняют MEMBERS, но не пересчитывают feedback и не будят его сессии.
|
||||
Post-commit уведомления читают адреса сообщений без живого cursor через callback.
|
||||
После физического purge сохраняются feedback и барьеры ver у отмен; уведомления идут после commit.
|
||||
|
||||
Сетевой сценарий выявил также зависший старый раунд после исключения пира:
|
||||
пакет уже отброшен по допуску, а восстановление членства само по себе не завершает ожидание.
|
||||
merkle_sync_revalidate_peers после commit/changed завершает ожидания MT_ERR_DATA,
|
||||
освобождает tx/waiter/deadline и оставляет подписку FAILED. При возврате changed начинает
|
||||
новый раунд BEGIN/WAKE через прежний UP-транспорт. Callback вправе отменить сессию;
|
||||
обход после callback не использует старый указатель. MEMBERS/другие datasets не затрагиваются.
|
||||
|
||||
Штатные регрессии:
|
||||
— test_chat_feedback: исключённый другой автор, реакция/ответ/отмена, сохранность подписей/ver,
|
||||
два канала, restart, запрет локального no-op/отмены, смешанная страница, физический purge,
|
||||
возвращение без возрождения старой версии, неизвестный signer и его поступление только в1002.
|
||||
Проверены SQL lookup/index failure, SQLite commit veto и внешние rollback/commit без ранних событий.
|
||||
Подписанное обновление профиля оставляет feedback-корень/события неизменными.
|
||||
— test_member_adapter/test_member_purge: дополнительная проверка ERROR и неизменности выходного
|
||||
ключа при отказах двух стадий Ed25519, SHA256 и prepare/bind/step/finalize.
|
||||
— test_merkle_protocol: отзыв допуска при заблокированных QUERY/ERROR, освобождение ожидания,
|
||||
сохранение подписки, новый раунд после возврата, callback с удалением своей сессии.
|
||||
— test_chat_poll_network: четыре настоящих ядра/UDP, A↔B↔C, исключение A, полный stop/start C,
|
||||
поздний D без feedback исключённого A, возврат A, догон D и новая отмена от A по прежнему транспорту.
|
||||
Проверены JSON-результаты, сохранённые версии/подписи/отмены и совпадение Merkle-корней.
|
||||
|
||||
Первый расширенный сетевой запуск остановился в стенде: D открыл группу до входящего UP.
|
||||
Ожидание транспорта добавлено; затем тест обнаружил и помог исправить ожидание отозванного раунда.
|
||||
Первый sanitizer-прогон дал12 PASS, затем test_stream_service остановился на временном
|
||||
assertion line1630 (99–150 мс); отчёта ASAN/UBSan не было. По логу устранены лишние пустые
|
||||
feedback-раунды при профилях/stream options. Временные границы stream-теста не ослаблялись.
|
||||
Повторный полный профиль без параллельной нагрузки проходит15/15, включая stream_service.
|
||||
|
||||
Сборка обычного ядра/тестов PASS. До оптимизации отдельный check.sh дал200/200 и все
|
||||
сетевые интеграции,exit0,193с: /tmp/feedback-author-fix-full-check.log.
|
||||
Окончательный check.sh текущего кода:200/200 PASS,0 failed/0 skipped, proxy9/9,
|
||||
burst1/1,8388608 байт, load8/8,134217728 байт,exit0,186с.
|
||||
Лог /tmp/feedback-author-fix-full-final-check.log. Запущен через sudo -n unshare -nm
|
||||
с собственным /run и новым lo; сеть, firewall и /run хоста не менялись.
|
||||
Профильные логи /tmp/feedback-author-fix-native-final-{feedback,member,protocol,network}.log.
|
||||
|
||||
ASAN/UBSan/LSan:15/15 PASS, detect_leaks=1:halt_on_error=1:abort_on_error=1,
|
||||
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1, отдельный user/network namespace.
|
||||
Изолированная сборка /tmp/utun-member-purge-sanitizers; подтверждены __asan_/__ubsan_
|
||||
в core/libuasync/Opus,12 изменённых C/header-файлов побайтно совпадают с рабочим кодом.
|
||||
Логи /tmp/feedback-author-fix-asan-final-results.log, -final-run.log и -final-test_*.log;
|
||||
согласованность /tmp/feedback-author-fix-{asan-consistency,source-consistency}.log.
|
||||
Предварительные отказы сохранены в /tmp/feedback-author-fix-network*.log и -asan-test_stream_service.log.
|
||||
|
||||
Ключевые коммиты:606c32f3,5e125755,0877930e,c0095c54,23329a44;
|
||||
штатные регрессии c4a6a128,9750fd22,f6a72c16,1720945d. Коммиты локальные, push не выполнялся.
|
||||
Исправление закрыто. Это не завершает исходный аудит member/Merkle, db_sync и всех callers:
|
||||
частичные отметки чеклиста сохранены. Native Windows/FreeBSD, GUI и Android не запускались.
|
||||
|
||||
10. Продолжение db_sync: отказ проверки удаляет корректную сохранённую запись
|
||||
|
||||
База aea8ca72 после rebase/push; рабочее дерево перед аудитом было чистым.
|
||||
STUN и поиск первого падающего коммита исключены пользователем из этой работы.
|
||||
Продолжено чтение db_sync.h, частей db_sync.c и db_sync_arch.md, связанных с хранением,
|
||||
верификацией, удалением, пересчётом цепочки и исходящей пачкой. Аудит всего db_sync
|
||||
и всех обработчиков/коллбэков ещё не завершён; db_sync.h/arch.md помечаются [~].
|
||||
Runtime-код, wire, схема БД и штатные тесты проекта не менялись.
|
||||
|
||||
Уточнённый пользователем контракт:
|
||||
DELETE разрешён, если проверка выполнена и подпись проверенных данных не совпала,
|
||||
либо доказан повреждённый формат самих исходных данных. Отказ OpenSSL/памяти/SQL
|
||||
и отсутствие необходимого ключа не доказывают порчу и не должны удалять данные.
|
||||
Граница входящей копии и БД обязательна: неверная копия с тем же ключом не является
|
||||
доказательством неверности ранее сохранённых байтов. Перед удалением сохранённой
|
||||
строки проверять надо именно эту строку; корректную копию необходимо сохранять.
|
||||
|
||||
Причина первого дефекта:
|
||||
db_verify_author_sig (db_sync.c:622) объединяет отсутствие ключа, битый формат,
|
||||
SC_ERR_AUTH_FAILED и SC_ERR_CRYPTO в -1. db_record_insert (db_sync.c:666–676)
|
||||
при любом таком результате выполняет DELETE по (timestamp,author_signature).
|
||||
Это происходит до проверки дубликата. Поэтому операционный отказ на корректном
|
||||
повторе стирает уже сохранённую запись и возвращает -2, а верхний лог называет её forgery.
|
||||
Та же классификация управляет удалением при выдаче SEND_DATA в si_send_data_batch
|
||||
(db_sync.c:1067–1077); отдельный сетевой runtime этой ветви не выполнялся.
|
||||
|
||||
Причина второго дефекта:
|
||||
Входящие байты проверяются, но удаляются байты из БД по совпавшему ключу.
|
||||
Если входящая копия изменена, а timestamp/подпись совпали с корректной сохранённой,
|
||||
SC_ERR_AUTH_FAILED относится к входящей копии. Текущий DELETE всё равно стирает корректную.
|
||||
Просто сохранить различие AUTH_FAILED/CRYPTO недостаточно: надо различать проверяемую копию.
|
||||
|
||||
Изолированное воспроизведение:
|
||||
Один настоящий core/chat_service, два временных канала SQLite :memory:, свои подписи.
|
||||
Сообщения публикуются через chat_core_submit_message; байты/подпись читаются публичным
|
||||
db_sync_select и повторно передаются публичному db_sync_insert_signed.
|
||||
Однократный EVP_DigestVerifyInit=0 вводится только в стенде через штатную линковку --wrap;
|
||||
все операции вне этой точки используют настоящие OpenSSL/SQLite/libutun/uasync.
|
||||
Нет пользовательской БД, третьих узлов или отправки повреждённых сетевых пакетов.
|
||||
|
||||
Случай ret Строк ожидалось/осталось Итог
|
||||
Корректный дубликат 1 1/1 PASS
|
||||
Разовый операционный VerifyInit=0 -2 1/0 FAIL
|
||||
Испорченная входящая копия -2 1/0 FAIL
|
||||
Неверная подпись самих сохранённых байтов -2 0/0 PASS
|
||||
Битое сохранённое тело размером4001 -2 0/0 PASS
|
||||
|
||||
После операционного отказа оригинальная подпись отдельно успешно проверяется SC_OK.
|
||||
После снятия отказа повторная вставка восстанавливает запись; без ошибки обычный
|
||||
дубликат не удаляет её. Во втором отрицательном случае до вызова отдельно подтверждено,
|
||||
что сохранённые байты корректны и отличаются от повреждённой входящей копии.
|
||||
Положительные контроли порчи меняют только собственную временную строку стенда.
|
||||
Другой канал сохраняет свою запись во всех случаях. После teardown tracked allocations
|
||||
восстановлены. Exit2 означает2/2 FAIL ожидаемого сохранения, а не ошибку сборки/стенда.
|
||||
Полный диагностический лог прочитан; причина VerifyInit failure и ошибочный forgery видны явно.
|
||||
|
||||
Дополнительный найденный дефект учёта доставки:
|
||||
si_delivery_update (db_sync.c:851) формирует UPDATE с WHERE timestamp=? AND author=?,
|
||||
но схема si_table_create содержит node_id и не содержит author. На реальной таблице
|
||||
стенда подготовка EXPLAIN UPDATE возвращает SQLITE_ERROR/no such column: author,
|
||||
без исполнения UPDATE. Поэтому эта ветвь не обновляет delivered_peers/delivery_chain;
|
||||
ошибка подготовки самим si_delivery_update не логируется. Полный сетевой PUSH/ACK
|
||||
для этой дополнительной находки не запускался; оба вызова используют тот же helper.
|
||||
|
||||
Стенд: /tmp/db-sync-audit-verify-failure.c
|
||||
Цель: /tmp/db-sync-audit-verify-failure.mk
|
||||
Сборка: make -C tests -f Makefile -f /tmp/db-sync-audit-verify-failure.mk /tmp/db-sync-audit-verify-failure
|
||||
Логи: /tmp/db-sync-audit-verify-failure-build.log (PASS), /tmp/db-sync-audit-verify-failure.log (exit2).
|
||||
Категории chat_sync=debug, crypto=info, остальное warn; запуск в отдельном user/network namespace.
|
||||
|
||||
Для исправления требуется единая классификация проверки (VALID, BAD_SIGNATURE,
|
||||
BAD_FORMAT, DEFERRED, ERROR), сохранение корректной существующей копии и отдельная
|
||||
проверка удаляемой строки. Условия нужны во вставке, исходящей пачке и обработчиках
|
||||
отказа; удаление доказанной порчи должно согласовываться с пересчётом цепочки и
|
||||
позициями/подтверждениями синхронизации. Неполный пакет без достоверного ключа записи
|
||||
не является основанием удалить произвольные данные БД. Операционные отказы требуют
|
||||
правильной диагностики и сохранения данных, включая путь неизвестного ключа.
|
||||
Контроль4001 подтверждает одну ошибку формата, но не проверяет все варианты повреждения.
|
||||
Полная проверка recalc, доставки и жизненного цикла callbacks остаётся дальнейшим этапом.
|
||||
|
||||
По AGENTS.md:10 аудит остановлен на подтверждённых дефектах; исправление runtime не выполнялось.
|
||||
Предупреждение и уточнение контракта добавлены в db_sync.h/arch.md, задачи — в doc/tasks.md.
|
||||
Новые полный check/sanitizers/GUI/Android/Windows/FreeBSD не запускались; STUN пропущен.
|
||||
Результаты прошлых исправлений member/feedback сохраняются и не объявляются покрытием новых случаев.
|
||||
|
||||
11. Исправление db_sync после rebase — 26.10.2026
|
||||
|
||||
По запросу пользователя выполнен fetch/rebase на origin/master=03b05094.
|
||||
Три локальных коммита аудита перенесены без конфликтов; range-diff:3/3 идентичны.
|
||||
Резервная ветка backup/db-sync-pre-rebase-61f5ab4f сохраняет исходный HEAD.
|
||||
Прежний запрет запуска STUN отменён новым запросом пользователя проверить его после rebase.
|
||||
|
||||
Реализация:
|
||||
- Проверка конкретных байтов возвращает VALID, BAD_SIGNATURE, BAD_FORMAT, DEFERRED или ERROR.
|
||||
Только SC_ERR_AUTH_FAILED завершённой операции означает BAD_SIGNATURE; CRYPTO и SQL lookup error — ERROR.
|
||||
Неизвестный ключ — DEFERRED. SQLITE_NOMEM при чтении BLOB не превращается в BAD_FORMAT.
|
||||
Формат здесь означает непустое тело до4000 байт и подпись ровно64 байта, не проверку JSON-схемы приложения.
|
||||
- Перед удалением дубликата внутри BEGIN IMMEDIATE независимо проверяется сохранённая строка.
|
||||
Корректная строка переживает неверную входящую копию. Доказанная сохранённая порча удаляется;
|
||||
при корректном входе вместо неё атомарно сохраняется исправная копия.
|
||||
Любой отказ чтения, DELETE, INSERT или COMMIT приводит к ROLLBACK без продвижения next_id/позиций.
|
||||
- Выдача пачки проверяет длины и подпись самой сохранённой строки. SELECT завершается до DELETE по rowid.
|
||||
Одна доказанно битая строка удаляется в собственной транзакции, затем пачка старого раунда не отправляется.
|
||||
После COMMIT запускается пересчёт с0 и сбрасываются позиции/состояния пиров; другой канал не затрагивается.
|
||||
- Recalc имеет отдельный pending, не зависящий от наличия таймера. Батч50 атомарен;
|
||||
только успешный COMMIT продвигает позицию. Отказ SQL/хеша/таймера сохраняет работу.
|
||||
Flush возвращает ошибку; обработчики не публикуют заведомо неготовые хеши как успешный результат.
|
||||
Битая длина сохранённой подписи, обнаруженная recalc, удаляется с проверкой SQL и COMMIT.
|
||||
- Парсер не передаёт короткую подпись дальше к чтению64 байт. Ошибка вставки/проверки/формата
|
||||
прерывает SEND_DATA без подтверждения позиций и SYNC_DONE; успешно вставленная часть остаётся для повтора.
|
||||
Старый peer-empty цикл теперь продвигается на фактически отправленное число записей при ограничении буфера.
|
||||
- ACK_PUSH изменён с(timestamp,author) на message_id[32], общий размер33 с типом; совместимость не поддерживается.
|
||||
SQL больше не использует отсутствующую author. Один UPDATE обновляет только нужную запись,
|
||||
повтор ACK от того же пира не увеличивает счётчик; WAS_SENT выставляется только для локального автора.
|
||||
Постановка PUSH в очередь доставкой не считается. Корректный дубликат PUSH тоже получает ACK.
|
||||
Подготовка/bind/step/finalize SQL и неизвестный message_id диагностируются.
|
||||
|
||||
Проверки и доказательства:
|
||||
- Прежний /tmp/db-sync-audit-verify-failure.c теперь exit0, сохранение2/2 PASS;
|
||||
положительные контроли удаления неверной подписи и тела4001 PASS, другой канал и allocations сохранены.
|
||||
Лог /tmp/db-sync-preservation-fixed.log. Исторический EXPLAIN старого неверного SQL
|
||||
в этом стенде ожидаемо остаётся SQLITE_ERROR; новую доставку проверяют штатные тесты ниже.
|
||||
- Новый tests/test_db_sync_integrity.c использует собственную SQLite, настоящие Ed25519 подписи,
|
||||
uasync и точечные --wrap отказы.10 введённых отказов: VerifyInit входа/сохранённой копии/выдачи,
|
||||
BLOB NOMEM во вставке/выдаче/recalc, COMMIT удаления/recalc/вставки, создание recalc-таймера.
|
||||
Дополнительно реальные SQL-триггеры запрещают DELETE, обновление хеша и ACK; проверены откат и повтор.
|
||||
Неизвестный ключ и SQL lookup error сохраняют историю. Проверены две таблицы, reset позиций,
|
||||
замена порчи корректной копией, длины подписи0/1/63/65/255, тело4001 и55 записей (несколько батчей).
|
||||
ACK для двух подписей одного автора при одном timestamp обновляет только конкретную запись и идемпотентен.
|
||||
После teardown tracked allocations восстановлены. Лог /tmp/db-sync-integrity-final.log,exit0.
|
||||
- tests/test_db_sync.c дополнен двумя сообщениями одного автора с одинаковым timestamp:
|
||||
до poll доставки нет, затем реальные PUSH/ACK подтверждают оба сообщения независимо.
|
||||
Все прежние сценарии2/3 узлов, merge, mid/tail PUSH, reply и randomized reconnect также PASS.
|
||||
Лог /tmp/db-sync-network-final.log; схема — /tmp/db-sync-schema-final.log, оба exit0.
|
||||
- Полный ./check.sh в отдельном root network/mount namespace:203/203 PASS,0 failed/0 skipped;
|
||||
proxy9/9,burst1/1 (8388608 байт),load8/8 (134217728 байт),exit0,195с.
|
||||
Лог /tmp/db-sync-full-final-check.log. Финальная целевая сборка без предупреждений.
|
||||
- STUN: отдельный запуск и общий набор PASS. Прежнее падение assert(bind) после rebase не воспроизведено;
|
||||
причина прежнего отказа и первый падающий коммит не установлены, исправление STUN не заявляется.
|
||||
В общем STUN-логе есть штатно пропущенный подслучай bind-to-device (нет разрешения);
|
||||
основные RFC5780/fallback/cache/lifecycle/isolation проверки выполнены.
|
||||
Логи /tmp/stun-after-rebase.log и tests/logs/test_stun_nat.log.
|
||||
- Отдельная копия HEAD9131789c: ASAN+UBSan и detect_leaks=1,halt_on_error=1;
|
||||
core/libuasync/Opus согласованно инструментированы.8/8 PASS: db_sync_integrity,db_sync,
|
||||
db_sync_schema,member_adapter,chat_feedback,chat_poll,chat_poll_network,merkle_protocol.
|
||||
/tmp/db-sync-asan-results.log и /tmp/db-sync-asan-test_*.log; признаков sanitizer errors нет.
|
||||
|
||||
Границы:
|
||||
Полный аудит db_sync не закрыт. В существующем db_handle_sync_done ветка одинакового count
|
||||
с разными chain_hash всё ещё явно принимает состояние как converged; это отдельный дефект
|
||||
финального критерия, не проверявшийся новым runtime-стендом. Wire раундов не имеет generation ID;
|
||||
поведение поздних пакетов прежнего раунда требует отдельного анализа. Также остаются TTL
|
||||
(единицы времени/пересчёт после удаления) и жизненный цикл callback-списка.
|
||||
Эти места не включены в доказанное покрытие текущего исправления. GUI/Android/Windows/FreeBSD не запускались.
|
||||
Следующий этап аудита требует отдельного исправления финального критерия; по AGENTS.md
|
||||
дальнейшее расширение текущей реализации остановлено на этом замечании.
|
||||
|
||||
12. План устранения ложной сходимости истории и проверка предпосылок — 26.10.2026
|
||||
|
||||
Запрос пользователя: продумать исправление и его корректность. Runtime-код в этом этапе не менялся.
|
||||
Повторно прочитаны относящиеся к обмену структуры/функции db_sync, общий merkle_sync,
|
||||
контракт data_ops, запуск/остановка chat_sync, создание канала, callback докачки медиа и sleep policy.
|
||||
|
||||
Почему заменить одну ветку на restart недостаточно:
|
||||
1. db_handle_send_data объявляет sync_state=2/done сразу после отправки собственного SYNC_DONE,
|
||||
ещё до получения подтверждённого равенства. db_handle_init_resp имеет отдельный путь завершения.
|
||||
2. db_handle_init_resp исследует только последние51 позицию через16 интервалов.
|
||||
Самая ранняя несовпавшая проба не является первым отличием или доказанным общим префиксом.
|
||||
3. chain_hash включает id, который не входит в ключ записи или подпись автора.
|
||||
Разные id одного подписанного сообщения сохраняются при duplicate и не исправляются пересылкой.
|
||||
4. OFFSET относится к изменяемой таблице; поздний PUSH/DELETE сдвигает позиции.
|
||||
В старом wire нет идентификатора раунда, запросы/ответы не проверяются по фазе.
|
||||
5. Сравнение8 байт не заменяет проверку полного SHA256 при объявлении успеха.
|
||||
|
||||
Дополнительный изолированный стенд /tmp/db-sync-completion-design.c/.mk, сборка через tests/Makefile:
|
||||
использованы настоящие SQLite/OpenSSL/uasync и private-функции из штатного integrity test.
|
||||
Только собственные временные записи, вызовы локальных обработчиков, без сетевых пакетов.
|
||||
- Две таблицы одного group_id содержат одинаковые timestamp/author/data/signature, но id1 и900.
|
||||
Обе db_sync_chain_verify возвращают0, последние хеши различны. Передача локальному обработчику
|
||||
count1/чужого хеша вызывает done1 и state2: ложное завершение подтверждено runtime.
|
||||
- По200 записей, различие только в первой; остальные199 записей и id совпадают.
|
||||
Фактические хеши по текущим sparse-пробам дают fm148. Весь хвост148..199 — дубликаты;
|
||||
его повторная вставка в обе таблицы сохраняет различие корней. Простое повторение старого обмена
|
||||
не имеет условия прогресса. Симулировался выбор sparse-проб и повтор хвоста, не полный сетевой раунд.
|
||||
- Exit0, tracked allocations восстановлены. /tmp/db-sync-completion-design-build.log и
|
||||
/tmp/db-sync-completion-design.log. Это диагностика существующего кода, не тест новой реализации.
|
||||
|
||||
Повторная проверка самого плана выявила обязательную предпосылку общего движка:
|
||||
при merkle_sync_cancel сессия и её round освобождаются. Новый follower начинает с round0,
|
||||
поэтому прежний BEGIN7 принимается как новый и получает READY. Локальный стенд
|
||||
/tmp/merkle-reattach-design.c/.mk это подтвердил на штатной protocol-фикстуре после cancel/start:
|
||||
round7,stateSERVE,READY в очереди. Применение старых PAGE или ложный MT_OK этим стендом
|
||||
не проверялись и не заявляются. Логи /tmp/merkle-reattach-design-build.log и
|
||||
/tmp/merkle-reattach-design.log,exit0,allocations восстановлены.
|
||||
Значит, при переносе нельзя безусловно считать поле round достаточной защитой после пересоздания сессии.
|
||||
|
||||
Варианты:
|
||||
А. Сохранить собственный bulk-протокол: потребуются канонический хеш без id, поиск настоящего общего
|
||||
префикса либо полный fallback с0, устойчивый курсор, два явных прохода, round/request/state,
|
||||
проверка revision и подтверждённого корня, backpressure и корректная отмена.
|
||||
Это второй самостоятельный автомат с теми же обязанностями, что уже выполняет merkle_sync.
|
||||
Б. Рекомендуемый: история как MT_DATA_MESSAGES в общем merkle_sync. db_sync остаётся моделью хранения,
|
||||
публичным API истории и PUSH/ACK. Старые INIT/RESP/SEND_DATA/SYNC_DONE и цепной recalc удаляются
|
||||
после подключения новой модели. Два bulk-протокола одновременно не поддерживаются.
|
||||
Это архитектурный перенос, а не локальная замена ветки SYNC_DONE; отдельное одобрение реализации
|
||||
не подразумевается самим запросом продумать план.
|
||||
|
||||
Конкретный план вариантаБ:
|
||||
|
||||
0. До подключения истории закрепить изоляцию пересозданных сессий общего Merkle.
|
||||
В новой версии wire использовать двустороннее подтверждение попытки:
|
||||
BEGIN несёт свежий round_id инициатора; READY добавляет свежий accept_id ответчика;
|
||||
последующие QUERY/PAGE/TURN/CHECK/DONE/ERROR проверяют пару идентификаторов, request и фазу.
|
||||
Идентификаторы — случайные128 бит с проверкой отказа генерации; каждый новый раунд/handshake
|
||||
получает новые значения, отмена уничтожает действующий accept_id. Поля namespace/dataset/соединение
|
||||
остаются частью области сессии. READY принимается только для ожидаемого текущего BEGIN.
|
||||
Поздний BEGIN после cancel может получить свежий challenge; старые PAGE/DONE содержат прежний accept_id
|
||||
и не могут примениться или завершить новый запрос. Дубликат BEGIN активной попытки не перезапускает
|
||||
её применение; повтор WAKE может вызвать новый handshake, но не является успехом или применением данных.
|
||||
Так не требуется хранить неограниченный архив отменённых сессий или надеяться на порядок доставки.
|
||||
Проверить это для MEMBERS/FEEDBACK прежде, чем добавлять MESSAGES. Старая версия wire не поддерживается.
|
||||
|
||||
1. Канонический набор истории.
|
||||
Namespace — числовой channel_id; dataset отдельный от MEMBERS/FEEDBACK.
|
||||
Ключ — существующий message_id[32], проверяемый вычислением из group_id/timestamp/signature.
|
||||
Лист и страница используют один и тот же отбор записей и порядок message_id по полным32 байтам.
|
||||
Канонические байты записи: timestamp(BE64),author(BE64),data_len(BE32),точные data,signature64;
|
||||
message_id связывает запись с каналом. Хеш листа включает ключ и эти однозначно кодируемые байты.
|
||||
id,flags,local_attrs,delivery_chain/delivered_peers и прочие локальные поля в корень не входят.
|
||||
Существующие подписи и идентичности сообщений не меняются; переподписывать историю не нужно.
|
||||
Авторство старой записи не фильтруется текущим ACTIVE-правилом feedback. Допуск передающего пира
|
||||
и проверка исторической подписи — разные проверки; действующие права не расширяются.
|
||||
|
||||
2. Индекс и границы SQL-транзакций.
|
||||
Добавить индекс для выбора листа и keyset-страниц; bucket — старшие25 бит ключа.
|
||||
Первая активация/restart восстанавливает производное дерево из сохранённых записей до снятия gate.
|
||||
Индекс строится ограниченными порциями, без долгой SQLite-транзакции между сетевыми событиями;
|
||||
ошибка оставляет канал неготовым, не публикует неполный корень как пустой.
|
||||
Ожидание неизвестного ключа автора — отдельное от операционного отказа состояние: строки сохраняются,
|
||||
готовность истории откладывается, но запуск чата не падает. После поступления общего ключа, в том числе
|
||||
через другой канал, индексация/попытка возобновляется без перезапуска сервиса.
|
||||
Каждый путь изменения содержимого (локальная вставка, PUSH, bulk page, доказанная порча, TTL)
|
||||
сохраняет строки и пересчитывает затронутые Merkle-пути в одной транзакции.
|
||||
Только после COMMIT меняются runtime-состояние/next_id и вызываются changed/on_insert.
|
||||
flags/local_attrs/ACK не меняют набор и не запускают Merkle.
|
||||
Перестройка формата должна сохранять подписанные строки и локальные атрибуты; DROP истории недопустим.
|
||||
|
||||
3. Adapter data_ops.
|
||||
get_page — WHERE bucket=? AND message_id>after ORDER BY message_id LIMIT..., с учётом размера пакета.
|
||||
Применение проверяет весь формат страницы, ключи/курсоры, подписи и достоверность identity до COMMIT.
|
||||
Одна страница атомарна: частичная запись/индекс/события при SQL или OpenSSL failure запрещены.
|
||||
apply_items не вызывает каскад PUSH и не отправляет пакеты из callback. Для локальной вставки/PUSH
|
||||
после COMMIT вызывается changed; bulk тоже уведомляет о реальном изменении, чтобы оно дошло другим пирам.
|
||||
Неизвестный ключ/ошибка операции прерывают попытку без удаления и ложной пустой страницы.
|
||||
BAD_SIGNATURE/BAD_FORMAT относится к проверенным сохранённым байтам и допускает согласованную очистку.
|
||||
Хеширование/чтение страницы сами не удаляют строки из открытого курсора и не запускают рекурсивный update.
|
||||
Если там найдена сохранённая порча, чтение завершается ошибкой; очистка выполняется отдельно
|
||||
после закрытия курсора в проверяемой транзакции, обновляет индекс и запускает повтор.
|
||||
|
||||
4. Управление раундом и успехом.
|
||||
Используются общий request/state и усиленная пара идентификаторов из п.0, два pull-прохода,
|
||||
CHECK/DONE, полный32-байтовый корень,
|
||||
revision, deadline и очередь с waiter. Ошибка или повтор неизменной пары разных корней — не успех.
|
||||
done для db_sync/media поступает только от MT_OK; постановка пакета в очередь и конец страницы
|
||||
не подтверждают завершение истории. Последующие изменения вновь делают сессию dirty.
|
||||
Успех означает подтверждённую контрольную точку данного раунда, не вечную глобальную синхронность
|
||||
при продолжающейся записи. PAGE/DONE/ERROR прежней пары идентификаторов не изменяет данные/позиции,
|
||||
не продлевает deadline и не завершает новый запрос. BEGIN/WAKE рассматриваются отдельно как handshake,
|
||||
а не как доказательство результата. Эти свойства проверяются и для подключённой модели истории.
|
||||
|
||||
5. Интеграция, права и владение.
|
||||
Регистрировать MESSAGES после создания общего движка при chat startup; data_ctx — стабильный inst,
|
||||
канал ищется по namespace, указатель на DB_SYNC_INSTANCE не удерживается движком после remove.
|
||||
Операционный отказ регистрации/индексации диагностируется и откатывает незавершённое подключение;
|
||||
ожидание ключа не приравнивается к ошибке. При restart заново восстановить дерево и подписки,
|
||||
даже если ETCP уже UP и повторного события UP не будет. До MEMBERS и READY история остаётся gated.
|
||||
validate_peer проверяет активную таблицу/готовность/членство. Изменение допуска после COMMIT
|
||||
переоценивает и MESSAGES, отменяя недопущенный раунд без потери чужих datasets.
|
||||
Нужна узкая отмена по(dataset,namespace), а для sleep — по(dataset,peer): cancel_ns/cancel_peer сейчас
|
||||
затрагивают сразу MEMBERS/FEEDBACK. Remove канала отменяет запросы истории до освобождения состояния.
|
||||
Порядок stop учитывает, что member_sync_destroy уничтожает общий движок раньше удаления db_sync instances.
|
||||
Публичные проверки цепочки заменяются явными проверкой истории/чтением полного Merkle-корня;
|
||||
тесты обновляются, старые chain API с неожиданно изменённой семантикой не сохраняются.
|
||||
Мост старых done callbacks требует безопасного удаления подписок во время dispatch; кешировать
|
||||
next на освобождаемом элементе нельзя. Проверить удаление текущего/следующего callback и канала.
|
||||
|
||||
6. Sleep/sound и PUSH/ACK — обязательная часть, не скрытое изменение поведения.
|
||||
Сейчас общий движок запрещает start при peer_sleep_phase, а chat_sync при sleep отменяет все datasets.
|
||||
db_sync допускает важный чат при sound_on=1. При переносе это нельзя молча потерять.
|
||||
Ввести предикат разрешённой работы модели: default для MEMBERS/FEEDBACK сохраняет нынешний запрет сна;
|
||||
для MESSAGES использует существующий db_sync_peer_throttled. Проверять его при старте, автоповторе
|
||||
после changed и обработке события sleep/sound, отменяя только запрещённые сессии.
|
||||
Awake и включение sound_on повторно подключают допустимые READY-сессии, даже без нового UP;
|
||||
одного changed недостаточно, если предыдущая отмена освободила подписку.
|
||||
PUSH и ACK(message_id) продолжают текущую адресную доставку; собственный round не требуется ACK,
|
||||
потому что он подтверждает конкретную неизменяемую запись. Очередь не считается доставкой.
|
||||
Отказ PUSH не теряет dirty-изменение Merkle. Bulk-совпадение корня само по себе не увеличивает
|
||||
delivered_peers: этот счётчик сохраняет текущую семантику индивидуальных ACK, без подтверждения
|
||||
более поздних записей старым результатом раунда.
|
||||
|
||||
7. TTL и очистка.
|
||||
До удаления старой цепочки исправить сравнение timestamp(ms) с cutoff сейчас в микросекундах;
|
||||
использовать то же определение времени, что при создании timestamp, и исключить unsigned underflow.
|
||||
Сохранить отдельную политику удаления локальных неподтверждённых записей, без глобальных tombstones.
|
||||
TTL удаляет строки и обновляет MESSAGES-пути атомарно; изменившаяся история уведомляет движок после COMMIT.
|
||||
Удаление хорошей копии из-за ошибки проверки запрещено прежним контрактом.
|
||||
|
||||
Почему этот план логически сходится:
|
||||
- Равенство набора записей даёт одинаковые корни независимо от локальных id, порядка получения,
|
||||
delivery counters или local_attrs. Изменение подписанного содержимого меняет канонические байты.
|
||||
- В пределах листа after — полный ключ неизменяемой записи, а не OFFSET; вставка до курсора не сдвигает
|
||||
уже читаемый хвост. Если её пропустил текущий проход, различие финальных корней требует нового раунда.
|
||||
- При конечном наборе допустимых записей, прекращении внешних изменений, доступных ключах/ресурсах,
|
||||
доставке пакетов и отсутствии криптографических коллизий первый pull добавляет записи второго узла
|
||||
первому, обратный — объединение второму. Пропущенные из-за изменения дерева листья дочитываются
|
||||
следующим раундом; принимать разные корни для окончания не требуется.
|
||||
- Ошибки формата/операций и неизвестные зависимости не кодируются совпадением; repeated-root conflict
|
||||
завершает попытку ошибкой, сохраняя данные. Поэтому нет бесконечного успешного restart без прогресса.
|
||||
- Сохранённые данные и индекс меняются атомарно. Предположение доказательства — все изменения модели
|
||||
проходят этот путь и производный индекс согласован; произвольная порча SQLite вне модели не становится
|
||||
автоматически обнаружимой без чтения/проверки соответствующих записей. Startup и читаемые страницы
|
||||
проверяются явно; непрерывная проверка всех байтов хранилища этим планом не обещается.
|
||||
- Контрольная точка допускает последующую запись/отзыв/TTL: они помечают дерево dirty или отменяют
|
||||
доступ, а не используют старый MT_OK для подтверждения новых данных.
|
||||
|
||||
Проверки при реализации (требования, сейчас не запускались для новой модели):
|
||||
- Равный count и разные наборы: отличие в начале/середине/конце истории, >200 и несколько тысяч записей;
|
||||
исходные ложные завершения воспроизводятся до исправления и исчезают после него.
|
||||
- Полностью одинаковые подписанные записи с разными id/local_attrs/доставкой: одинаковый полный корень.
|
||||
- Пустые таблицы; по одному сообщению; два автора с одним timestamp; несколько страниц одного листа,
|
||||
пакет заполнен раньше лимита записей, дубликаты и повтор последней страницы.
|
||||
- PUSH/вставка до и после курсора; третий узел; удаление/TTL; изменения между CHECK и отправкой DONE.
|
||||
- Старые round/request, неожиданные фазы, отмена/повтор, DOWN/REINIT/restart, старый UP без нового события.
|
||||
- Cancel/start follower, затем прежний BEGIN и PAGE/DONE старого accept_id; новая попытка того же namespace
|
||||
на том же UP-соединении; повторы BEGIN/READY/WAKE и старый ERROR. Никакой успех/запись от старой пары.
|
||||
- SQL prepare/bind/step/commit, BLOB NOMEM, хеш/VerifyInit, очередь/таймер/waiter: нет частичных данных,
|
||||
ложного MT_OK, потери корректных строк или callback после cancel/free.
|
||||
- ACTIVE-допуск пира, отзыв/возврат, сохранение старых сообщений исключённого автора; различие sound_on/off
|
||||
при сне, изменение sound в ходе раунда, восстановление после пробуждения.
|
||||
- Недостающий исторический ключ не роняет startup и не стирает записи; его получение через другой
|
||||
канал возобновляет индексацию и обмен без restart. Операционный SQL/crypto error имеет отдельный исход.
|
||||
- Сохранность прежней истории/подписей/локальных атрибутов при смене схемы, удаление только derived-chain
|
||||
формата, startup index failure, остановка и повторный запуск чата с работающим UTUN.
|
||||
- Корректные индивидуальные ACK без двойного счётчика; media backfill вызывается только при MT_OK;
|
||||
callback отписывает себя/следующий callback и удаляет канал без UAF.
|
||||
- Общий check.sh со STUN и целевые ASAN/UBSan/LSan после реализации.
|
||||
|
||||
Статус: план вариантаБ сформирован и уточнён после отдельной проверки reattach;
|
||||
логическое обоснование требует п.0 и согласованного индекса, а не опирается на неизменённый старый движок.
|
||||
новая модель и её тесты ещё не реализованы. Перенос нельзя объявлять проверенным текущими203/203
|
||||
и8/8: эти результаты относятся к уже выполненному исправлению раздела11.
|
||||
|
||||
13. Реализация MESSAGES, свежий handshake и ограниченные транзакции
|
||||
|
||||
Пользователь одобрил вариантБ раздела12, уточнив два обязательных условия:
|
||||
- Не ограничивать исторические сообщения по deleted автора: ограничение входа в группу отдельно.
|
||||
- Не фиксировать всю новую историю одной транзакцией: короткая локальная вставка/страница,
|
||||
сеть вне транзакции, первичная проверка и построение индекса ограниченными порциями.
|
||||
|
||||
Реализовано:
|
||||
- db_sync оставляет общую SQLite, подписанные записи, публичное API и PUSH/ACK(message_id).
|
||||
Собственный bulk-автомат, chain_hash, sparse-поиск и сетевой OFFSET удалены.
|
||||
MT_DATA_MESSAGES=2 использует общий merkle_sync; namespace=group_id, ключ=message_id[32].
|
||||
Канонический лист включает ключ/timestamp/author/длину/точное тело/signature и исключает local id,
|
||||
attrs/flags/доставку. Одинаковая подписанная история не зависит от локального порядка получения.
|
||||
- Wire v4:55-байтовый заголовок с двумя случайными128-битными round_id/accept_id.
|
||||
Старые PAGE/DONE/ERROR после cancel/start не проходят новый challenge; дубликаты BEGIN/READY
|
||||
не сбрасывают применение. Ошибка RNG обеих сторон даёт MT_ERR_IO до установки нового раунда.
|
||||
Полные корни и revision подтверждаются общими CHECK/DONE; равный count не означает успех.
|
||||
- Страница MESSAGES проверяет полный32-байтовый курсор, лист, строгий порядок и подписи до BEGIN.
|
||||
До16 записей и ограничение размера wire; данные/индекс/next_id фиксируются атомарно.
|
||||
Обычная вставка меняет один путь дерева в своей короткой транзакции.
|
||||
Preflight проверяет до16 строк вне write-lock, затем сверяет собственные байты в коротком commit.
|
||||
Второй этап строит до16 листьев/путей за commit; timer1 tick возвращает управление I/O между шагами.
|
||||
Число строк ограничено, но жёсткий временной предел commit не обещается: диск/наполнение листа влияют.
|
||||
- VALID/BAD_FORMAT/BAD_SIGNATURE/DEFERRED/ERROR сохранены. Удаляются только доказанно неверные
|
||||
сохранённые байты; SQL/OpenSSL/NOMEM/неизвестный ключ не удаляют историю и не публикуют пустой корень.
|
||||
Ошибка оставляет pending; поздний общий ключ, включая событие другого канала, возобновляет preflight.
|
||||
Ключ автора читается независимо от deleted; допуск пересылающего READY-пира проверяется отдельно.
|
||||
- Миграция схемы сохраняет подписанные строки и local_attrs/flags/счётчики доставки.
|
||||
Производные message_id/bucket вычисляются заново; ошибка DDL/copy/COMMIT откатывает перестройку.
|
||||
Подпись неправильной длины — доказанный неверный формат, корректной длины проверяется preflight.
|
||||
- Новые data_ops apply_page/can_run/on_result; узкая отмена dataset/ns и dataset/peer.
|
||||
MESSAGES сохраняет sound_on-разрешение спящего пира; остальные модели используют default sleep policy.
|
||||
Отзыв/возврат допуска и sleep/sound переоценивают сессии на прежнем UP, не затрагивая чужие datasets.
|
||||
Android retry сохраняет отменяемое ожидание активной фазы, inline callback отказа памяти не рекурсивен.
|
||||
- on_insert bulk отложен за пределы data_ops и видит committed страницу; rollback отменяет уведомления.
|
||||
Done получает только MT_OK, включая автоматические раунды; самоотписка, удаление следующей подписки
|
||||
и канала безопасны. Remove сохраняет таблицу, отменяя только её MESSAGES/таймеры/уведомления.
|
||||
- TTL исправлен на миллисекунды Unix и удаляет до16 собственных неподтверждённых строк за commit.
|
||||
Удаление и очистка MESSAGES-индекса атомарны; далее корень gated/pending до восстановления.
|
||||
Это уточнение реализации к плану: массовая очистка сбрасывает производный индекс вместо обновления
|
||||
каждого удалённого пути. Не создаёт tombstone; другая сохранённая копия может вернуться при обмене.
|
||||
|
||||
Проверки новой реализации:
|
||||
- test_db_sync:реальный ETCP/READY,2/3 узла, PUSH/ACK и случайные слияния;200 одинаковых записей,
|
||||
затем отличие первой при равном count и сдвиг local id на900. Итог201 на обоих, полный корень одинаков.
|
||||
- test_chat_sync_stress UTUN_TEST_HISTORY_LARGE=1:20 чередующихся пачек по250 записей двух авторов;
|
||||
вставлено5000, вместе с исходными2 итог5002/5002 и одинаковые полные корни,exit0.
|
||||
insert_avg453мкс,финальная сходимость2180мс,весь сценарий4485мс; обычный случай после добавления режима PASS.
|
||||
- test_db_sync_integrity:11 введённых crypto/SQL/COMMIT/BLOB/timer отказов, реальные подписи/триггеры;
|
||||
сохранность хорошей копии, доказанная порча/формат0/1/63/65/255/4001, два канала, индекс/next_id rollback.
|
||||
Плотный лист40 записей, несколько страниц, вставка до курсора требует следующей сверки, replay без дублей.
|
||||
Отказ второй записи и COMMIT страницы откатывает все строки/корень/уведомления; on_insert только после commit.
|
||||
Подписи preflight проверяются при sqlite3_get_autocommit=1. Разные id/attrs/flags/доставка не меняют корень.
|
||||
История deleted автора выдаётся; поздний ключ через событие другого канала сохраняет строку и запускает индекс.
|
||||
TTL дренирует19 expired в двух коротких commit и оставляет fresh/ACKed. Tracked allocations восстановлены.
|
||||
- test_merkle_protocol:старые токены/фазы/курсоры, reattach, дубликаты BEGIN/READY, RNG обеих сторон,
|
||||
backpressure, timeout, apply/SQLite faults, модельный sleep-допуск и восстановление на том же соединении.
|
||||
Последняя регрессия меняет revision после получения CHECK до передачи DONE:старый checkpoint
|
||||
не завершает запрос; контроль без изменения MT_OK. Нативный и ASAN/UBSan/LSan прогоны PASS.
|
||||
- test_db_sync_schema:сохранность полей, повторный restart, rollback DDL после copy, очистка только доказанной порчи.
|
||||
- test_dm_invite:настоящий JOIN и UTUN_TEST_HISTORY_TIMEOUT=1; принудительно потерянный обмен30с
|
||||
завершается timeout, повтор восстанавливает историю, тест exit0.
|
||||
- Общий первый прогон199/203:две изолированные db_sync-фикстуры не создавали общий движок,
|
||||
purge-фикстура использовала недопущенного peer2; исправлены setup/teardown и реальный допущенный peer.
|
||||
Повтор202/203, STUN PASS:stream-сценарий зависел от standby предыдущих сценариев.
|
||||
DEBUG и SQL-диагностика показали новые версии только у автора и sleep=1 на центральном пиру;
|
||||
wake-only PASS. Тест явно восстанавливает activity центрального узла и пробуждает lifecycle-получателя
|
||||
после сохранённой проверки перехода forced-active → standby. Production stream не менялся.
|
||||
- ASAN/UBSan/LSan10/10 PASS,detect_leaks=1:db_sync,integrity,schema,merkle_protocol,merkle_sync,
|
||||
member_adapter,chat_feedback,chat_poll,chat_poll_network,dm_invite. Core/libuasync/Opus инструментированы
|
||||
согласованно в /tmp/utun-member-purge-sanitizers. Нативные/ASAN-объекты не смешивались.
|
||||
Отдельной Makefile-цели test_member_sync нет: исключена ошибочная попытка implicit build;
|
||||
соответствующие проверки входят в member_adapter. Новая sanitizer-сборка/запуск10 целей exit0.
|
||||
- Временная цель через tests/Makefile: /tmp/history-standby-check.c/.mk, макрос UTUN_HAVE_STANDBY,
|
||||
настоящий history-код/SQLite/uasync и управляемый standby callback. Inline OOM даёт один retry без
|
||||
рекурсии, wake освобождает ожидание, allocations восстановлены;exit0. Это не запуск Android-приложения.
|
||||
|
||||
Документы db_sync.h/db_sync_arch.md и merkle_sync.h/merkle_sync_doc.md приведены к реализации.
|
||||
Ограничения:индекс согласован при записи через модель; произвольная внешняя порча SQLite
|
||||
выявляется при соответствующем чтении/перепроверке, непрерывный scrub всех байтов не заявляется.
|
||||
Платформенные GUI/Android/Windows/FreeBSD прогоны в этом этапе не выполнялись. Общий аудит чата частичный.
|
||||
Логи: /tmp/history-merkle-asan-results.log, /tmp/history-merkle-asan-test*.log,
|
||||
/tmp/history-merkle-final-test_db_sync_integrity.log, /tmp/history-merkle-final-test_merkle_protocol.log,
|
||||
/tmp/history-merkle-dm-timeout.log, /tmp/history-standby-check.log.
|
||||
Окончательный общий прогон /tmp/history-merkle-verified-check.log:203/203,0 failed/0 skipped,
|
||||
proxy9/9,burst1/1,load8/8 (134217728 байт),STUN/stream PASS,exit0,179с.
|
||||
После него добавлены только два тестовых случая:5000 записей и CHECK/DONE revision;
|
||||
оба нативно PASS, CHECK/DONE также PASS с sanitizer. Production-код не менялся.
|
||||
Дополнительные логи: /tmp/history-merkle-large.log, /tmp/history-merkle-final2-test_merkle_protocol.log,
|
||||
/tmp/history-merkle-final2-test_chat_sync_stress.log, /tmp/history-merkle-asan-checkpoint.log.
|
||||
Задача переноса закрыта. Изменения сохранены локально; push этого этапа не выполнялся.
|
||||
|
||||
14. Согласование отметок общего чек-листа
|
||||
|
||||
После завершения раздела13 итог был добавлен в конец /home/user/utun-doc-audit-checklist.txt,
|
||||
но семь строк основного списка ошибочно остались [~]. Исправлено по замечанию пользователя:
|
||||
member_sync.h/member_sync_doc.md, merkle_sync.h/merkle_sync_doc.md, db_sync.h/db_sync_arch.md
|
||||
и историческое merkle_tz.md отмечены [V]. merkle_tree.h уже имел [V].
|
||||
В документах сняты устаревшие формулировки о незавершённости именно этого блока;
|
||||
добавлено уведомление MESSAGES после commit MEMBERS, историческое ТЗ ссылается на wire v4.
|
||||
Остальные модули чата и записи других аудитов сохраняют прежние статусы.
|
||||
Это закрытие сверки описаний с кодом; непроведённые платформенные прогоны отдельно перечислены выше.
|
||||
Изменены только комментарии/документы/учёт статусов; runtime и тесты не менялись,
|
||||
прежние результаты раздела13 действуют, повторный запуск не требовался.
|
||||
|
||||
15. Rebase на актуальный master и повторная проверка
|
||||
|
||||
По запросу пользователя выполнен fetch origin/master:03b05094 → b0bf27de,23 upstream-коммита.
|
||||
Перед переносом40 локальных коммитов сохранена backup/messages-merkle-pre-rebase-6b8dd2ec.
|
||||
Конфликты только в doc/tasks.md:задачи upstream и актуальные локальные закрытия сохранены.
|
||||
Range-diff40/40 сопоставлен; три отличия касаются только контекста/объединения doc/tasks.md.
|
||||
Все22 локальных уникальных файла и28 upstream-уникальных файлов побайтно сохранены;
|
||||
общие файлы doc/tasks.md и tests/Makefile.am объединены,новые upstream-цели и wrappers присутствуют.
|
||||
Код и регрессии member/Merkle/history не изменились; DM/call/Android/desktop соответствуют upstream.
|
||||
Native build PASS; check.sh206/206 PASS,0 failed/0 skipped,STUN/stream PASS,
|
||||
proxy9/9,burst1/1,load8/8 (134217728 байт),exit0,197с.
|
||||
Санитайзеры после rebase заново не запускались; прежний профиль10/10 относится к разделу13.
|
||||
Логи /tmp/member-merkle-rebase-{build,check,range-diff,preservation}.log.
|
||||
Перед отправкой ls-remote подтвердил master=b0bf27de6188746e53ff436f64a24ec69789051a.
|
||||
+25
-7
@@ -1,7 +1,8 @@
|
||||
# Ссылки в сообщениях
|
||||
|
||||
Android и Qt распознают `http://`, `https://` и `utun://` в обычном тексте
|
||||
личных и групповых сообщений. HTTP/HTTPS нечувствительны к регистру схемы.
|
||||
личных и групповых сообщений. HTTP/HTTPS нечувствительны к регистру схемы;
|
||||
`utun://` распознаётся только в нижнем регистре. Утилиты разбора не открывают ссылки.
|
||||
Ссылки выделяются цветом и подчёркиванием; длинные веб-адреса переносятся по ширине.
|
||||
Сокращённая строка структурированного PM-приглашения сохраняет полный `utun://` адрес.
|
||||
|
||||
@@ -11,26 +12,43 @@ Android и Qt распознают `http://`, `https://` и `utun://` в обы
|
||||
Адреса без схемы не распознаются. Некорректные веб-адреса остаются обычным текстом.
|
||||
Исходное сообщение в БД и протоколе не меняется.
|
||||
|
||||
Разборщики возвращают диапазоны UTF-16 и полные адреса:
|
||||
У `utun://` распознаётся кандидат из обычного base64-алфавита с до двух конечных `=`.
|
||||
На этом этапе не проверяются версия, поля и содержимое приглашения: `utun://abc+/==`
|
||||
может получить подсветку, но быть отклонённым при открытии. Предпросмотр Qt и Android
|
||||
использует общий `src/chat/invite_link.c` через Qt-обёртку/JNI; принимается версия 0x03.
|
||||
Сам декодер не проверяет регистрацию/срок join_key и не даёт членства.
|
||||
|
||||
Разборщики возвращают непересекающиеся диапазоны исходного текста в порядке появления
|
||||
и полные адреса в исходном написании: Qt — `start/length`, Android — `start/end`,
|
||||
где end исключён. Единицы обоих API — UTF-16:
|
||||
`tools/chatgui/src/message_links.cpp`, Android `data/MessageLinks.kt`.
|
||||
Удаление внешней пунктуации занимает линейное время: баланс трёх видов скобок
|
||||
считается один раз, хвост сокращается по индексу без повторного копирования адреса.
|
||||
Порт веб-адреса пустой либо десятичный от 0 до 65535; знак перед числом недопустим.
|
||||
Подсветка и нажатие используют эти диапазоны. Android подставляет emoji placeholders
|
||||
только в обычный текст между ссылками: emoji внутри адреса остаётся частью полного URL.
|
||||
Qt использует один QTextLayout для размеров, отрисовки, выделения и координат нажатия.
|
||||
Перетаскивание выделяет текст без открытия ссылки.
|
||||
|
||||
HTTP/HTTPS открываются из GUI системным обработчиком: Android `ACTION_VIEW`,
|
||||
Qt `QDesktopServices::openUrl`. `utun://` запускает существующий JOIN.
|
||||
Qt `QDesktopServices::openUrl`. На нажатии веб-адрес снова проверяется.
|
||||
В Qt `utun://` сначала декодируется: уже существующая группа, где узел является
|
||||
участником, открывается без JOIN; иначе показывается JoinDialog с автоматической попыткой входа.
|
||||
На Android адрес передаётся в существующий invite/JOIN flow.
|
||||
Ошибки открытия показываются через перевод интерфейса и логируются.
|
||||
Диагностика открытия содержит схему и host, без полного пути/query.
|
||||
|
||||
Общие случаи: `tools/chatgui/tests/message_links_cases.tsv`.
|
||||
Qt `test_invite_links` проверяет разбор, перенесённые веб-ссылки, смесь схем,
|
||||
нажатие, drag/copy и сокращённые приглашения; `test_emoji_playback` — жесты приглашений.
|
||||
Android `tests/test_message_links.kt` проверяет тот же TSV на классах из APK-сборки:
|
||||
Android `MessageLinksTest` проверяет тот же TSV через Gradle, некорректные authority
|
||||
и длинный хвост пунктуации. Аналогичный тест длинного хвоста есть в Qt.
|
||||
Оба разбора не открывают ссылки; некорректный пользовательский текст не создаёт логов.
|
||||
|
||||
```bash
|
||||
cd tools/chatgui-android
|
||||
./build.sh noinstall
|
||||
kotlinc tests/test_message_links.kt -classpath app/build/tmp/kotlin-classes/debug -d /tmp/message-links-tests.jar
|
||||
kotlin -classpath /tmp/message-links-tests.jar:app/build/tmp/kotlin-classes/debug Test_message_linksKt ../chatgui/tests/message_links_cases.tsv
|
||||
./gradlew :app:testDebugUnitTest --tests com.utun.chat.data.MessageLinksTest
|
||||
```
|
||||
|
||||
`tests/test_message_links.kt` остаётся отдельным CLI-тестом того же TSV;
|
||||
ему передаётся путь к файлу, а в classpath нужны собранные классы `MessageLinks`.
|
||||
@@ -88,10 +88,61 @@ READY сессии. Принятая подписанная запись и пу
|
||||
доставка не создаёт дубликаты UDP/TCP. Работающие линки не закрываются только
|
||||
из-за смены списка адресов. NCD удерживает ссылку на общую запись, пока
|
||||
обслуживает соединение, и использует её при восстановлении.
|
||||
Для обхода адресов NCD делает отдельную копию: callbacks транспорта могут заменить
|
||||
списки в общем реестре до завершения обхода. Удержание ссылки на запись сохраняет
|
||||
сам объект, но не старые списки. Обновление, открытие и добавление нового сокета
|
||||
используют общий подбор разрешённых пар: family, UDP/TCP, локальная политика,
|
||||
NAT-профиль и обязательные метаданные REALITY. IPv6 link-local требует netif_index.
|
||||
Новый сокет обслуживает NCD-транспорты вне FIN_WAIT; случайный выбор одного адреса не используется.
|
||||
|
||||
`node_conn_direct_open_node` сверяет переданную запись с реестром/БД. Более
|
||||
свежая подписанная запись принимается целиком, старая уступает уже известной.
|
||||
Bootstrap-адреса не перекрывают подписанную запись даже при DOWN.
|
||||
ID узла проверяется по public_key. Ошибка запуска возвращает NULL handle и не снимает
|
||||
прежний FIN_WAIT. Отдельно принятый snapshot и уже добавленные линки при частичной
|
||||
ошибке могут сохраниться; новый handle публикуется после подготовки ресурсов.
|
||||
Явный specific_sock задаёт локальную политику, сохраняя проверки family/протокола/цели/REALITY;
|
||||
автоматическое появление сокета использует обычную политику подбора.
|
||||
|
||||
LAN discovery предоставляет отдельные адреса подключения и не меняет подписанный снимок.
|
||||
Открытое объявление рабочих сокетов и ответ на проверку имеют Ed25519-подпись;
|
||||
публичные ключи в этих пакетах отсутствуют. Получатель использует известный ключ
|
||||
из registry/SQLite, а неизвестное устройство остаётся только в списке discovery.
|
||||
Обычные объявления имени, сообщения и ACK не подписаны и не подтверждают личность.
|
||||
Общий UDP-порт 45731 принимает только объявления; unicast-обмен использует отдельные
|
||||
ephemeral-порты. Формат и API описаны в `src/local_transport/protocol.h` и `local_transport.h`.
|
||||
|
||||
Подписываются весь заголовок local_transport (magic/version/type, длина, IDs/эпохи,
|
||||
request=0) и тело: nonce(16), family(4/6), IP(16), количество сокетов,
|
||||
затем socket_id/protocol/port(BE)/generation(BE) каждого сокета. IPv4 дополнен
|
||||
нулевыми байтами. Подписанный IP обязан совпасть с источником UDP; рабочий порт
|
||||
отдельный от discovery-порта. Публикуются принимающие IP/interface обычные сокеты,
|
||||
без PRIVATE, probe и SOCKS UDP; до 32 endpoint на один IP, без фрагментации.
|
||||
|
||||
Объявление само не разрешает NCD использовать адрес. Только для узла без прямого UP
|
||||
и живого BGP-пути во всех группах получатель отправляет случайный nonce;
|
||||
подписанный ответ связывает его с обоими node_id и эпохами discovery. Ответ принимается
|
||||
однократно в течение 3 секунд. Это проверка свежести без часов/NTP: старые подписи,
|
||||
эпохи или случайные поколения сокетов сами по себе свежесть не доказывают.
|
||||
После проверки адрес действует 30 секунд по монотонным часам и только на интерфейсе
|
||||
приёма (включая IPv6 scope). Повтор объявления/ответа не продлевает срок без нового nonce.
|
||||
Дополнительно должен оставаться живым discovery-endpoint: только обычный ANNOUNCE
|
||||
продлевает его собственный TTL 30 секунд; MESSAGE/ACK и подписанный обмен этого не делают.
|
||||
Смена discovery-эпохи очищает endpoints и кандидаты. Проверка offline выполняется
|
||||
при обработке подписанного объявления/ответа; чтение кандидатов её не повторяет
|
||||
и само по себе не удаляет ранее проверенные адреса при появлении рабочего пути.
|
||||
Сверка интерфейсов и периодические объявления выполняются каждые 10 секунд,
|
||||
очистка TTL — по секундному tick или при запросе списка устройств/отправке сообщения.
|
||||
Отключение broadcasts сохраняет unicast-объявления и ответы, приём и сроки жизни;
|
||||
полная остановка discovery закрывает сокеты, завершает pending-доставки и очищает кандидаты.
|
||||
|
||||
NCD использует эти кандидаты при проверке совместимости, открытии/обновлении
|
||||
и появлении локального сокета. Если сохранены только ключи, идентичность загружается
|
||||
из nodes без требования старого адреса. LAN не создаёт NCD handle автоматически.
|
||||
Неподтверждённые ETCP-пробы очищаются после истечения кандидата/остановки discovery;
|
||||
после успешного handshake линк живёт по обычным правилам транспорта. REALITY требует
|
||||
метаданных из известного снимка; прямые LAN TCP-пробы при глобальном SOCKS выключены.
|
||||
Диагностика: discovery для подписи/nonce/адресов, etcp для NCD-проб и их удаления.
|
||||
|
||||
Конфигурационные подключения UTUN используют один адаптер
|
||||
`init_client_connections` для старта и reload. Все адреса, TCP и REALITY
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
Динамический пакетный драйвер ETCP
|
||||
================================
|
||||
|
||||
Потребитель: lib/packet_link.h. Экземпляр API драйвера: lib/packet_link_backend.h.
|
||||
Привязка к ETCP: src/transport_layer/etcp_link_io.h/c, etcp_connections.h.
|
||||
Все операции выполняются в одном uasync-потоке. Платформенные callbacks драйвер
|
||||
сам переводит в этот поток. Тип транспорта не определяет интерфейс доставки.
|
||||
|
||||
Использование
|
||||
-------------
|
||||
conn = etcp_connection_create(instance, "peer"); // пока без линков
|
||||
etcp_connection_set_peer(conn, peer_x25519_key); // проверить результат
|
||||
link = etcp_link_add(conn, &driver); // проверить результат
|
||||
|
||||
Драйвер создаёт вызывающий модуль. В packet_link_driver передаются ctx, ua,
|
||||
диагностическое name и функции bind/get_state/send/set_rx_enabled/stop/destroy.
|
||||
direction задаёт физическое направление (UNKNOWN/OUTGOING/INCOMING), копируется
|
||||
при bind и доступно через packet_link_get_direction. Оно не задаёт роль handshake
|
||||
верхнего протокола. name служит также подписью транспорта в диагностике;
|
||||
по его содержимому не выбираются кодирование или способ доставки.
|
||||
Один ctx обслуживает один двусторонний канал к пиру. Общий UDP-сокет или BLE-радио
|
||||
может принадлежать отдельному владельцу и обслуживать несколько экземпляров.
|
||||
Несколько разных драйверов добавляются в один conn отдельными вызовами link_add.
|
||||
Если conn уже настроен NCD, повторно set_peer вызывать не нужно.
|
||||
Наличие ключа ещё не доказывает владение им: ETCP проверяет защищённый handshake.
|
||||
Обнаружение ключа неизвестного пира не является частью пакетного драйвера.
|
||||
|
||||
Владение и события
|
||||
-----------------
|
||||
packet_link_bind копирует API, name, callbacks и снимок состояния. bind драйвера
|
||||
копирует таблицу обратных вызовов, заимствует arg и не выдаёт события синхронно.
|
||||
Успех передаёт ctx оболочке; отказ сохраняет его у вызывающего, без новой подписки.
|
||||
Один экземпляр допускает только одну привязку; повторный bind возвращает ошибку.
|
||||
Все потенциально неуспешные выделения оболочки делаются до bind.
|
||||
При отказе etcp_link_add соединение сохраняется, драйвер остаётся у вызывающего.
|
||||
|
||||
Первое уведомление состояния отложено. RX первоначально выключен; ETCP включает
|
||||
его после возврата link_add, при OPEN. OPEN драйвера означает готовность канала,
|
||||
а UP ETCP — результат своего handshake и подтверждения эпох сессии.
|
||||
|
||||
send принимает целый ll_entry вне очереди. OK передаёт владение entry и dgram.
|
||||
Любой отказ оставляет их вызывающему. Частичного принятия нет. Успех означает
|
||||
принятие ответственности, а не доставку удалённому приложению. Ошибка после
|
||||
принятия сообщается состоянием TERMINATED. Подтверждения ETCP сохраняются.
|
||||
|
||||
max_packet — максимум байтов целого пакета на границе драйвера, неизменный на время
|
||||
его жизни; в него входят защита и заголовки ETCP. ATT MTU сюда не подставляется:
|
||||
драйвер GATT сам фрагментирует принятый пакет. ETCP ограничивает свой wire-пакет
|
||||
1500 байтами и вычисляет полезную часть с учётом заголовков выбранного кодирования.
|
||||
|
||||
tx_ready сообщает о собственных ресурсах драйвера. Если собственной очереди нет,
|
||||
используется публичная готовность следующего модуля. Чужие буферы не инспектируются.
|
||||
WOULD_BLOCK не требует опроса: восстановление готовности даёт state_changed.
|
||||
Проверка готовности не заменяет результат send.
|
||||
|
||||
Драйвер может синхронно сообщить изменение состояния из send; оболочка сразу
|
||||
обновляет снимок, но откладывает/объединяет верхний state_changed. RX из send
|
||||
запрещён. received передаёт владение пакетом получателю; при отключённом RX драйвер
|
||||
сам ограничивает накопление и не выдаёт callback. Поле memlen содержит размер
|
||||
доступного dgram, len — размер данных. Оболочка очередей данных не создаёт.
|
||||
|
||||
close сразу отменяет уведомления и вызывает stop. stop запрещает дальнейшие
|
||||
события, но не освобождает ctx. Если платформенный API не позволяет закрытие
|
||||
из callback, драйвер откладывает физическое отключение после отмены подписки.
|
||||
destroy выполняется через call_soon после выхода обработчиков. До уничтожения
|
||||
uasync закрыть линки и выполнить drain. После close handle недействителен.
|
||||
TERMINATED необратим; CONNECTING→OPEN запускает handshake. Возврат внешнего канала
|
||||
в CONNECTING снимает готовность ETCP; последующий OPEN возобновляет handshake.
|
||||
|
||||
Доставка и кодирование
|
||||
----------------------
|
||||
ETCP_LINK владеет привязкой io. packet_link из get заимствован и отдельно не закрывается.
|
||||
Общий путь отправки/приёма вызывает кодирование через etcp_link_codec и доставку
|
||||
через API экземпляра драйвера. Кодирование не выбирается по имени драйвера.
|
||||
|
||||
etcp_link_get_snapshot копирует описание линка в uasync без callbacks и ссылок
|
||||
на ресурсы драйвера: подпись, физическое направление, состояние ETCP, IP-адреса
|
||||
и имя сокета, если они есть. Внешний пакетный канал имеет has_ip=0, имя/направление
|
||||
берутся из packet_link. IP-путь сохраняет подписи UDP/TCP/TCP+REALITY и свои адреса.
|
||||
Desktop MEMBER_DETAIL и Android JNI JSON сериализуют один и тот же снимок.
|
||||
|
||||
etcp_link_add использует защищённые датаграммы ETCP без IP-заголовков. Ключ пира
|
||||
уже задан в conn. Приём привязан к каналу, поэтому адресный bootstrap, NAT и поиск
|
||||
сокета не нужны. INIT и согласование эпох используют существующие форматы ETCP.
|
||||
|
||||
Существующий etcp_link_new — фабрика IP-путей: подготавливает UDP/STCP-драйвер
|
||||
и подключает его через тот же packet_link_bind. Адреса, listener, reconnect,
|
||||
SOCKS и REALITY остаются в IP-привязке. Прежние wire-форматы сохранены:
|
||||
UDP получает зашифрованную датаграмму, STCP — plaintext для собственного framing.
|
||||
Неизвестный UDP endpoint проходит прежний bootstrap; известный — callback драйвера.
|
||||
|
||||
UDP сначала пробует sendto; EAGAIN сохраняет одну датаграмму. writable сокета
|
||||
возобновляет передачу; SOCKS ожидает relay ready без EPOLLOUT-цикла.
|
||||
STCP разрешает один ожидающий пакет в TX адаптера. Нижний STCP извлекает следующий
|
||||
после освобождения собственной досылки. RX сохраняет прежние лимиты/паузы TCP.
|
||||
DATA, ACK и измерительные серии проверяют готовность. ACK и служебные признаки
|
||||
фиксируются после принятого send; при отказе остаются для повторной отправки.
|
||||
|
||||
Проверки и диагностика
|
||||
---------------------
|
||||
test_packet_link: владение, TX blocked/resume, объединённые события, RX pause,
|
||||
закрытие из callbacks, отмена событий и отсутствие утечек.
|
||||
test_etcp_packet_link: UDP EAGAIN, два пути общего сокета, ACK rollback/commit.
|
||||
test_etcp_driver: пустые conn, отказ bind без потери владения, копирование API,
|
||||
два ядра без сокетов, защищённый INIT/DATA, сообщения 1500 байт, backpressure,
|
||||
независимые драйверы и освобождение ресурсов.
|
||||
Существующие проверки UDP/STCP/session сохраняют прежние ожидания.
|
||||
sys=debug показывает состояние packet_link, etcp=debug — привязку/протокол.
|
||||
+23
-2
@@ -1,4 +1,4 @@
|
||||
Работа лички
|
||||
Работа лички — требования и реализация
|
||||
|
||||
1. отдели логику лички от логики групповых чатов. не смешивай.
|
||||
|
||||
@@ -9,4 +9,25 @@
|
||||
время хранения медиафайлов на промежуточных узлах ограничено - 1 неделя по умолчанию (задачется в конфиге этих узлов).
|
||||
для доставки медиа поднимаем линк (conn_mgr).
|
||||
|
||||
и посмотри реализацию медиа для групповых чатов.
|
||||
Контракты реализации проверены 27.10.2026: src/dm/ и doc/dm_arch.md.
|
||||
|
||||
«Напрямую» означает доставку endpoint через живой маршрут общей CHAT-группы,
|
||||
в том числе с BGP-транзитом; физический линк между A и B не обязателен.
|
||||
Беседа имеет собственные историю/seq/outbox и не создаёт отдельную TOPO_GROUP.
|
||||
Общие с групповыми вложениями примитивы: media_async, шифрование потоков,
|
||||
attachment_info, voice_file; DM не использует групповой HAVE_BLOCK и его хранилище.
|
||||
|
||||
S выбирается по подписанному adm_tags supernode=yes, M — storage=yes в общей группе A/B.
|
||||
Роли независимы и могут совмещаться на одном узле. A сохраняет исходящую задачу
|
||||
до подписанной квитанции B; STORED от S/M не означает доставку получателю.
|
||||
Текстовая квитанция подтверждает commit сообщения, файловая — проверку ciphertext,
|
||||
публикацию файла и сохранение результата. S повторяет удаление у M до DELETE_ACK
|
||||
либо завершения подтверждённого срока назначения; сохраняются служебные tombstones.
|
||||
|
||||
На M настройки чата dm_media_ttl_sec=604800 и dm_media_storage_mb=1024 задают TTL
|
||||
в секундах и квоту в МиБ. Повтор STORE не продлевает TTL; при нехватке квоты новое
|
||||
размещение отклоняется, подтверждённая копия не вытесняется до срока.
|
||||
Текстовые pending не имеют TTL. Истечение копии M не удаляет оригинал A и не мешает
|
||||
B получить его после возвращения A; локальное удаление истории не отзывает копии S/M.
|
||||
|
||||
Подробные wire-форматы, жизненный цикл и проверки: doc/dm_arch.md и src/dm/*.h.
|
||||
+19
-2
@@ -40,9 +40,21 @@ ID получателя, выбранную группу и исходную о
|
||||
Повтор во время ожидания возвращает BUSY. Закрытие диалога отменяет только
|
||||
соответствующий `request_id`; уже отправленное сообщение не отзывается.
|
||||
Отмена и остановка DM завершают принятый запрос ровно один раз.
|
||||
Отмена уже завершённого request_id ничего не отправляет и не создаёт повторный результат.
|
||||
Отмена касается ожидания PM, уже зарегистрированный JOIN-ключ живёт по своему TTL.
|
||||
|
||||
`CHAT_EVT_DM_INVITE_RESULT` содержит 28 байт:
|
||||
`[request_id:u64][result:i32][peer:u64][conv_id:u64]` в порядке байтов ядра.
|
||||
Запрос — один обнулённый блок `u_calloc(dm_invite_req)` с NUL-строками;
|
||||
после передачи в uasync им владеет обработчик до финального callback.
|
||||
Instance, chat_sync и SQLite должны оставаться живыми до завершения отмены.
|
||||
`dm_core_destroy` отменяет ожидающий invite до освобождения owner.
|
||||
Поколение беседы захватывается перед регистрацией и проверяется перед `dm_send`:
|
||||
удалённая и повторно открытая PM не принимает старый результат.
|
||||
Создание/открытие беседы происходит до регистрации; ошибка регистрации или отправки
|
||||
не откатывает это открытие, но не создаёт invite-сообщение.
|
||||
|
||||
`CHAT_EVT_DM_INVITE_RESULT` содержит 36 байт:
|
||||
`[request_id:u64][result:i32][peer:u64][conv_id:u64][generation:u64]` в порядке байтов ядра.
|
||||
Поколение читается при завершении запроса и позволяет сопоставить результат с состоянием беседы.
|
||||
Desktop и Android сопоставляют результат с запросом и получателем.
|
||||
Ошибки регистрации или сохранения сообщения оставляют диалог открытым для повторной попытки.
|
||||
|
||||
@@ -52,6 +64,11 @@ JOIN во вторую группу может использовать уже
|
||||
|
||||
## Проверка и диагностика
|
||||
|
||||
Контракт сверён 27.10.2026 с DM, регистрацией JOIN-ключей и обработчиками результата
|
||||
Qt/Android. Свежий `test_dm_invite` прошёл вместе с остальными восемью DM-тестами.
|
||||
Приведённые ниже GUI-тесты описывают существующее покрытие; в этом этапе новые
|
||||
сборки Qt/Android и интерактивная проверка интерфейса не выполнялись.
|
||||
|
||||
- `tests/test_dm_invite`: настоящая UDP-доставка ссылки в другую группу, JOIN,
|
||||
репликация участника и отправка группового сообщения после вступления;
|
||||
отмена, BUSY, неверная группа, rollback при ошибке commit и остановка DM;
|
||||
|
||||
+214
-13
@@ -22,17 +22,47 @@ utun_instance_destroy(inst);
|
||||
```
|
||||
|
||||
`utun_instance_init()` — сокращение для запуска ядра, UTUN и чата, если чат включён
|
||||
в конфигурации. Desktop GUI запускает ядро, UTUN и чат явно; TUN создаётся при
|
||||
`[global] tun_enabled=yes`. Без TUN и NAT запуск чата не требует прав администратора.
|
||||
Desktop подтверждает start только после готовности всех сервисов и uasync poll;
|
||||
ошибка TUN откатывает запуск и возвращается в GUI. Android запускает ядро и чат без UTUN.
|
||||
в конфигурации. Desktop и Android GUI по умолчанию запускают ядро и чат.
|
||||
`[gui] utun_enabled=yes` явно добавляет UTUN-сервис; `[global] tun_enabled=yes`
|
||||
управляет только созданием TUN внутри включённого UTUN. Отсутствующий GUI-флаг означает no.
|
||||
Без TUN и NAT запуск чата не требует прав администратора.
|
||||
Desktop подтверждает start после готовности всех сервисов и медиапривязок, перед входом в uasync poll;
|
||||
ошибка запрошенного TUN откатывает запуск и возвращается в GUI. Android сохраняет тот же выбор при рестарте.
|
||||
Повторный start работающего сервиса и повторный stop допустимы. Ошибка старта
|
||||
сервиса откатывает созданные им ресурсы. `utun_instance_stop()` останавливает цикл
|
||||
UTUN/чата откатывает созданные им ресурсы; ошибка core start оставляет подготовленные
|
||||
ресурсы для retry/destroy. `utun_instance_init()` сохраняет ранее запущенные сервисы
|
||||
при отказе последующего. `utun_instance_stop()` снимает `running` и будит цикл
|
||||
приложения; освобождение ядра выполняет `utun_instance_destroy()`.
|
||||
|
||||
Новый старт чата требует SQLite без внешней транзакции до подготовки db_sync/listeners.
|
||||
chat_core всегда заимствует DB ядра. Схема,локальная идентичность и media_base
|
||||
сохраняются под BEGIN IMMEDIATE;обе подписки member properties/routes устанавливаются
|
||||
до commit и снимаются при отказе. Ошибка сохраняет DB/чужих подписчиков;no-ready events.
|
||||
Далее готовятся сохранённые каналы. Отказ активации оставляет committed метаданные,
|
||||
а chat_service_stop убирает частичный runtime;повтор start повторяет активацию.
|
||||
MY_NODE_ID/DB_READY публикуются после обеих фаз chat_core,без активного SQL cursor.
|
||||
Это готовность chat_core,а не подтверждение запуска всех следующих частей чат-сервиса.
|
||||
Тестовые Alice/Bob/etc в production accounts больше не создаются;существующие записи сохраняются.
|
||||
|
||||
Desktop и Android проверяют `instance->running` вместе со своим флагом остановки,
|
||||
включая Android-цикл после restart. Запрос из callback завершает цикл после возврата
|
||||
из текущего poll; уже начатая обработка остальных callbacks этого poll не прерывается.
|
||||
Владелец затем снимает media-привязки, уничтожает ядро и освобождает uasync.
|
||||
Это используется также при отказе rollback подготовки вложения, приёма REQUEST, решения автора или ответа о ключе, если SQL-транзакция
|
||||
осталась открыта: модуль вложений предварительно прекращает приём и снимает свой таймер/router.
|
||||
|
||||
Create уже подготавливает listeners, топологию/SQLite, router/proxy и мониторинг;
|
||||
core/UTUN/chat запускаются отдельно. `create_from_str` использует временный файл
|
||||
в `/tmp`, updater и parse. `create_from_config` передаёт владение config только
|
||||
при успехе; при отказе config остаётся caller, но node_id мог быть исправлен.
|
||||
Logger общий для процесса, его изменения не откатываются при отказе создания.
|
||||
|
||||
Для TUN в desktop `vibechat.cfg` задаются те же параметры, что и у CLI:
|
||||
|
||||
```ini
|
||||
[gui]
|
||||
utun_enabled=yes
|
||||
|
||||
[global]
|
||||
tun_enabled=yes
|
||||
tun_ifname=vibechat
|
||||
@@ -63,14 +93,48 @@ ConnectivityManager до запроса запуска и передаёт ко
|
||||
должны открыться полностью. Android подтверждает запуск по `SERVICE_STARTED`, а не
|
||||
по успешному созданию потока; после события передаётся свежий снимок адресов.
|
||||
|
||||
Android `instance_lite` владеет joinable worker с момента `pthread_create`, включая фазу до появления
|
||||
`UTUN_INSTANCE`. Все выходы worker проходят через общую очистку; завершившийся поток собирается при stop
|
||||
или перед следующим start. Stop снимает готовность, будит живой uasync и ждёт cleanup/join без detach;
|
||||
ожидание дольше двух секунд даёт предупреждение, ресурсы сохраняются до завершения. Controller API
|
||||
из callback worker отклоняется до захвата блокировки. Только успешный join разрешает освобождение
|
||||
recorder, C callbacks и JNI global refs; ошибка возвращается frontend без освобождения этих ресурсов.
|
||||
JNI init регистрирует callbacks/recorder; SQLite открывает только ядро. `ChatRepository` получает данные
|
||||
через JNI, предварительное создание `chats.db` в init не требуется.
|
||||
`NativeLib.stop/destroy` снимает media-владельцев на main и ждёт native worker на IO. `HeadlessService`
|
||||
передаёт остановку в общий `CoreShutdown`, который переживает onDestroy; новый Service ждёт его до init.
|
||||
Отказ stop повторяется автоматически с backoff 250 мс..30 с, без нового Service/request.
|
||||
Отмена одного ожидающего Service не отменяет очистку. AudioRecorderManager запрашивает cleanup вне main,
|
||||
удерживает AudioRecord/AudioTrack и decoder handles до подтверждённого release, включая ViewModel.onCleared.
|
||||
NativeLib не разрушает recorder до завершения этих владельцев; во время teardown новый capture запрещён.
|
||||
Подготовка звонка также ждёт voice cleanup, а отмена звонка не отменяет это ожидание.
|
||||
Диагностика worker не считает живые ресурсы
|
||||
frontend утечками: recorder освобождается bridge после остановки ядра.
|
||||
|
||||
| Владелец | Ресурсы |
|
||||
|---|---|
|
||||
| Ядро | Идентичность узла, реестр узлов и групп, SQLite, транспортные сокеты, NCD, маршрутизатор, общая репликация, время, мониторинг |
|
||||
| UTUN | Группа UTUN, запросы соединений из конфигурации, DATA binding, TUN, системные маршруты, NAT transport |
|
||||
| Ядро | Идентичность узла, реестр узлов и групп, SQLite, сокеты, NCD и постоянные транспорты `[client]`, маршрутизатор, репликация, время, мониторинг |
|
||||
| UTUN | Группа UTUN, независимые запросы участия к настроенным пирам, DATA binding, TUN, системные маршруты, NAT transport |
|
||||
| Чат | CHAT-группы, запросы подключения и join, экземпляры синхронизации каналов, chat API, медиа, личные сообщения, звонки и headless API |
|
||||
| Группа | Сессии пиров, её маршруты и логические каналы, планировщик подключения и recovery |
|
||||
| Сессия пира группы | NCD handle с момента CONNECTING, согласованные эпохи, состояние обмена таблицей, очередь отправки |
|
||||
|
||||
`[client]` открывает транспорт без вступления в UTUN. CHAT согласуется только при наличии
|
||||
членства в соответствующем канале. Входящий JOIN отсутствующей группы отклоняется.
|
||||
UTUN stop закрывает свои групповые запросы, но сохраняет постоянные транспорты ядра.
|
||||
Запись настроенного пира владеет копией параметров и адресов; DOWN/TIMEOUT сохраняет NCD,
|
||||
окончательный CLOSED запускает повтор через секунду. Удаление записи отменяет её повтор
|
||||
и освобождает только её handles. Ошибка подготовки нового списка сохраняет прежний список.
|
||||
Изменение автосокетов переприменяет сохранённые endpoints также для bootstrap без подписанного NODEINFO.
|
||||
Полный reload сохраняет набор запущенных сервисов и running; частичный обновляет `[client]` также без UTUN.
|
||||
Restart нужен при смене негорячих global-настроек (в том числе db_path, control,
|
||||
TUN/NAT/proxy), подсетей либо server-конфига в automatic режиме. В ручном режиме
|
||||
сравниваются все параметры endpoint и IPv6 scope; неизменённый port=0 сохраняет
|
||||
порт ОС. TCP endpoint закрывается вместе с listener и своей TCP-моделью.
|
||||
Ошибка подготовки сохраняет прежний instance; ошибка создания после полного destroy
|
||||
возвращает NULL. Частичный reload не атомарен: отказ нового endpoint логируется,
|
||||
новый config сохраняется для повторной попытки. Caller всегда использует результат reload.
|
||||
|
||||
Proxy настраивается при создании ядра. Instance владеет общими UDP/ICMP-контекстами;
|
||||
TCP client/server владеют только своими bindings, listeners и TCP-потоками.
|
||||
Общие контексты нужны для exit либо клиентского TUN; SOCKS/HTTP без TUN их не требуют.
|
||||
@@ -81,9 +145,34 @@ TCP client/server владеют только своими bindings, listeners
|
||||
UTUN/CHAT-групп и транспорта. Waiters/таймеры снимаются до освобождения их владельцев.
|
||||
Остановка отдельного TCP proxy-модуля не уничтожает общие UDP/ICMP-контексты.
|
||||
|
||||
Команды JOIN из Qt/JNI используют удерживаемый `chat_join_port`: frontend копирует ссылку,
|
||||
а состояние чата читает только callback в uasync. Close закрывает приём под mutex, отменяет
|
||||
ожидающие команды и отделяет порт от instance. Ссылка frontend остаётся безопасной после stop;
|
||||
новый запуск публикует новый порт. Команды старого запуска не обращаются к новому ядру.
|
||||
При stop чата порт и активная попытка закрываются до ожидания media workers. В принятой команде
|
||||
есть отдельный request_id интерфейса; сетевой JOIN сопоставляется по peer/group/attempt_id/phase.
|
||||
Результат публикуется после очистки состояния и собственного NCD handle, поэтому callback может
|
||||
начать следующий JOIN. Отмена ожидания не откатывает уже сохранённое удалённое членство.
|
||||
|
||||
Кеш каналов/пиров chat_sync публикуется полным снимком; ошибка чтения или памяти сохраняет
|
||||
предыдущий кеш. Обязательные подписки и первый таймер проверяются при запуске. Удаление
|
||||
канала снимает его waiters, предложения и активный JOIN до уведомления об отмене.
|
||||
При выборе invite все кандидаты берутся из полной таблицы участников и требуют живого пути
|
||||
(self также должен иметь полную запись); ошибки SQLite не подменяются выбором self.
|
||||
|
||||
SQLite открывается ядром один раз: `db_path/chats.db`, либо `:memory:`, если путь
|
||||
не задан. Остановка чата сохраняет каналы и сообщения в этой БД. В варианте
|
||||
`:memory:` данные сохраняются только до уничтожения ядра.
|
||||
Caller завершает заимствованные SQLite statements/backups до destroy. Ошибка close
|
||||
логируется; close_v2 при BUSY откладывает освобождение БД до завершения этих объектов.
|
||||
UASYNC живёт до конца destroy. Отменённые heap-таймеры могут оставаться в нём
|
||||
до следующего poll; это не разрешает callback обращаться к освобождённому instance.
|
||||
|
||||
Headless chat/call/radio bind задаёт IPv4 literal:port, decimal 0..65535;
|
||||
port=0 выбирает ОС. Ошибка bind/listener не останавливает чат и не открывает
|
||||
порт по умолчанию. Stop снимает клиентов с UASYNC, отменяет cleanup/mix-таймеры,
|
||||
освобождает headless-контексты и кодеки. PCM после 11-байтового заголовка
|
||||
копируется в выровненный буфер перед Opus.
|
||||
|
||||
Работающий общий транспорт сохраняется, пока он нужен хотя бы одному из пиров. Закрытие запроса одной группы
|
||||
не разрывает соединение, нужное другой группе. При остановке группы отменяются
|
||||
@@ -107,6 +196,21 @@ KEEP_ALIVE полностью отменяет FIN_WAIT. Ожидание CLOSE
|
||||
для спящего пира увеличивается до его RX deadline. Сам факт DOWN во время FIN_WAIT
|
||||
завершает ранее запрошенное мягкое закрытие.
|
||||
|
||||
NCD возвращает новый handle после подготовки подписки на все события ETCP и нужных
|
||||
таймеров. До возврата open новый владелец событий не получает; уже работающий
|
||||
транспорт доставляет начальный UP через call_soon. Отказ подписки, таймера или
|
||||
планирования UP возвращает ошибку и сохраняет прежних владельцев и FIN_WAIT.
|
||||
TIMEOUT не закрывает удерживаемый транспорт: возможен поздний UP, новое open может
|
||||
начать новый бюджет подключения. CLOSED отменяет таймеры, отсоединяет conn и требует
|
||||
закрыть оставшийся handle; его можно закрыть после уничтожения ядра.
|
||||
Операции открытия, обновления линков и закрытия удерживают NCD/ETCP через вложенные
|
||||
callbacks, включая синхронный callback исходящей очереди при отправке CLOSE.
|
||||
Более новый вложенный переход прекращает оставшуюся рассылку прежнего события.
|
||||
Управляющий пакет NCD занимает ровно 10 байт: cmd=0x12, subcmd и node_id little-endian.
|
||||
Приём проверяет длину, команду и принадлежность отправителя тому же транспорту.
|
||||
Отказы ресурсов, вложенные события, обновление snapshot и удаление соседних линков
|
||||
проверяет `test_ncd_faults`; готовый handle, TIMEOUT и сетевой CLOSE/KEEP_ALIVE — `test_node_conn_direct`.
|
||||
|
||||
Контроль RX независим от отправки keepalive. Объявленный сон не отключает проверку
|
||||
жизни: срок от последнего RX равен максимуму согласованного keepalive timeout и
|
||||
объявленного остатка ACTIVE+SLEEP с запасом normal timeout. Фазовые таймеры и наши
|
||||
@@ -120,6 +224,10 @@ KEEP_ALIVE полностью отменяет FIN_WAIT. Ожидание CLOSE
|
||||
закрывает медиа и сервисы, синхронизацию и группы, после чего освобождает chat core.
|
||||
`media_async_destroy()` дожидается завершения native workers и вызывает каждый
|
||||
ожидающий callback с `MEDIA_ASYNC_CANCELLED`, пока его контекст ещё существует.
|
||||
Whisper хранит модель и одну активную задачу отдельно для каждого экземпляра.
|
||||
Запрос загружает модель в worker,повтор после отказа разрешён;при занятости приходит BUSY.
|
||||
До ожидания workers `chat_whisper_destroy()` отсоединяет владельца и запрещает публикацию
|
||||
результата. Занятая модель освобождается в completion после join;глобального триггера нет.
|
||||
Worker резервирует completion до запуска и передаёт его через `uasync_post_reserved`;
|
||||
периодического polling нет. Destroy выполняется вне callbacks uasync, после join
|
||||
отменяет pending post и завершает done ровно один раз. Uasync живёт до конца этой очистки.
|
||||
@@ -142,11 +250,25 @@ completion. Отказ запуска/неверные аргументы даю
|
||||
`encrypted_file_write/read` только готовят закрытый durable temp; worker публикует
|
||||
его одним rename, completion подтверждает результат в БД/UI. Отмена до completion
|
||||
не публикует сообщение; cleanup учитывает реально созданные/заменённые файлы.
|
||||
Модуль канальных вложений отдельно владеет списком до16 файлов: worker либо
|
||||
завершение, ожидающее SQL commit. Захват decrypting фиксируется до worker;
|
||||
ошибка записи результата сохраняет задачу для следующего tick без новой криптографии.
|
||||
Quiesce останавливает эти повторы. После media_async_destroy chat_attachment_destroy
|
||||
освобождает оставшиеся завершения до удаления chat_core; внешняя транзакция
|
||||
откладывает запись, незавершённый rollback запрашивает штатную остановку владельцем.
|
||||
Ошибка синхронизации каталога после rename означает установленное имя без подтверждённой
|
||||
durability; отложенный discard уже не удаляет результат. Общей транзакции ФС/БД нет.
|
||||
До запуска workers и после их завершения recovery очищает собственные stale temp
|
||||
в `media/decrypted`; общий file_io-реестр сохраняет активные temp всех instance.
|
||||
|
||||
Две подписки backfill устанавливаются вместе: отказ любой отменяет частичный запуск
|
||||
и возвращает ошибку chat_service_start. JSON медиа читается единым способом для live,
|
||||
manual, startup и кеша; manifest проверяется целиком до использования блоков.
|
||||
Очистка сирот и удаление по квоте требуют полного снимка ссылок: ошибка SQL/памяти
|
||||
отменяет удаление. Один путь учитывается один раз; расшифрованные производные файлы
|
||||
также входят в квоту. Локальные отметки сообщений изменяются отдельным проверяемым
|
||||
commit; played задаётся через JSON без ограничения прежним буфером 256 байт.
|
||||
|
||||
`media_delivery_init()` вызывается после подготовки chat core и media_index, пока
|
||||
общая БД и группы живы; подписка на роли обязательна. Репликация хранит отдельный
|
||||
контекст и CM handle для каждой пары «CHAT-группа–пир». DOWN отменяет текущий RPC,
|
||||
@@ -220,9 +342,17 @@ UI, ожидающие команды camera handler и каждая camera sess
|
||||
TextureView возвращает false при destroy и снимает ссылку UI; Surface/SurfaceTexture освобождаются
|
||||
после закрытия последней session. CameraDevice.onClosed снимает также оставшиеся ссылки своих
|
||||
sessions, если Android не прислал отдельные session.onClosed. Повторные callbacks безопасны.
|
||||
Capture stop запрещает attach и снимает своё владение target; camera HandlerThread завершается
|
||||
после camera close и освобождения outputs всех созданных sessions.
|
||||
Отказ preview output возвращает encoder-only session и не завершает сам эфир.
|
||||
Capture создаётся пассивно: сервис сохраняет ссылку до start. Stop запрещает attach и снимает своё владение target;
|
||||
true возвращается после camera/session ACK, encoder/input Surface, ориентации, microphone subscription и join Opus worker.
|
||||
До этого false оставляет engine у сервиса. Ошибки освобождения повторяются независимо; camera HandlerThread живёт до полного cleanup.
|
||||
Таймаут camera close только логирует задержку SDK: encoder/Surface остаются до подтверждённого закрытия камеры.
|
||||
Opus worker сохраняет свой handle до успешного close, также после ошибки discontinuity/recreate.
|
||||
Отказ настройки preview до SDK submission или onConfigureFailed возвращает encoder-only session.
|
||||
Исключение createCaptureSession сохраняет outputs до callback сессии или закрытия устройства; допускается восстановление прошлого режима.
|
||||
Foreground-сервис сохраняет engine, CPU lock, токены и foreground/stopSelf до подтверждения каждого cleanup API, включая onDestroy.
|
||||
FIN следует после capture.stop=true; нормальная остановка ждёт native END до 4 секунд, abort/destroy не ждут доставки.
|
||||
Handler повторяет ошибки без переноса ближайшего retry при повторном shutdown. Publisher barrier запрещает native teardown
|
||||
также при незавершённом foreground handshake. Старый notification token не останавливает новый session ID.
|
||||
При stop/destroy отменяются delivery и media-index callbacks до освобождения store;
|
||||
destroy отсоединённого cursor не обращается к store. Frontend прекращает audio callback
|
||||
и worker до освобождения своего DSP; borrowed handle/RS pointers между потоками не передаются.
|
||||
@@ -237,7 +367,7 @@ VIDEO_END/AUDIO_END следуют за последними пакетами с
|
||||
Callback готовности только будит desktop worker и не вызывает API ядра/bridge. Stop снимает callback
|
||||
под mutex viewer до освобождения его контекста; detach будит ожидающие workers до уничтожения manager.
|
||||
На Android signal принадлежит audio worker и живёт до stream_stop, join video worker и close mixer input;
|
||||
ядро только уведомляет его. Surface освобождается после join video worker.
|
||||
ядро только уведомляет его. Surface освобождается после join video worker и подтверждённого decoder release.
|
||||
В wait не удерживаются mutex bridge/ядра. Расход PCM будит тот же signal, уведомления сливаются без потери пробуждения.
|
||||
GUI и audio callback будят worker при движении часов/освобождении места; сна между готовыми пакетами нет.
|
||||
Каждый reader имеет отдельные audio/control и video workers. Audio worker читает батчи и готовит
|
||||
@@ -313,6 +443,16 @@ Fatal ошибка audio worker передаётся в GUI с session ID и з
|
||||
не завершает новую сессию. Причина ошибки сохраняется в окне после окончания drain.
|
||||
|
||||
Desktop-звонок и рация проверяют поступление корректного PCM, включая первый callback.
|
||||
Заимствованная регистрация `call_set_audio_ops` принадлежит `UTUN_INSTANCE`; stop/start чата её сохраняет.
|
||||
`call_audio_init/destroy` выполняются в uasync: init допустим до старта чата, повтор того же владельца
|
||||
сохраняет эпоху и занятые TX-слоты. Перед освобождением ядра frontend останавливает I/O и снимает привязку;
|
||||
codec/jitter освобождаются отдельно через `call_audio_stop/release` после остановки устройства.
|
||||
Android с `UTUN_WITH_VOICE` регистрирует voice в worker до SERVICE_STARTED и снимает привязку перед destroy
|
||||
при stop/restart и ошибке частичного запуска. Main вызывает только start DSP; без живой привязки start отклоняется.
|
||||
NativeLib перед stop/restart сбрасывает CallController на main и требует подтверждённую очистку CallService,
|
||||
а также завершение mediaPreparation. Отказ stop API не разрешает разрушить используемое ядро;
|
||||
Handler остаётся доступен для повторной очистки, следующий stop/restart повторяет проверку.
|
||||
Диагностика и проверки исправления — разделы18–19 `doc/call_audit_findings.txt`.
|
||||
Секунда без данных отключает TX и фиксирует понятную ошибку в GUI; цифровая тишина является данными.
|
||||
RX продолжает работать, рация снимает PTT, автоматического переоткрытия неисправного входа нет.
|
||||
Выбор другого микрофона или новая сессия снимают ошибку. Неисправное устройство закрывается
|
||||
@@ -380,8 +520,57 @@ GUI не уничтожает живой QProcess. При отказе новы
|
||||
wake lock: перед исходящим вызовом или принятием входящего запускается foreground
|
||||
режим `phoneCall|microphone`, затем сигналинг и аудио. Завершение сначала
|
||||
останавливает I/O и дожидается аудиопотоков, затем освобождает native-аудио,
|
||||
wake lock и сервис. Перед `NativeLib.stop/restart/destroy` выполняется тот же
|
||||
порядок; новый звонок разрешается после `SERVICE_STARTED` нового ядра.
|
||||
wake lock и сервис. Ошибочные операции сохраняют владельцев и повторяются независимо
|
||||
с задержкой 250 мс..30 с; process-ссылка удерживает их также после onDestroy.
|
||||
Успешный Deferred от shutdown подтверждает stop API audio/video и очистку receiver,
|
||||
wake lock, токенов, foreground/stopSelf. Сервис сохраняет пассивный video engine до его start.
|
||||
Video stop возвращает true после RX join/освобождения decoder, playout и Surface,
|
||||
SDK-подтверждений camera/session, освобождения Image/readers/encoder и native close.
|
||||
Пока TX выполняет cleanup, false оставляет engine у сервиса и Deferred незавершённым.
|
||||
Ошибки RX не прерывают независимую очистку TX; native port остаётся до подтверждения обоих.
|
||||
onConfigureFailed означает закрытую сессию без обязательного onClosed. При исключении
|
||||
создания сессии выходы сохраняются до callback сессии или подтверждения закрытия камеры.
|
||||
Codec и input Images с отказавшим release сохраняются также при ошибке конструктора/probe;
|
||||
их отдельный retry использует только Android-ресурсы и не обращается к native-ядру.
|
||||
RX stop ограничивает join 250 мс за попытку; ошибка release сохраняет decoder,
|
||||
его Surface не освобождается до codec. Прочие Surface и playout очищаются независимо.
|
||||
Process-ссылка и Handler повторяют cleanup после выхода worker; поздние Surface также
|
||||
удерживаются до успешного callback release. Исключение callback требует повторяемого
|
||||
освобождения у передавшего владельца. Неудачный RX stop запрещает закрывать native port.
|
||||
Отказ поста или join оставляет stop повторяемым. Отмена Deferred не снимает владельцев.
|
||||
Audio stop ждёт каждый worker до 250 мс за попытку; playback включает join reporter.
|
||||
Main не ждёт device locks, занятые SDK-вызовами workers: пропущенные stop/pause
|
||||
логируются, а живые workers сохраняются для следующей попытки. Маршрут и замер AEC
|
||||
повторяются на main после освобождения lock только для прежнего активного call ID.
|
||||
Живые workers и устройства с отказавшим release остаются у движка, native audio stop
|
||||
откладывается до завершения потоков, освобождения устройств, communication listener,
|
||||
router и восстановления собственного аудиорежима. Незавершённая очистка бросает ошибку сервису;
|
||||
новый start сначала обязан завершить прежний stop. Проверка состояния/настройка нового
|
||||
AudioRecord/AudioTrack выполняется после сохранения владельца для rollback.
|
||||
Router сохраняет callback/proxy до успешного unregister/close, собственные глобальные
|
||||
заявки communication device/SCO — до подтверждённого снятия. Отказ одной операции не
|
||||
прерывает другие. Process-ссылка и main Handler повторяют очистку через 250 мс..30 с;
|
||||
отказ post сохраняет владельца для явного stop/start. Поздние Bluetooth proxy после stop
|
||||
получают тот же контракт освобождения, не меняя новый активный маршрут. До доставки
|
||||
callback принятый getProfileProxy не даёт router объект, который можно закрыть.
|
||||
Прямое превью использует отдельные идемпотентные ссылки UI/команды/camera session
|
||||
на один Surface/texture. Последний release передаёт физическую очистку process-владельцу;
|
||||
ошибки Surface и texture повторяются независимо без пропуска другой операции.
|
||||
Предложенная listener ссылка отменяется при исключении, queued receiver игнорирует
|
||||
released target. Закрытие preview не допускает последующей активации через update.
|
||||
Viewer трансляции хранит decoder/DSP/signal до успешного cleanup; ошибки освобождения
|
||||
повторяются его audio worker независимо, Surface callbacks выполняются после подтверждения всех владельцев.
|
||||
GAP сохраняет следующий KEY из того же batch. Ошибка reset не допускает новой шкалы поверх старого DSP.
|
||||
Общий mixer сохраняет AudioTrack при ошибке initialize/play/close и не открывает следующий выход до ACK.
|
||||
Join viewer/video/mixer перед звонком ограничен 250 мс за попытку; незавершённая очистка запрещает передачу ресурсов.
|
||||
Перед native teardown все viewers получают stop, затем проверяются их реестр и завершение общего mixer worker.
|
||||
Routing listeners после неудавшегося attach/detach сохраняются отдельно от активных outputs
|
||||
до подтверждённого удаления; поздние callbacks не обращаются к released track.
|
||||
Общая MediaAudioSession при close блокирует новых потребителей и оставляет executor
|
||||
до подтверждённой очистки hardware/router; отказ допускает повторный close.
|
||||
Перед `NativeLib.stop/restart/destroy` проверяется подтверждение очистки сервиса и
|
||||
окончание подготовки чужого media; ошибка блокирует native stop. Новый звонок
|
||||
нового ядра разрешается после `SERVICE_STARTED`.
|
||||
|
||||
Активность ядра задаётся явно: видимая Activity или незавершённый звонок удерживают
|
||||
ACTIVE и выключают Android standby. Состояние не истекает по таймеру. После
|
||||
@@ -395,6 +584,11 @@ ACTIVE и выключают Android standby. Состояние не истек
|
||||
сигналинг и звонковое аудио. Foreground-сервис резервируется сразу из видимой Activity.
|
||||
Отмена во время освобождения не запускает устаревший звонок.
|
||||
До окончания звонка повторное включение рации/трансляции заблокировано.
|
||||
До подтверждённой очистки сервиса и Telecom Connection blocksMedia сохраняется также после ENDED карточки.
|
||||
Telecom реестр не теряет отвергнутые/удалённые connections при ошибке destroy; Handler хранит владельцев и повторяет освобождение.
|
||||
Отказ markDisconnected не отменяет независимый destroy; подтверждённый destroy завершает владение.
|
||||
Восстановление рации ждёт Deferred shutdown, завершения mediaPreparation и awaitReleased Telecom.
|
||||
Ожидание не отменяет SDK cleanup. Новый service/core stop проверяет тот же Telecom barrier на main.
|
||||
После закрытия звонкового аудио и Telecom Connection ранее включённая рация
|
||||
возвращается в прежний канал; удержания PTT сброшены. Удалённый канал и остановленное
|
||||
ядро не восстанавливаются. Публикация и просмотр трансляции автоматически не возобновляются.
|
||||
@@ -407,3 +601,10 @@ standby даже при начальном `client_activity=STANDBY`. Keepalive
|
||||
handshake. Пробуждение по таймеру и внешней сети сначала сообщает ACTIVE-окно
|
||||
пирам, затем запускает ожидающие задачи. При выходе сначала восстанавливаются
|
||||
линки; deinit отменяет ожидающие задачи и очищает подписки старого ядра.
|
||||
|
||||
Activity/peer-sleep подписки уникальны по fn+arg и вызываются синхронно в обратном
|
||||
порядке добавления. Remove себя/соседа безопасен: память записи освобождается
|
||||
после внешней рассылки. Новые подписки могут получить вложенное событие;
|
||||
более новая эффективная activity прерывает остаток прежней рассылки.
|
||||
Destroy/reload из этих callbacks запрещён. `test_instance_contract` проверяет
|
||||
вложенные подписки, отказы ресурсов, manual/full reload, TCP listener и headless stop/start.
|
||||
+224
-40
@@ -1,5 +1,25 @@
|
||||
Модуль стриминга. Техническое задание, этап 1.
|
||||
|
||||
Статус аудита 26.10.2026: сверены все 18 заголовков src/stream с полными
|
||||
реализациями (15 C и playback_audio.cpp). Уточнены API, поля, владение,
|
||||
сроки и результаты операций; последние правки заголовков не меняют C-токены.
|
||||
Настоящий документ сверён в части общего ядра stream; платформенные разделы
|
||||
desktop/Android и подробности внешнего сетевого стенда остаются частично проверенными.
|
||||
Расхождение допуска deleted устранено: stream_member_load читает принятый флаг
|
||||
в том же SELECT, что и профиль, и возвращает STREAM_ERR_NOT_MEMBER до crypto.
|
||||
Штатный test_stream_member проверяет подписи/версии, source/supplier/self,
|
||||
закрытие handles/выдач, изоляцию каналов и повторное разрешение участия.
|
||||
Полный аудит платформенных разделов ещё не завершён. Подтверждения, архитектура
|
||||
исправления и результаты проверок — doc/stream_audit_findings.txt.
|
||||
Дефект publisher_block устранён: помещающийся Opus уходит в отдельную аудиопачку,
|
||||
ожидающее видео получает следующий слот actor. Штатный test_stream_publisher
|
||||
проверяет 23 сценария границ, продвижения треков и OPENING/FIN; раздел 7 отчёта.
|
||||
Дефект expire_controls устранён: INFO_REQ9 сопоставляется по probe_id, просроченная
|
||||
проба закрывается с OVERLOAD. Штатные проверки TTL и реальной ETCP-очереди пройдены;
|
||||
полный check201/201 выполнен в отдельном IPv6 namespace — раздел9 отчёта.
|
||||
Финальная сверка заголовков: сборка и check201/201, proxy9/9, burst/load PASS;
|
||||
C-токены12 доработанных заголовков совпадают с базой — раздел10 отчёта.
|
||||
|
||||
1. Цель и принятые решения
|
||||
|
||||
Модуль размещается в src/stream. Узлы получают поток данных от источника через других подписчиков группы,
|
||||
@@ -12,7 +32,7 @@
|
||||
- Источник подписывает всю пачку один раз до RS; подпись восстанавливается вместе с содержимым и проверяется после decode.
|
||||
- В источник добавляется циклический timestamp uint16_t в миллисекундах; он сохраняется при всей ретрансляции.
|
||||
Задержка оценивается относительно предыдущих данных по дельтам timestamp и времени прихода, включая полную остановку.
|
||||
- Передаём произвольные данные как есть, без обработки форматов видео, кадров и ключевых точек.
|
||||
- Транспортная часть передаёт произвольные данные как есть. Необязательный media-слой поверх неё разбирает uAV2 и точки KEY.
|
||||
- Размер части автоматически уменьшается по длине пачки, максимум по умолчанию 1300 байт.
|
||||
Побайтовый источник сбрасывает остаток через 100 мс; publish_block публикует сразу.
|
||||
- На каждый локальный экземпляр трансляции — кольцевой буфер 32 МБ для всех её субпотоков вместе.
|
||||
@@ -112,7 +132,7 @@ Handle получателя, поколение stream-сессии, измен
|
||||
Невосстановимый пропуск обозначается событием GAP; нельзя молча склеивать данные по обе стороны разрыва или выдавать нули за исходные байты.
|
||||
Окончательно пропускаем пачку, когда её данных уже нет в окне либо все нужные выдачи прошли её индекс/сообщили пропуск,
|
||||
а k блоков не собрано. Получение более поздней пачки от одного поставщика само по себе не закрывает ожидание остальных.
|
||||
Модуль не интерпретирует содержимое и не занимается видео, кодированием, воспроизведением или поиском ключевых кадров.
|
||||
RS/сетевой слой не интерпретирует содержимое. Media store, publisher и playback — отдельные компоненты поверх него.
|
||||
|
||||
4. Подпись источника и timestamp
|
||||
|
||||
@@ -241,13 +261,19 @@ ACCEPT содержит handle, фактический начальный инд
|
||||
Реальное получение блока, а не только ACCEPT, подтверждает пригодность выдачи.
|
||||
Индекс сразу после последнего доступного блока разрешён: курсор ждёт его появления в том же кольце.
|
||||
Более далёкий будущий индекс отвергается. Пустой источник допускает выдачу с 0/с конца,
|
||||
которая ждёт первый блок; первый проверенный DATA по-прежнему устанавливает общий N.
|
||||
которая ждёт первый блок; первая структурно допустимая DATA-часть по-прежнему устанавливает общий N.
|
||||
Пустой ретранслятор пути не объявляет. Завершение без данных подтверждается подписанным END с last_seq=UINT64_MAX.
|
||||
Общий N фиксирует первая структурно допустимая RS-часть, до проверки подписи всей пачки.
|
||||
INFO/ACCEPT и счётчик принятых частей не являются подтверждением подписи; она проверяется при decode из k частей.
|
||||
Отказы различают отсутствие подписки/трансляции, эпоху, FEC, позицию, слот, перегрузку, цикл и право доступа.
|
||||
DATA содержит поколение сессии, handle, неизменяемую часть RS-пачки и изменяемый путь.
|
||||
Источник подписывает и END с ключом трансляции и индексом последней пачки, чтобы ретранслятор не мог подделать окончание.
|
||||
|
||||
Нет продления 6 секунд — отправитель закрывает выдачу и сообщает CLOSED, при необходимости через маршрутизатор группы.
|
||||
INFO/connect ограничен 5 секундами; готовая INFO-only проба остаётся до явного close.
|
||||
Control FIFO удаляет сообщения старше 4 секунд. Просроченные INFO_REQ (9 байтов), SUBSCRIBE и ACCEPT
|
||||
закрывают соответствующую незавершённую выдачу с OVERLOAD; INFO_REQ дополнительно сопоставляется по nonce.
|
||||
После первого DATA отдельного таймера тишины receive нет: восстановление выполняет selector либо ручной владелец.
|
||||
Аренда, пробы и timers используют монотонное локальное время. Таймаут аренды очищает ресурсы;
|
||||
реакция на отсутствие прогресса начинается раньше по метрикам потока.
|
||||
Потеря отдельного ETCP-линка не закрывает выдачи, пока NCD остаётся UP.
|
||||
@@ -259,7 +285,7 @@ DOWN закрывает выдачи соответствующей сессии
|
||||
Начальный запуск:
|
||||
1) По опциям мемберов и BGP выбрать первый подходящий онлайн-узел, включая сам источник.
|
||||
2) Запросить один субпоток с N=UINT64_MAX.
|
||||
3) Получить и проверить первый блок, зафиксировать его source_epoch и batch_seq=N.
|
||||
3) Принять структурно допустимую часть и зафиксировать batch_seq=N; source_epoch уже выбран по профилю источника.
|
||||
4) У остальных поставщиков запросить субпотоки этой же эпохи с конкретного N.
|
||||
5) Собрать достаточный набор блоков одной пачки, затем продолжать непрерывное получение.
|
||||
|
||||
@@ -270,7 +296,7 @@ DOWN закрывает выдачи соответствующей сессии
|
||||
Обычный перенос одного субпотока:
|
||||
1) Выбрать первую ещё нужную пачку N и поставщика, способного её выдать.
|
||||
2) Создать новую выдачу, продолжая старую; временное перекрытие также занимает исходящий слот нового поставщика.
|
||||
3) Получить блоки и подтвердить продвижение нового поставщика.
|
||||
3) Получить структурно допустимые части и подтвердить продвижение нового поставщика до курсора старого.
|
||||
4) Закрыть старую выдачу. При отказе новой сохранить старую и отменить неудачную попытку.
|
||||
|
||||
Дедупликация — по (stream key, batch_seq, subblock), независимо от handles.
|
||||
@@ -309,7 +335,9 @@ DATA несёт фактически пройденные узлы; началь
|
||||
Базу сбрасываем при отмеченном источником разрыве времени, неоднозначной дельте, новой выдаче/эпохе или разрыве последовательности.
|
||||
Резкое изменение позиции выдачи не считается доказательством улучшения задержки.
|
||||
|
||||
Ожидаемый межблочный интервал и rate источника оцениваются по окну его timestamp и объёмам блоков.
|
||||
Rate источника оценивается по timestamp и объёмам пар последовательных блоков за 60 секунд.
|
||||
Ожидаемый интервал сначала равен 100 мс; после ненулевой суммы source_delta текущей секундной корзины
|
||||
используется её среднее по интервалам, не меньше 1 мс. Первый/rebase-блок не даёт парных bytes/time для rate.
|
||||
Если несколько блоков имеют один timestamp, агрегируем объём до ненулевой дельты, не делим на ноль.
|
||||
Не используем время формирования всех n блоков одной пачки как n независимых временных интервалов.
|
||||
При отсутствии новых данных продолжаем измерять отставание от ожидаемого следующего прихода:
|
||||
@@ -348,8 +376,9 @@ INFO_RESP также сообщает число свободных исходя
|
||||
Неуспех имеет backoff; одинаковые активные пробы одного пира для разных экземпляров переиспользуются.
|
||||
Временные NCD handles невыбранных кандидатов освобождаются; рабочие экземпляры удерживают свои handles.
|
||||
|
||||
Используются route_connectivity, sock_match/NCD и метрики существующих соединений; второй адресный probe-движок не создаётся.
|
||||
Прямая доступность — UNKNOWN/REACHABLE/UNREACHABLE с временем замера, а не вечный boolean.
|
||||
Используются node_conn_direct_compatible и sock_match/NCD; stream не запускает собственные route_connectivity-пробы.
|
||||
Совместимость означает возможность direct/reverse-попытки по известным сокетам/адресам либо уже работающий транспорт.
|
||||
Фактическую доступность и RTT подтверждает INFO-обмен, снимки и неуспехи имеют ограниченный срок/backoff.
|
||||
|
||||
После проверки доступного субпотока/индекса и отсутствия цикла минимизируем интегральную оценку в миллисекундах:
|
||||
score_ms = RTT_цепочки_до_поставщика + 2 * RTT_до_поставщика + 3 * hop_count + problem_penalty_ms
|
||||
@@ -396,21 +425,26 @@ A — поступившая нагрузка, D — передано в ETCP, X
|
||||
в интервале без смены выдач g = max(0, (A-D)/elapsed_seconds).
|
||||
Rate выдачи — её средний вклад в поступающую нагрузку, а не урезанная скорость выхода из перегруженного канала.
|
||||
Для разгрузки нужно снять суммарный rate больше g, чтобы очередь начала убывать.
|
||||
Число выдач выбирается по их rates: закрываем достаточно выдач для требуемого сокращения;
|
||||
при сильном росте это сразу несколько, не фиксированная одна и не случайное постоянное число.
|
||||
Число выдач оценивается по их rates, но закрывать можно только избыточные части сверх k.
|
||||
Без безопасных избыточных частей запрос завершается без принудительных закрытий. Возраст сам по себе не разрешает пропуск:
|
||||
backpressure сохраняет cursor, GAP возникает при фактическом вытеснении/утрате диапазона.
|
||||
|
||||
OVERLOAD содержит request_id, расчётное число выдач, требуемое сокращение rate и список handles с фиксированными rates.
|
||||
Получатель выбирает handles с учётом FEC и альтернативных поставщиков и отправляет OVERLOAD_SELECT.
|
||||
Получатель выбирает handles только при сохранении k различных работающих частей и отправляет OVERLOAD_SELECT.
|
||||
Альтернатива должна уже получать DATA с действительными метриками без остановки; ожидающий INFO не является заменой.
|
||||
Одновременный выбор для других перегруженных пиров тоже исключается из доступных альтернатив.
|
||||
Отправитель исполняет выбор, только пока актуален исходный request_id, и сообщает CLOSED.
|
||||
Получатель не отсоединяет выбранные handles до CLOSED: поздний SELECT после принудительного закрытия не режет дополнительные выдачи.
|
||||
При разных rates одного количества закрытий недостаточно: суммарный снятый rate должен покрыть требование.
|
||||
Получатель не отсоединяет выбранные handles до CLOSED; поздний SELECT после завершения запроса не меняет выдачи.
|
||||
При разных rates снятая нагрузка считается по сумме rates; безопасность FEC важнее полного исполнения требования.
|
||||
Rate закрываемых выдач фиксируется по оценке на момент запроса, чтобы условие исполнения не менялось на каждом тике.
|
||||
Срок реакции — 2 секунды с первого запроса; повторы не сбрасывают срок и не начинают новое требование.
|
||||
Новые выдачи, компенсирующие запрошенное сокращение, до разгрузки не принимаются.
|
||||
Если требование не выполнено, отправитель сам закрывает достаточный остаток выдач и сообщает handles/причину.
|
||||
Далее снова измеряется рост; автоматический выбор учитывает освобождаемую нагрузку, а при равенстве допустима случайность.
|
||||
Если очередь опустела, запрос завершается сразу. По истечении 2 секунд отправитель завершает запрос без слепых закрытий.
|
||||
Далее снова измеряется рост; автоматический выбор учитывает освобождаемую нагрузку.
|
||||
При глобальном отставании сокращение выбирается по всем пирам модуля.
|
||||
Память и возраст очереди остаются ограниченными во время ожидания; просроченные задания удаляются с GAP/EXPIRED.
|
||||
Возраст ожидающих DATA используется для диагностики перегрузки, но не разрешает пропуск данных.
|
||||
Backpressure сохраняет курсоры и непрерывную цепочку кадров. GAP обозначает фактическое вытеснение/утрату диапазона.
|
||||
Принятые ETCP пакеты и подписки сохраняются. Завершённый источник дочитывается до подписанного END.
|
||||
|
||||
13. Статусы через опции мембера и Merkle
|
||||
|
||||
@@ -502,7 +536,7 @@ Stop/destroy выполняются по правилам ядра, вне callb
|
||||
- Перекрытие поставщиков без дублирования потребителю, rollback, одновременное восстановление нескольких субпотоков.
|
||||
- Цикл по устаревшим метрикам, одновременная перестройка и выдача сохранённых путей: DATA не циркулирует.
|
||||
- Глитч короче подтверждения, устойчивое отставание 500 мс, нулевой выход и локальная перегрузка uasync.
|
||||
- Выбор числа закрытий по g/rates, разные rates, повтор OVERLOAD, частичное выполнение и закрытие остатка через 2 секунды.
|
||||
- Выбор безопасных закрытий по g/rates, разные rates, повтор OVERLOAD, частичное выполнение и сохранение k после 2 секунд.
|
||||
- 32 совместимых кандидата на старте, далее 1/с, маленький/большой список и запрет повторного полного цикла раньше 30 с.
|
||||
- Merkle-опции до/после BGP-события, офлайн-опция не кандидат, поздний участник, перезапуск и сохранение других полей userinfo.
|
||||
- Мобильный источник/получатель и исключение мобильных из фоновых проб ретрансляторов.
|
||||
@@ -537,8 +571,12 @@ Stop/destroy выполняются по правилам ядра, вне callb
|
||||
stream.c/h — контекст в UTUN_INSTANCE, локальные handles публикации/подписки, общий экземпляр и буфер.
|
||||
DATA/GAP/END/CLOSED доставляются отложенно через один callback экземпляра ядра;
|
||||
callback может закрывать handles, заимствованные данные живут до его возврата.
|
||||
DATA/GAP/END используют общую позицию сессии и не повторяются для каждого raw handle;
|
||||
медиакурсоры отдельных handles независимы. Последний owner закрывает сессию;
|
||||
после FIN оставшиеся owners удерживают кольцо, publisher close без FIN отсоединяет их.
|
||||
stream_member.c/h — канонические опции, проверка подписанного профиля/ключа и членства,
|
||||
online-кандидаты только из живых BGP-путей этой CHAT-группы; мобильный источник допускается, relay исключается.
|
||||
online-кандидаты только из живых BGP-путей этой CHAT-группы; мобильный источник допускается,
|
||||
мобильный промежуточный поставщик требует подписанную роль supernode.
|
||||
chat_core_set_my_profile_options — атомарная замена опций с сохранением имени, аватарки и остальных userinfo.
|
||||
При старте снимаются оставшиеся после аварии собственные опции; смена эпохи/ключа или утрата членства отменяют подписку.
|
||||
Удаление группы и остановка CHAT отменяют таймеры/события, отсоединяют handles и освобождают буферы;
|
||||
@@ -557,7 +595,7 @@ Stop/destroy выполняются по правилам ядра, вне callb
|
||||
stream_network.c/h — собственный NCD owner пира, общий для его потоков/групп; ожидание его UP,
|
||||
прямые SUBSCRIBE/ACCEPT/REJECT/DATA/GAP/RENEW/UNSUBSCRIBE/CLOSED/END, routed REVERSE_REQ/CLOSED.
|
||||
Новые handles назначаются при постановке SUBSCRIBE в общую FIFO; watermark защищает от закрытых/старых запросов.
|
||||
Первый проверенный DATA с конца устанавливает общий N; прочие latest-выдачи ждут его и запрашивают конкретный N.
|
||||
Первая структурно допустимая DATA-часть с конца устанавливает общий N; прочие latest-выдачи запрашивают конкретный N.
|
||||
Повторный stream_receive_restart отменяет upstream старой попытки, оставляя кольцо/downstream;
|
||||
старые receive handles отсоединяются и требуют close. Уже сохранённый первый блок также устанавливает новый старт.
|
||||
DATA копируется из кольца только перед отправкой; пути сохраняются по каждому блоку и проверяются на LOOP.
|
||||
@@ -584,7 +622,8 @@ Stop/destroy выполняются по правилам ядра, вне callb
|
||||
Кольцо сообщает только фактические вставки/вытеснения; повторный блок не создаёт A.
|
||||
Списки субпотоков по времени вставки дают точный возраст ещё ожидающих блоков, в том числе вне порядка batch_seq.
|
||||
Логическая DATA-очередь представлена курсорами и счетчиками: содержимое и история остаются в одном 32-МБ кольце.
|
||||
Частные/общие метрики lag/rates/late/stops подключены к подписанным DATA; после END ожидание прекращается.
|
||||
Метрики lag/rates/late/stops обновляются при приёме структурно допустимых частей, до проверки общей подписи;
|
||||
они описывают доставку поставщика и не подтверждают содержимое источника. После END ожидание прекращается.
|
||||
INFO содержит nonce пробы и 64-битный снимок метрик; RTT — uint32 мс с признаком неизвестного.
|
||||
Условие перегрузки: ожидающая нагрузка старше 200мс и рост либо нулевой D;
|
||||
непрерывное подтверждение — 500мс по монотонным часам, независимо от количества тиков.
|
||||
@@ -592,13 +631,14 @@ Stop/destroy выполняются по правилам ядра, вне callb
|
||||
Rates фиксируются при создании OVERLOAD. Расчётное число — подсказка, исполнение проверяется по суммарному rate.
|
||||
Получатель предпочитает закрытия, сохраняющие k с учётом других поставщиков, затем parity при равных условиях.
|
||||
OVERLOAD_SELECT предлагает handles; source закрывает только актуальное требование, поздний/повторный выбор безопасен.
|
||||
Срок 2с начинается с первого запроса и не меняется при повторе. Невыполненный остаток source закрывает сам.
|
||||
Срок 2с начинается с первого запроса и не меняется при повторе. С 25.10 остаток без безопасного SELECT не закрывается.
|
||||
Новые SUBSCRIBE не компенсируют разгрузку: запрет действует до исполнения и 500мс снятого давления;
|
||||
stream_peer_get_load.accepting сообщает этот статус отдельно от request_active.
|
||||
Глобальный бюджет включается только для backlog нескольких пиров вместе с CPU/опозданием callbacks;
|
||||
CPU отдельно не отключает выдачи, уже запрошенное сокращение повторно не учитывается.
|
||||
Control FIFO ограничена 128 кодограммами по 1094 байта, возрастом 4с и отменой неподтверждённых запросов.
|
||||
Просроченные ожидающие DATA при активном OVERLOAD учитываются X и пропускаются явным GAP, кольцо не стирается.
|
||||
Удаление DATA только по возрасту отменено: оно разрезало KEY и вызывало постоянные resync при недостаточной полосе.
|
||||
GAP сообщает фактическую потерю диапазона; после END ожидающие DATA дочитываются.
|
||||
Диагностика сообщает A/D/X/Q, age, input/output/g, длительность интервала, cpu/late callbacks,
|
||||
frozen/released rates, request/deadline и нижние очереди/inflight; D остаётся принятием ETCP, а не доставкой.
|
||||
test_stream_core: баланс, X отдельно от D, граница 500мс, глитч, смена состава, 10-секундное окно и широкие wire-метрики.
|
||||
@@ -627,7 +667,7 @@ Stop/destroy выполняются по правилам ядра, вне callb
|
||||
INFO сохраняет диапазон, фактический путь, metrics, RTT, свободные slots, accepting, queue, CPU и возраст прогресса.
|
||||
RTT цепочки относится к этому пути; смена фактического пути DATA делает старое измерение неизвестным,
|
||||
новая проба обновляет RTT соответствующей активной выдачи. Невыбранные снимки истекают через 60с.
|
||||
Первый поставщик запрашивается с конца; остальные стартуют с его проверенного N, даже если k уже позволили выдать пачку.
|
||||
Первый поставщик запрашивается с конца; остальные стартуют с N его первой принятой части, даже если пачка уже выдана.
|
||||
Поиск учитывает доступную старую позицию, запрет LOOP и лимит r при наличии альтернатив.
|
||||
Оценка поставщика в мс: RTT цепочки до поставщика + 2 * RTT до поставщика + 3 * hops + штраф проблем (0..1000 мс).
|
||||
hops = path.count: путь INFO содержит автора/поставщика, последний переход к получателю добавляет один hop.
|
||||
@@ -635,15 +675,19 @@ Stop/destroy выполняются по правилам ядра, вне callb
|
||||
текущая остановка или lag > 500 мс сразу дают 1000 мс. Неизвестные метрики не дают штрафа, неизвестный RTT даёт UINT64_MAX.
|
||||
Меньшая оценка лучше; при равенстве выбираются меньше общих узлов путей, затем меньший ID поставщика.
|
||||
Более новый индекс повторно проверяется свежим INFO/SUBSCRIBE: старое объявление last не является вечным верхним пределом live-потока.
|
||||
Обычно переносится один субпоток; новая выдача перекрывает старую и заменяет её только после проверенного DATA и догоняющего cursor.
|
||||
Обычно переносится один субпоток; новая выдача заменяет старую после структурно допустимого DATA и догоняющего cursor.
|
||||
По умолчанию: подтверждение улучшения 2с, удержание поставщика 5с, гистерезис оценки 20мс; параметры доступны через stream_selection_configure.
|
||||
Перенос сравнивает ту же оценку по фактическим метрикам текущей выдачи и INFO альтернативы;
|
||||
распределение для FEC допускает ухудшение оценки не более гистерезиса. Логи выбора/переноса показывают оценку и её составляющие.
|
||||
Новая выдача без достаточного продвижения отменяется через 5с, старая остаётся. CLOSED/DOWN восстанавливают несколько отсутствующих субпотоков.
|
||||
При отсутствии decode-прогресса 5с, недостатке работающих субпотоков и наличии более новой истории возможен явный restart с конца;
|
||||
пропущенный диапазон передаётся потребителю GAP, кольцо/downstream сохраняются. Сам по себе простой источника restart не вызывает.
|
||||
После 5с без полной проверенной пачки, если anchor уже есть либо запрошен media-индекс, выполняется recovery_request;
|
||||
следующая попытка не раньше чем через 5с. Зависшие выдачи переоткрываются, consumer/media cursors сохраняются.
|
||||
Для media переход к более новому полному KEY выполняется лишь при доказанной недоступности нужного диапазона;
|
||||
тогда stream_local_restart сохраняет кольцо/downstream и передаёт пропущенный диапазон как GAP.
|
||||
Тишина источника может вызвать перезапрос, но сама по себе не доказывает потерю данных и не требует скачка к голове.
|
||||
stream_selection_get_info сообщает пробы/циклы, выдачи, перекрытия, поставщиков, миграции, отказы/restart и фактическую FEC-защиту.
|
||||
Защита подтверждается всеми n проверенными работающими субпотоками и не более r на поставщика; при недостатке поставщиков помечается ослабленной.
|
||||
Защита оценивается по всем n субпотокам с наблюдёнными частями/метриками без stopped и не более r на поставщика;
|
||||
эта оценка доступности частей не заменяет проверку общей подписи. При недостатке поставщиков защита ослаблена.
|
||||
Владельцы публикации/подписки удерживают ACTIVE; запрошенный STANDBY восстанавливается после закрытия последнего экземпляра.
|
||||
Подписанный END проходит также для источника без единого DATA.
|
||||
test_stream_core проверяет границы 32/1с/30с, новые записи, долгий/незавершённый обход и отсутствие повторного burst.
|
||||
@@ -665,27 +709,31 @@ Stop/destroy выполняются по правилам ядра, вне callb
|
||||
17. Зафиксированный сетевой формат и пределы реализации
|
||||
|
||||
Все stream-числа на wire — big-endian, без packed-структур и зависимости от host byte order.
|
||||
Версия stream wire — 4; обратная совместимость не поддерживается.
|
||||
Заголовок raw (52 байта, version=5): cmd:1, version:1, command:1, subblock:1,
|
||||
Версия stream wire — 7; обратная совместимость не поддерживается.
|
||||
Заголовок raw (52 байта, version=7): etcp_id=0x07:1, version:1, command:1, subblock:1,
|
||||
group_id:8, source_node_id:8, source_epoch:8, sender_epoch:8, receiver_epoch:8, handle:8.
|
||||
INFO_REQ/RESP согласуют sender/receiver epochs; receiver_epoch=0 используется INFO_REQ для обнаружения поколения и REVERSE_REQ.
|
||||
Подписанный routed payload содержит тот же заголовок; его group_id сверяется с реальной группой router,
|
||||
sender берётся из end-to-end router metadata. DATA через routed service отвергается.
|
||||
|
||||
INFO_REQ: probe_nonce:8, не резервирует номер SUBSCRIBE.
|
||||
INFO_REQ: probe_nonce:8, want_media:1 (0/1), не резервирует номер SUBSCRIBE.
|
||||
want_media=1 просит поставщика подготовить общий медиаиндекс; поиск KEY выполняется отложенно с ограниченным бюджетом.
|
||||
INFO_RESP/ACCEPT: probe_nonce:8 (0 в ACCEPT), actual_seq:8, first_available:8, last_available:8, n:1, r:1, block_size:2,
|
||||
metric_flags:1, lag_ms:8, source_rate:8, receive_rate:8, blocks:8, late_blocks:8, stops:8,
|
||||
offer_flags:1, free_slots:2, cpu_basis_points:2, queue_bytes:8, progress_age_ms:8, path_rtt_ms:4,
|
||||
path_count:1, node_ids:8*path_count — фиксированная часть 111 байт. В INFO actual_seq — последний доступный, в ACCEPT — старт.
|
||||
key_seq:8, key_last_seq:8, path_count:1, node_ids:8*path_count — фиксированная часть 127 байт. В INFO actual_seq — последний доступный, в ACCEPT — старт.
|
||||
metric_flags: bit0 — измерение известно, bit1 — source_rate известен, bit2 — receive_rate известен, bit3 — stopped.
|
||||
offer_flags: bit0 — принимает новые выдачи, bit1 — RTT цепочки известен, bit2 — возраст прогресса известен,
|
||||
bit3 — CPU известен, bit4 — кольцо непусто для этого субпотока. CPU — 0..10000 (сотые доли процента).
|
||||
bit3 — CPU известен, bit4 — кольцо непусто для этого субпотока, bit5 — полный KEY сохранён для выдачи этой части.
|
||||
При bit5=0 обе позиции KEY равны UINT64_MAX; при bit5=1 first_available <= key_seq <= key_last_seq <= last_available.
|
||||
Каждая часть диапазона KEY проверяется на наличие; свежий незавершённый KEY не заменяет предыдущий полный.
|
||||
CPU — 0..10000 (сотые доли процента).
|
||||
Пустой источник: first/last=UINT64_MAX, bit4=0, INFO actual_seq=UINT64_MAX, ACCEPT actual_seq=0, путь {source}.
|
||||
Курсор last+1 допустим только в ACCEPT непустого кольца; DATA/END проверяются по точной позиции и подписи источника.
|
||||
SUBSCRIBE: requested_seq:8; REJECT: reason:1, для SUBSCRIBE дополнительно first_available:8,last_available:8,
|
||||
для INFO дополнительно probe_nonce:8. Старый nonce не подтверждает новую пробу.
|
||||
RENEW: один header handle без payload (каждая кодограмма продлевает один элемент списка).
|
||||
UNSUBSCRIBE/CLOSED: reason:1. DATA: path_count:1,node_ids:8*path_count,неизменяемая часть RS-пачки переменной длины.
|
||||
UNSUBSCRIBE/CLOSED: reason:1. DATA: path_count:1,node_ids:8*path_count,frame_timing:48,неизменяемая часть RS-пачки переменной длины.
|
||||
GAP: old_cursor:8,next_cursor:8, уведомление включает диапазон [old_cursor,next_cursor).
|
||||
END: "stream_end":10,group_id:8,source_node_id:8,source_epoch:8,last_seq:8,Ed25519:64 — всего 106 байт.
|
||||
UINT64_MAX в END означает пустой источник; принимается только выдачей с next=0 и без ранее принятых DATA.
|
||||
@@ -738,7 +786,7 @@ Restart в той же source_epoch сохраняет consumer cursor: bootstra
|
||||
|
||||
8. Live media и Android
|
||||
|
||||
Транспортный wire v5 не меняется. stream_publish_block публикует одну непустую FEC-пачку
|
||||
Текущий транспортный wire — v7. stream_publish_block публикует одну непустую FEC-пачку
|
||||
немедленно, с сохранением границ. Первая запись выбирает режим источника: byte write или block write;
|
||||
смешивание отвергается до изменения данных. stream_publish_finish не освобождает буфер источника.
|
||||
|
||||
@@ -791,7 +839,8 @@ stream_media проверяет весь блок до изменения view.
|
||||
Desktop и Android открывают просмотр через stream_media_open: ядро проверяет выбранный source_epoch
|
||||
и эпоху существующей session, создаёт подписку и cursor; при ошибке снимает только нового владельца.
|
||||
Платформы сохраняют собственное планирование чтения и вывод; закрытие использует обычный stream_close.
|
||||
Ретрансляторы не разбирают uAV2 и не пересоздают подписи источника.
|
||||
Пересылка RS-частей не требует разбора uAV2 и не пересоздаёт подписи источника.
|
||||
По INFO_REQ.want_media ретранслятор дополнительно готовит общий индекс проверенных пачек для предложения полного KEY.
|
||||
|
||||
Android:
|
||||
- Один foreground publisher на процесс; каждый viewer имеет независимый session ID.
|
||||
@@ -828,6 +877,12 @@ Android:
|
||||
UI не сохраняет borrowed inst/ua. Idle без pending data не будит мобильное ядро.
|
||||
- TX<=2MiB/128 records, не более 250ms ожидания. Actor публикует <=2 пачек за вызов,
|
||||
неполную пачку объединяет с аудио/видео до 20ms; продолжение работы через timer 5ms.
|
||||
Opus целиком занимает header32 + audio descriptor16 + payload; точное заполнение допустимо.
|
||||
Без ожидающего видео резерв под video descriptor отсутствует. Обычная смешанная пачка
|
||||
сохраняет место под описание и непустой фрагмент видео. Если первый Opus помещается только отдельно,
|
||||
второй слот того же actor отдаётся видео без ожидания сборки неполной пачки.
|
||||
feed=1 означает копирование в очередь. Actor после определения FEC-ёмкости при open удаляет Opus,
|
||||
не помещающийся даже отдельно, с WARN (ID, PTS, bytes, max, capacity), не блокируя следующие записи.
|
||||
При video pressure отбрасываются целые ожидающие кадры, P-кадры запрещены до нового KEY.
|
||||
Кодировщик получает отказ admission и запрашивает KEY с ограничением частоты 500ms.
|
||||
- RX: stream_viewers держит отдельный handle/cursor, demand, status и ограниченный owned батч на session ID.
|
||||
@@ -850,7 +905,11 @@ Android:
|
||||
и отсоединения своего mixer input; detach ядра будит signal, не освобождая его.
|
||||
При обычной нехватке данных decoder references сохраняются; GAP/вытеснение требуют нового KEY.
|
||||
Просроченный кадр при нормальной последовательности декодируется без показа.
|
||||
- Фиксированного prebuffer нет. Начало clock/audio output разрешено после первого декодированного кадра.
|
||||
- Первый декодированный кадр разрешает audio output; общий PCM-модуль desktop/Android перед первым звуком
|
||||
и после underrun накапливает 250ms готового PCM. Это порог готового звука, без аппаратной задержки и lookahead DSP.
|
||||
Во время накопления backend выдаёт silence с неподвижным исходным PTS; decoder references сохраняются.
|
||||
Уже переданный устройству PCM продолжает воспроизводиться. До возобновления регулятор держит темп 1x;
|
||||
затем избыток сокращается по обычной истории минимального запаса. EOF снимает порог и выпускает весь короткий хвост.
|
||||
AudioTrack задаёт время показа видео по реально услышанному PCM;
|
||||
без аудио используются монотонные часы с остановкой на границе готовых кадров.
|
||||
Android StreamAudioOutput принадлежит StreamAudioMixer и хранит один недописанный общий PCM (до 480 stereo frames).
|
||||
@@ -1036,7 +1095,7 @@ Desktop:
|
||||
UI показывает video queue, суммарный inflight, send buffer и ACK_1s каждые 100ms.
|
||||
Stop закрывает источники и audio callback, join обоих workers, затем FIN и ограниченный drain 2s.
|
||||
Удаление engine происходит до SoundManager и ядра; detach отменяет posts/timers и закрывает publisher
|
||||
в uasync. Протокол uAV2 и wire v5 при добавлении desktop publisher не меняются.
|
||||
в uasync. Общий publisher использует те же uAV2 и текущий wire v7 на обеих платформах.
|
||||
- Одиночный StreamPlayerWindow либо StreamGridWindow с отдельным player на источник.
|
||||
Player содержит имя, состояние и громкость; fullscreen/close в сетке принадлежат общему окну.
|
||||
- transport/stream_bridge владеет stream_viewers: независимый cursor и ближайший батч на reader.
|
||||
@@ -1072,7 +1131,8 @@ Desktop:
|
||||
Ожидающий кадр не теряется при заполненной очереди изображений. Отсутствие прогресса при наличии работы логируется как ошибка.
|
||||
- При начальном подключении desktop/Android прогревают Opus из кольца до первого KEY (до 500ms истории).
|
||||
Старый звук обрезается по PTS видео, включая начало stereo PCM-пакета; декодер не сбрасывается из-за preroll.
|
||||
Фиксированного prebuffer нет: показ следует физическим audio callback timestamps;
|
||||
Звук начинается после накопления 250ms готового PCM; после underrun применяется тот же порог без сброса decoder/PTS.
|
||||
EOF выпускает хвост любой длины. Показ следует физическим audio callback timestamps;
|
||||
без звука — монотонным часам с ограничением готовым PTS. END до KEY завершает пустой reader.
|
||||
Короткие пропуски аудио заполняются Opus PLC; большой разрыв/pressure сбрасывает timeline.
|
||||
Закрытие окна снимает только reader, собственная публикация сохраняется.
|
||||
@@ -1185,7 +1245,8 @@ test_stream_publish проверяет реальные encoder/decoder H.264/H.
|
||||
- AVAILABLE сообщает готовность индекса, DATA/GAP/END callbacks описывают live ingest.
|
||||
media_read выдаёт END после обоих треков конкретного reader. MORE продолжает ограниченную сборку
|
||||
отдельным callback без ожидания новых пакетов; WAIT ожидает новые данные или изменение позиции.
|
||||
- Декодированный PCM пополняется до 250ms при наличии данных; фиксированного ожидания перед стартом нет.
|
||||
- Декодированный PCM пополняется до 250ms при наличии данных; перед стартом и после underrun consumer ждёт этот порог.
|
||||
При EOF остаток выпускается без порога. Во время накопления PTS удерживается silence span; Opus/SoundTouch не сбрасываются.
|
||||
Это запас отдельного audio worker, размер аппаратного буфера не увеличивается. Audio lookahead — device lead + 500ms,
|
||||
достаточный для 250ms PCM даже при x1.6. SPSC имеет 128 слотов по 960 stereo frames с собственными PTS spans.
|
||||
Mixer/callback не вызывает Opus/SoundTouch; Android переиспользует PCM-массив каждого источника.
|
||||
@@ -1215,7 +1276,7 @@ test_stream_publish проверяет реальные encoder/decoder H.264/H.
|
||||
Сам старт публикации не запускает приём на суперузлах. Новая подписка сразу запускает INFO-пробы и отдельный
|
||||
дедлайн 100 мс. Если пригодного ответа ещё нет, запрашивается один суперузел. Первый валидный INFO с разрешённой
|
||||
выдачей выигрывает: порядок приёма фиксируется локальным счётчиком, независимо от RTT и порядка списка кандидатов.
|
||||
DATA сначала открывается только у победителя; проверенный первый блок фиксирует общий старт всех остальных субпотоков.
|
||||
DATA сначала открывается только у победителя; первая структурно допустимая часть фиксирует общий старт остальных субпотоков.
|
||||
Другие INFO-пробы продолжаются: после фиксации старта недостающие части можно брать у любых подходящих поставщиков,
|
||||
даже до первого восстановления FEC. Победитель гонки не обязан иметь все k частей. Ненужная помощь отменяется.
|
||||
Пустой доступный источник не требует помощи, пока он не публикует данные.
|
||||
@@ -1293,3 +1354,126 @@ callback обнаружения поставщика без секундной
|
||||
раздачу нескольким зрителям, общий owner с локальным viewer, остановку при уходе последнего,
|
||||
поздний повтор запроса до/после удаления tombstone, CANCEL до REQUEST, истечение READY без SUBSCRIBE,
|
||||
откат неуспешного добавления viewer, исключение relay-only кандидатов и передачу пустого подписанного END.
|
||||
|
||||
Диагностика доставки и индикатор буфера
|
||||
|
||||
[debug] video=debug включает две сводки с периодом не меньше 500 мс и фактическим dt_ms.
|
||||
stream NET — одна строка на соседа для всех его трансляций и субпотоков вместе:
|
||||
- TX: sent/drop/gap за интервал, remain до головы локального кольца, возраст очереди и скорость её роста.
|
||||
- ETCP: очередь до нормализации/отправки, уникальные unacked DATA, физический flight и сумма окон линков,
|
||||
уникальные acked_B, принятые транспортом повторы и число физических RTO за интервал; очередь пересборки RX.
|
||||
Все поля ETCP относятся к общему соединению с пиром, включая другие сервисы. unacked и flight не складываются.
|
||||
errors_total — суммы счётчиков send/recv/crypto существующих линков; удаление/сброс линка меняет базу этих сумм.
|
||||
- PATH: рабочие UDP/TCP, stalled, заполненные окна для первого ожидающего DATA, pacing, RTT/jitter/RTO и причина ожидания.
|
||||
- RX: received/gap/dup/expired за интервал, время без DATA, максимальное относительное отставание и число stopped.
|
||||
worst=group/source/epoch/sub обозначает самый отставший измеренный субпоток. connecting — все ещё открывающиеся выдачи пира.
|
||||
Счётчики блоков/байтов записаны как blocks/bytes. sent означает принятие ETCP, не ACK.
|
||||
na — нет измерения. После смены эпохи changed=1, транспортные дельты na; следующая сводка имеет новую базу.
|
||||
ack_idle_ms начинается только после реального логического ACK, не после отправки/keepalive.
|
||||
Метрика lag относительна исходной базе timestamp, не является абсолютной задержкой от источника.
|
||||
|
||||
stream PLAY — одна строка на плеер/источник: TIME (позиция, непрерывная готовая граница, запас, минимум, guard,
|
||||
темп и причина решения), VIDEO (показ/отброс за интервал, PTS показанного/переданного поверхности кадра,
|
||||
av_ms=video PTS минус media clock, очередь и ожидание KEY), AUDIO (PCM, устройство, underruns/тишина за интервал).
|
||||
Длительности — мс, tempo=1000 означает 1x. generation/epoch отделяют новые временные шкалы.
|
||||
GAP, перегрузка, смена поставщика и restart выводятся отдельными событиями. Подробности кодеков/кадров/GUI — video=trace.
|
||||
Kotlin videoDebug/videoTrace используют native-фильтр и общий log hook, включая файловый/UDP вывод.
|
||||
|
||||
Индикатор публикации обновляется раз в 500 мс независимо от уровня логирования:
|
||||
Android — 96x48dp слева от Start/Stop в шапке, desktop — рядом с этой кнопкой над preview.
|
||||
Горизонталь [first, head] показывает сохранённое окно, где head — следующий seq после последнего блока.
|
||||
Красная/жёлтая/зелёная трети обозначают близость к вытеснению/середину окна/близость к голове.
|
||||
Линия объединяет субпотоки одного получателя и отмечает худшую и лучшую позиции передачи в ETCP.
|
||||
Показываются до 16 наиболее отставших получателей, порядок при равенстве — ID. Позиции вне окна прижимаются к краям.
|
||||
Индикатор отражает чтение кольца для отправки; неподтверждённые транспортные данные учитываются отдельно в inflight.
|
||||
|
||||
Восстановление доставки и старт с полного KEY — 25.10.2026
|
||||
|
||||
Отправитель не пропускает очередь по времени: 1-секундный catchup удалён. Возраст и рост очереди остаются метриками.
|
||||
Последняя полная проверенная RS-пачка обновляет progress_ms общей сессии. Приход отдельных частей/дубликатов,
|
||||
INFO/RENEW и уже объявленные GAP не являются таким прогрессом. Отсутствие полной пачки 5с запускает перезапрос, в том числе до первого DATA;
|
||||
следующая попытка не раньше чем через 5с. Сохраняются сессия, принятые части, consumer/media cursors и decoder references.
|
||||
Переоткрываются лишь зависшие выдачи, свежие INFO обновляют диапазоны и точки KEY поставщиков.
|
||||
Счётчик selection.restarts означает попытки перезапроса, а не сбросы медиабуфера.
|
||||
|
||||
INFO и ACCEPT сообщают last_available и [key_seq,key_last_seq] последнего полного сохранённого KEY.
|
||||
Медиаиндекс обновляет KEY только после всех фрагментов; позднее создание индекса ищет начало назад с бюджетом
|
||||
и индексирует вперёд. Снимок использует уже известную точку, без повторного сканирования всего 32-МБ кольца.
|
||||
INFO_REQ.want_media позволяет реле подготовить индекс без локального плеера; частичный relay не объявляет непроверенный KEY.
|
||||
Начальная media-подписка выбирает KEY из INFO и открывает части параллельно с одной явной позиции.
|
||||
Обычный непрозрачный stream сохраняет прежний старт с последней доступной пачки.
|
||||
|
||||
При восстановлении сначала запрашивается текущая недостающая пачка. Если свежие диапазоны/отказы поставщиков
|
||||
и локальные части не позволяют собрать k, выбирается полный доступный KEY не ниже уже пройденной границы.
|
||||
Отправитель не заменяет согласованную позицию самостоятельно; вытеснение между INFO и SUBSCRIBE возвращает EXPIRED.
|
||||
Данные старых handles не примешиваются к новой попытке. Пропущенный диапазон выдаётся одним явным GAP;
|
||||
новая цепочка начинает декодирование с KEY, уже выданные DATA/PTS не повторяются.
|
||||
|
||||
Desktop/Android не закрывают соединение по отдельному 10-секундному ожиданию KEY: отсутствие DATA обслуживает ядро.
|
||||
Ошибка декодера, смена эпохи/членства, остановка сервиса и подписанный END сохраняют собственную обработку.
|
||||
Новый KEY может занимать несколько пачек; продвижение этих пачек предотвращает ложный перезапрос во время сборки кадра.
|
||||
Автор публикует все n частей вместе: один INFO разрешает параллельный выбор, каждое открытие проходит собственный INFO/ACCEPT.
|
||||
Реле проверяются отдельно для каждой части. Подтверждённая поломка уже работавшего транспорта допускает повтор через 250мс.
|
||||
|
||||
Диагностика video:
|
||||
- stream source offers ready — first/last, key/key_last, valid;
|
||||
- stream recovery request — epoch, недостающий next, idle_ms, число переоткрытых выдач, preserve_media=1;
|
||||
- stream recovery resumed — продвижение после попытки без сброса медиа;
|
||||
- stream recovery KEY — пройденная граница, выбранный KEY и причина недоступности прежнего диапазона;
|
||||
- stream GAP — точные first/next и направление; stream NET/PLAY — сводки каждые >=500мс.
|
||||
- stream BUFFER — худшая цепочка, сумма её буферов, число устаревших/недоступных/неизвестных узлов, максимум возраста раз в секунду.
|
||||
|
||||
Буферы цепочки воспроизведения (raw protocol v7):
|
||||
|
||||
TELEMETRY=18 идёт через общий raw ETCP отдельной служебной кодограммой. В очередь видео DATA он не ставится:
|
||||
перед очередной DATA pump сначала отправляет просроченную/обновлённую телеметрию. Снимок строится в момент отправки,
|
||||
не копится в control FIFO; при backpressure сохраняется только dirty-флаг. Уже принятые ETCP данные он не обгоняет.
|
||||
Источник и relay обновляют собственные измерения каждые 200ms; получение нового upstream сразу будит downstream.
|
||||
Если upstream не обновлялся 400ms, его значения сохраняются с STALE. Подтверждённый разрыв помечает последний переход DOWN,
|
||||
предыдущую часть цепочки — STALE. Отсутствовавшее измерение отличается от известного нулевого буфера.
|
||||
|
||||
Payload TELEMETRY: count:1, count записей по 33 байта:
|
||||
node_id:8, buffered_us:8, sample:8, frames:4, age_ms:4, flags:1. Все числа big-endian.
|
||||
Флаги KNOWN=1, STALE=2, DOWN=4. Путь начинается источником, заканчивается отправителем и не содержит повторов/получателя.
|
||||
sample — монотонный номер измерения узла; возраст увеличивается при каждом хранении/пересылке, без синхронизации часов.
|
||||
Потеря выдачи не удаляет последний снимок из общего кольца; новая эпоха получает отдельное кольцо.
|
||||
|
||||
Подсказка frame_timing в DATA содержит first_frame:8,last_frame:8,first_pts:8,last_pts:8,period_us:4,
|
||||
started:4,audio_us:4,flags:1,reserved:3. Флаги: valid=1,video=2,continuation=4; reserved=0.
|
||||
Она формируется из uAV2 у источника и неизменно пересылается relay. Это диагностическая подсказка непосредственного пира,
|
||||
а не часть подписанного RS-тела: на проверку подписи, выбор частей и декодирование видео она не влияет.
|
||||
Считаются только части >= cursor конкретной выдачи, ещё не переданные в ETCP. История кольца и другие получатели не прибавляются.
|
||||
Начала кадров плюс одно продолжение в начале очереди дают число уникальных кадров, независимо от FEC и дробления большого KEY.
|
||||
Длительность определяется их PTS и последним измеренным периодом кадра; при постоянном FPS соответствует frames/FPS.
|
||||
До измерения периода первого кадра время неизвестно. Аудио считается по сэмплам 48kHz; параллельные треки не суммируются.
|
||||
Источник также учитывает ещё не упакованную publisher-очередь, объединяя границы частично упакованного кадра без дублирования.
|
||||
|
||||
Для каждого viewer выбирается целый фактический входящий путь с максимальной суммой buffered_us.
|
||||
Desktop/Android показывают две строки: «0.6S → 0.5S → 2S 1.1x», под каждым временем имя узла.
|
||||
Имя длиннее восьми Unicode code points сокращается до первых восьми и одного символа «…».
|
||||
Последняя колонка — собственный непрерывный запас до фактической playback-позиции, включая decoder/PCM/device;
|
||||
перенос между локальными очередями не уменьшает запас повторно. Темп берётся из общего playback clock и виден только при ускорении.
|
||||
STALE отмечается приглушением и «?», DOWN — приглушением и «×»; последнее известное время сохраняется.
|
||||
|
||||
Регрессии test_stream_service --recovery-only используют настоящий CHAT/ETCP, подписи и RS 8+0.
|
||||
Медиатест создаёт 50 кадров/с по 6000 байт, KEY 55000 байт на нескольких пачках раз в 50 кадров.
|
||||
Проверяются порядок, возрастающий PTS, длины и каждый байт после штатного media cursor; H.264-декодер здесь не запускается.
|
||||
Сценарии: блокировка отправки 850мс и 2600мс без GAP; REINIT с задержкой stream-управления 100мс;
|
||||
10с выдачи 60КБ/с без потери KEY; остановка 5600мс с одной попыткой после 5с; поздний viewer с общим KEY-стартом;
|
||||
настоящее вытеснение 32-МБ source-кольца с переходом на KEY; зависание одной части при продолжающейся доставке семи других;
|
||||
вытеснение KEY между INFO и SUBSCRIBE ещё до первого DATA с обновлением стартовой точки через 5с.
|
||||
После коротких задержек сохраняются все кадры и состояние media; при фактической утрате диапазона требуется явный GAP.
|
||||
test_stream_media отдельно проверяет пустой индекс, неполный KEY, сохранение предыдущего KEY, завершение, вытеснение и GAP.
|
||||
|
||||
Предыдущая проверка 25.10 до этого изменения: ./check.sh — 187/187 и proxy/burst/load;
|
||||
Qt stream_view/audio/publish — 3/3, Android assembleDebug — PASS.
|
||||
Предыдущий изолированный стенд с 16 узлами: baseline 10с, WAN 2Мбит/с + RTT 300мс + loss 5% на 10с,
|
||||
восстановление, закрытие двух подписок и END — PASS. Результат: /tmp/stream-recovery-netns/result.json.
|
||||
|
||||
Историческая проверка реализации c3cf86b9 относилась к wire v6; затем телеметрия повысила версию до v7.
|
||||
При обновлении нужны отправитель, получатели и задействованные реле одной версии; совместимости с v5/v6 нет.
|
||||
Указанные ниже старые результаты desktop/Android получены из utun2 и не заменяют текущую платформенную проверку.
|
||||
|
||||
Проверка реализации c3cf86b9: ./check.sh — 187/187, proxy/burst/load — PASS; Android assembleDebug — PASS.
|
||||
Девять сетевых регрессий входят в test_stream_service; лог полного прогона: /tmp/utun2-key-check-final-pass.log.
|
||||
Известный нестабильный Qt audio-gap тест описан в doc/tasks.md; результаты отдельного Qt-прогона: /tmp/utun2-key-qt-isolated.log.
|
||||
@@ -0,0 +1,523 @@
|
||||
Аудит документации src/stream — 26.10.2026, заголовки завершены
|
||||
|
||||
Текущий статус: deleted исправлен и покрыт штатным test_stream_member; сборка,
|
||||
профиль7/7 и check.sh195/195 PASS относятся к внедрению этого исправления (раздел 5).
|
||||
При продолжении аудита подтверждена потеря помещающегося Opus в publisher_block (раздел 6).
|
||||
Исправление внедрено: сборка и штатный test_stream_publisher23/23 PASS; после
|
||||
объединения origin/master профиль8/8 и check.sh200/200 PASS (раздел 7).
|
||||
Остальные частичные/непроверенные контракты каталога не закрыты. Разделы 1–4
|
||||
описывают первоначальную диагностику и проверку архитектуры до внедрения deleted.
|
||||
В продолжении полностью прочитаны stream.c и stream_optimizer.c, уточнены их
|
||||
заголовки; теперь6[V]/12[~]/0[ ]. В частично прочитанном network подтверждён
|
||||
дефект истечения INFO_REQ (раздел8). Исправление и штатные проверки выполнены:
|
||||
TTL4/4, обе реальные backpressure-сцены и check201/201 в отдельном IPv6 namespace PASS (раздел9).
|
||||
Текущий итог продолжения:18[V]/0[~]/0[ ] для заголовков src/stream; полностью
|
||||
прочитаны все16 реализаций. Раздел10 описывает финальную сверку контрактов.
|
||||
doc/stream.txt остаётся[~] из-за неполной проверки платформ desktop/Android
|
||||
и внешнего многопроцессного стенда. Старые показатели выше и в разделах1–9 — история отдельных этапов.
|
||||
|
||||
1. Подтверждённый дефект: deleted не учитывается stream_member_load
|
||||
|
||||
src/stream/stream_member.c:166–167 выбирает строку только по node_id. Условие
|
||||
source=0 проверяется ниже, но столбец deleted не выбирается и не проверяется.
|
||||
Проверка дерева приглашений и подписи профиля не заменяет проверку блока владельца:
|
||||
после принятого deleted=yes эти подписи участника остаются корректными.
|
||||
Поэтому loader возвращает STREAM_OK и публикует прежние stream-опции/ключ.
|
||||
|
||||
Это расходится с допуском CHAT (source=0, deleted=0), закреплённым в topo_group.h,
|
||||
и с результатом topo_node_sqlite_member_allowed. stream_member_supernode уже
|
||||
отбирает deleted=0. Существующий тест deleted проверяет только supernode lookup.
|
||||
Обработчик member_changed в stream.c:539–546 использует stream_member_load:
|
||||
данный результат не запускает ветку инвалидирования STREAM_CLOSE_MEMBER.
|
||||
Для исходной реализации полный сетевой сценарий продолжения выдачи после удаления не проверялся;
|
||||
обход сетевой авторизации этим воспроизведением не заявляется.
|
||||
|
||||
Изолированная проверка
|
||||
|
||||
Настоящее ядро с SQLite :memory:, отдельные ключи канала и тестового участника.
|
||||
Join/update/tree подписи создаются штатными функциями и принимаются member_sync_put.
|
||||
В профиле корректные stream_source/subscriptions. Участник отличается от локального
|
||||
узла; никаких удалённых узлов или пользовательской БД проверка не затрагивает.
|
||||
Затем через member_sync_put применяется подписанный ключом владельца блок
|
||||
{"ver":"1","deleted":"yes"}; прямой UPDATE deleted для воспроизведения не используется.
|
||||
|
||||
Фактический лог:
|
||||
before load=0 allowed=1; after load=0 allowed=0 published=1; expected load=-9
|
||||
|
||||
Тест завершился exit=1 на несовпадении с ожидаемым STREAM_ERR_NOT_MEMBER.
|
||||
Стенд: /tmp/stream-audit-member-deleted.c.
|
||||
Сборка через существующие Makefile/LIBUTUN/COMMON_LIBS и дополнительную цель:
|
||||
make -C tests -f Makefile -f /tmp/stream-audit-member.mk /tmp/stream-audit-member-deleted
|
||||
Логи: /tmp/stream-audit-member-build.log, /tmp/stream-audit-member-deleted.log.
|
||||
Runtime выполнен вне sandbox из-за ограничений сокетов uasync.
|
||||
|
||||
Предлагаемый вариант исправления
|
||||
|
||||
Добавить deleted в список полей этого же SELECT и до проверки профиля вернуть
|
||||
STREAM_ERR_NOT_MEMBER для удалённого участника, с отдельным логом причины и ID.
|
||||
Вторая SQL-проверка и новый API не требуются. Вариант WHERE deleted=0 тоже
|
||||
отсечёт запись, но существующий лог "member absent" не отличит удаление от отсутствия.
|
||||
Сохранить различие отсутствующего/удалённого участника и неполного профиля (WAIT),
|
||||
атомарность state/public_key при отказе.
|
||||
Регрессия должна проверить подписанное удаление источника/локального участника/
|
||||
поставщика, закрытие соответствующих handles и отсутствие выдачи новых DATA.
|
||||
После исправления продолжить сверку оставшихся заголовков и сетевых контрактов.
|
||||
Реализация не исправлялась: аудит остановлен по правилу AGENTS.md:10.
|
||||
|
||||
2. Выполненная сверка
|
||||
|
||||
Все 18 заголовков src/stream прочитаны предварительно. Полностью прочитаны
|
||||
10 реализаций: stream_block/source/buffer/media/media_buffer/wire/member/metrics/
|
||||
load/playback_clock. Другие реализации stream, включая playback_audio.cpp,
|
||||
network/optimizer/publisher/viewer и stream.c целиком, ещё не проверены.
|
||||
В stream.c просмотрена только затронутая ветка member_changed; полный аудит
|
||||
жизненного цикла сервиса этим не заявляется.
|
||||
|
||||
Завершены и готовы к [V]: stream_proto.h, stream_block.h, stream_source.h,
|
||||
stream_media.h. Исправлены описание полного размера части и области подписи,
|
||||
предусловия BE helpers, Ed25519 seed/ключ, владение и ошибки codec/source,
|
||||
нулевой write/accepted, отсутствие END в source_finish, ограничения uAV2,
|
||||
пересчёт first_frame_offset и атомарность views/выходов при ошибке.
|
||||
В этих четырёх заголовках изменены комментарии/оформление, C-токены сохранены.
|
||||
|
||||
Частично проверены [~]: stream_buffer.h, stream_load.h, stream_media_buffer.h,
|
||||
stream_member.h, stream_metrics.h, stream_playback_clock.h, stream_wire.h.
|
||||
Их реализации прочитаны, но оставшиеся уточнения контрактов не внесены после
|
||||
остановки. В stream_member.h добавлено предупреждение о подтверждённом дефекте.
|
||||
|
||||
Остальные семь заголовков остаются [ ]: stream.h, stream_network.h,
|
||||
stream_optimizer.h, stream_playback_audio.h, stream_priv.h, stream_publisher.h,
|
||||
stream_viewer.h. Прочтение одного заголовка без реализации не закрывает аудит.
|
||||
doc/stream.txt остаётся [~]: просмотрены начальные разделы о FEC, подписи,
|
||||
кольце и выдачах; последующие сетевые/media/desktop/Android разделы не сверены.
|
||||
Добавлен статус аудита.
|
||||
Полные .h/doc контракты всего модуля ещё не закрыты.
|
||||
|
||||
3. Проверки и ограничения
|
||||
|
||||
Сборка диагностического стенда PASS; отрицательная проверка deleted воспроизвела дефект.
|
||||
Профильные наборы stream, полный check.sh, санитайзеры, Windows/FreeBSD и
|
||||
GUI/Android runtime не запускались. Неизменность C-токенов заголовков и
|
||||
git diff --check — PASS; успешные сетевые/медиа тесты не заявляются.
|
||||
Предыдущие изменения Local_transport и чужие записи чек-листа сохранены.
|
||||
|
||||
4. Архитектурная проверка предлагаемого исправления — 26.10.2026
|
||||
|
||||
Вывод: проверка deleted в stream_member_load архитектурно корректна. На этом
|
||||
этапе проверка выполнялась на временной копии модуля без изменений .c проекта;
|
||||
внедрение описано в разделе 5. Статусы полного аудита каталога этим не закрываются.
|
||||
|
||||
Граница ответственности
|
||||
|
||||
Блок A участника описывает личность/stream-опции. Блок B владельца определяет
|
||||
в том числе исключение из канала. Корректный A не отменяет запрет из B.
|
||||
member_apply_record проверяет подпись B и его версию; owner_put обновляет
|
||||
adm_tags/adm_tags_sig/storage/deleted одним UPDATE. После успешного commit
|
||||
member_committed посылает props-событие; Merkle-page уведомляет после commit
|
||||
всей страницы. Поэтому достаточно прочитать принятую проекцию deleted в том же
|
||||
SELECT, не вводя вторую криптопроверку блока B или отдельный SQL-запрос допуска.
|
||||
|
||||
STREAM_ERR_NOT_MEMBER уже обработан в stream_open_check, каталоге источников,
|
||||
member_changed/service_reconcile, authorized и network_membership_changed.
|
||||
Событие источника/self немедленно ставит invalidated, затем reconcile закрывает
|
||||
сессию. session_find/handle_active/session_deliver учитывают invalidated до
|
||||
отложенного освобождения. Закрытие сохраняет владение handles у caller до close.
|
||||
Для исключённого поставщика закрываются его flow, а локальная сессия источника
|
||||
может продолжать получать от другого поставщика. Сфера запрета — конкретный
|
||||
канал; общий ETCP/NCD и владельцы других каналов не должны закрываться.
|
||||
|
||||
Результаты реального стенда
|
||||
|
||||
В /tmp/stream-arch-member.c добавлены только поле deleted в существующий SELECT
|
||||
и ветка NOT_MEMBER с причиной в логе до разбора профиля. Копия связана перед
|
||||
штатной libutun.a, остальные модули взяты из существующей сборки.
|
||||
Стенд использует fixture test_stream_service: четыре живых ядра, два канала,
|
||||
подписанные owner/member записи, настоящий Merkle, UDP/TCP и NCD.
|
||||
|
||||
Все четыре группы проверок PASS:
|
||||
- Неполный профиль/placeholder остаётся WAIT; отсутствие и deleted дают
|
||||
NOT_MEMBER; state/public_key не меняются на отказе. Неверная подпись владельца
|
||||
отвергается, старая версия не снимает запрет, более свежая разрешает участие.
|
||||
- Исключение источника сразу закрывает receive с MEMBER и инвалидирует reader;
|
||||
новых DATA callback нет. Запрет через Merkle останавливает сам publisher.
|
||||
Handle другого канала и транспорт с дополнительным NCD-владельцем сохраняются.
|
||||
- Исключение поставщика закрывает только его локальную выдачу; reader источника,
|
||||
другой канал и upstream другого ядра в этот момент остаются активными.
|
||||
- Исключение self закрывает handles данного канала и запрещает новые publish/
|
||||
subscribe. Другой канал остаётся активен. После разрешения новый open работает,
|
||||
прежние отсоединённые handles не оживают. Новая публикация получает новую эпоху.
|
||||
|
||||
Итоговый exit=0, teardown flows=0 и ноль оставшихся tracked allocations.
|
||||
Сборка: /tmp/stream-arch.mk, /tmp/stream-arch-build-final.log.
|
||||
Кейсы: /tmp/stream-arch-cases.h; harness: /tmp/stream-arch-service.c.
|
||||
Результат: /tmp/stream-arch-results-final.log.
|
||||
Запуск: /tmp/stream-arch-service --architecture-only, вне sandbox.
|
||||
|
||||
Первый прогон /tmp/stream-arch-results.log неверно пытался повторно использовать
|
||||
publisher после распространённого через Merkle запрета: старый publisher уже
|
||||
правильно закрыт. Ожидание стенда исправлено на новый запуск после разрешения,
|
||||
сама временная проверка deleted при повторе не менялась.
|
||||
|
||||
Гарантии и ограничения
|
||||
|
||||
Уведомления/освобождение остаются отложенными; нужен обычный uasync loop.
|
||||
Проверено отсутствие новых DATA callback у исключённого reader. Уже принятые
|
||||
ETCP пакеты не отзываются, удалённые узлы узнают запрет при его доставке через
|
||||
Merkle. Архитектурная проверка не обещает мгновенного глобального отзыва данных.
|
||||
Полный check.sh, санитайзеры и платформенные/GUI runtime не запускались.
|
||||
На этапе архитектурной проверки C-код репозитория не менялся; последующее
|
||||
внедрение и постоянная регрессия описаны ниже.
|
||||
|
||||
5. Внедрение согласованного исправления — 26.10.2026
|
||||
|
||||
В stream_member_load существующий SELECT дополнен deleted. Для удалённой записи
|
||||
до crypto возвращается STREAM_ERR_NOT_MEMBER, с отдельным DEBUG_WARN о запрете
|
||||
владельца, group_id/member_id. Другого SQL-запроса, нового API или формата нет.
|
||||
Остальные результаты loader, включая WAIT, и атомарность выходов сохранены.
|
||||
Предупреждение о неисправленном дефекте в stream_member.h заменено действующим
|
||||
контрактом. Задача deleted в doc/tasks.md закрыта, текущий статус stream.txt уточнён.
|
||||
|
||||
Постоянная регрессия — test_stream_member в check_PROGRAMS. Тест использует
|
||||
test_stream_service.c с теми же четырьмя ядрами/подписанными записями/сетью,
|
||||
а STREAM_MEMBER_TEST_ONLY выбирает изолированный сценарий в отдельном процессе.
|
||||
Fixture не дублируется, её изменённые member-версии/активность не влияют на
|
||||
обычный test_stream_service. Сценарий доступен также через --member-only.
|
||||
|
||||
Проверены подписи/версии владельца, WAIT/NOT_MEMBER и сохранность выходов,
|
||||
удаление источника/поставщика/self, причины закрытия, отсутствие новых DATA
|
||||
callback после запрета, распространение через Merkle до остановки publisher,
|
||||
сохранность чужого NCD-владения и работоспособность другого канала после
|
||||
исключения self. Разрешение не оживляет старые handles; новая публикация
|
||||
проверяется на новую эпоху. Ожидание удалённой остановки зависит от состояния,
|
||||
а не фиксированного sleep; teardown подтверждает отсутствие tracked allocations.
|
||||
|
||||
./build.sh --no-gui -j4 PASS: /tmp/stream-member-fix-build.log.
|
||||
Профиль7/7 PASS: stream_member, stream_service, stream_core, stream_media,
|
||||
stream_playback, stream_tx_stats, android_stream.
|
||||
Сводка /tmp/stream-member-fix-profile-results.log,
|
||||
подробности /tmp/stream-member-fix-test_*.log.
|
||||
Последующий перенос длинной строки лога не меняет содержимое сообщения;
|
||||
обе test-stream цели пересобраны, test_stream_member повторно PASS:
|
||||
/tmp/stream-member-fix-test-rebuild.log, /tmp/stream-member-fix-test_stream_member-final.log.
|
||||
|
||||
Полный Linux check.sh:195/195 PASS,0 failed/0 skipped; proxy9/9,burst PASS,
|
||||
load8/8,134217728 байт,167 секунд. Лог /tmp/stream-member-fix-full-check.log.
|
||||
git diff --check PASS. Sanitizers и нативные Windows/FreeBSD/GUI/Android runtime
|
||||
в этом этапе не запускались; test_android_stream — тест общего C bridge на Linux.
|
||||
Отметки полного аудита заголовков не расширены: устранение одного дефекта
|
||||
не завершает оставшуюся сверку src/stream и всего doc/stream.txt.
|
||||
|
||||
6. Продолжение аудита: помещающийся Opus не упаковывается — 26.10.2026
|
||||
|
||||
Полностью прочитаны stream_playback_audio.cpp, stream_viewer.c, stream_publisher.c
|
||||
и их заголовки. Всего прочитаны13 реализаций; полные stream.c/network/optimizer
|
||||
пока не сверены. До исправления документов трёх новых модулей подтверждён дефект,
|
||||
поэтому publisher.h/viewer.h/playback_audio.h отмечены[~], не[V].
|
||||
Теперь4 заголовка[V],10[~],4[ ]; doc/stream.txt[~] сохраняется.
|
||||
|
||||
Причина
|
||||
|
||||
В publisher_block (stream_publisher.c:145) перед каждым Opus проверяется
|
||||
used + STREAM_MEDIA_AUDIO_SIZE + packet_length + STREAM_MEDIA_VIDEO_SIZE >= capacity.
|
||||
Резерв video descriptor добавляется всегда, включая полностью пустую video-очередь.
|
||||
В результате допустимая audio-only запись, которая целиком помещается в uAV2,
|
||||
не попадает в block. При отсутствии видео publisher_block возвращает WAIT,
|
||||
actor повторяет попытки и после MEDIA_AGE_US=250000 удаляет принятый пакет.
|
||||
Штатный лог говорит audio expired, не объясняя блокировку из-за лишнего резерва.
|
||||
|
||||
Фактическое воспроизведение
|
||||
|
||||
Один настоящий локальный publisher, отдельный автоматически созданный канал,
|
||||
SQLite в памяти, stream_subblocks=1/stream_rs_redundant=0. Ёмкость содержимого
|
||||
FEC-пачки1125 байт. Видео в очереди нет. Codec Opus48kHz stereo/20мс:
|
||||
штатный opus_codec_encode плюс opus_packet_pad; оба тестовых пакета дают
|
||||
opus_packet_get_nb_samples=960 и действительно декодируются в960 frames/channel.
|
||||
Самостоятельный stream_media_encode успешно помещает оба блока в ту же ёмкость.
|
||||
После publisher feed принимаются настоящие RS-пачки, проверяется подпись,
|
||||
содержимое декодируется штатным stream_media_parse в DATA callback.
|
||||
|
||||
packet=1044 valid_samples=960 media_bytes=1092 capacity=1125 feed=1 delivered=1 queued=0 state=LIVE
|
||||
packet=1045 valid_samples=960 media_bytes=1093 capacity=1125 feed=1 delivered=0 queued=0 state=LIVE
|
||||
В логе между этими строками: publisher audio expired pts=1020000.
|
||||
Результат: expected=2 received=1, exit=1; после destroy нет оставшихся tracked allocations.
|
||||
Для каждой проверки отсчитано500мс: больше TTL и периода обновления TX snapshot.
|
||||
Это не переполнение TX, не испорченный Opus и не нехватка места в полной пачке.
|
||||
|
||||
Стенд: /tmp/stream-audit-audio-pack.c, дополнительная цель /tmp/stream-audit-audio-pack.mk.
|
||||
Сборка через штатные Makefile/LIBUTUN/COMMON_LIBS/test_clock.o PASS:
|
||||
/tmp/stream-audit-audio-pack-build.log. Полный лог /tmp/stream-audit-audio-pack.log.
|
||||
Первый диагностический запуск остановился на неверной константе capacity1133
|
||||
в стенде; она заменена вычислением границы из фактической ёмкости1125.
|
||||
Реализация проекта при диагностике не менялась.
|
||||
|
||||
Предлагаемый вариант исправления
|
||||
|
||||
Упаковка должна учитывать фактически добавляемые описания/данные и допускать
|
||||
audio-only пачку, если целый Opus в неё помещается. Резерв под видео не должен
|
||||
навсегда блокировать принятую audio-запись. Простой вариант — заполнять audio
|
||||
до его реальной ёмкости, затем видео в оставшееся место; нехватка места под видео
|
||||
означает следующую пачку, а не потерю audio. Wire и неделимость Opus сохраняются.
|
||||
Если важно гарантировать часть видео в каждой смешанной пачке, нужен явный
|
||||
переход к отдельной audio-only пачке для такой головы очереди; это более сложный вариант.
|
||||
|
||||
Проверить1044/1045 байтов, максимальное audio-only содержимое1077 для FEC1+0,
|
||||
точное заполнение capacity, очередь видео/пустую очередь и прежний default8+0.
|
||||
Отдельно различать запись, которая вообще не помещается в доступную FEC-пачку,
|
||||
и запись, не поместившуюся только в текущий остаток: диагностика должна показывать
|
||||
packet_length, остаток/полную ёмкость и причину, не маскируя всё как expiry.
|
||||
Реализация не исправлялась; дальнейший аудит остановлен по AGENTS.md:10.
|
||||
В publisher.h добавлено предупреждение, задача открыта в doc/tasks.md.
|
||||
Новый полный build/check, sanitizers и Qt/Android runtime в этом продолжении
|
||||
не запускались; прежний check195/195 из раздела5 новый дефект не покрывает.
|
||||
|
||||
Оставшиеся уточнения новых заголовков (не выполнены после остановки):
|
||||
- playback_audio: create/destroy lifetime, producer/consumer дисциплина всех getters,
|
||||
потребление через needs_input, feed0/1/-1/-2, PLC до100мс, prepare после finish,
|
||||
EOF в empty означает извлечённый PCM, а не проигранный аппаратурой звук.
|
||||
- viewer: единый callback manager, notify без reentry, асинхронный stop до освобождения ID,
|
||||
пределы demand/video<=records/audio_ahead, нулевой снимок status до открытия,
|
||||
уничтожение manager требует остановки callers и живого instance.
|
||||
- publisher: чтение настроек FEC при actor-open, snapshot смешивает локальные очереди
|
||||
текущего источника и общие ETCP-счётчики ядра, PTS/revision/KEY admission,
|
||||
ошибки/состояния и условное сохранение кольца локальным readers после FIN.
|
||||
|
||||
7. Исправление упаковки и продвижения треков — 26.10.2026
|
||||
|
||||
Принят локальный вариант без изменения wire, публичного API и неделимости Opus.
|
||||
Без ожидающего видео рассчитывается только фактическая стоимость audio: header32,
|
||||
descriptor16, payload. Условие допуска допускает равенство полной ёмкости.
|
||||
Для обычной смешанной пачки сохранён резерв descriptor32 и хотя бы байта видео.
|
||||
Если первая audio-запись помещается только отдельно, публикуется audio-only пачка.
|
||||
Локальный video_turn отдаёт второй слот того же actor видео; coalescing20ms в этом
|
||||
слоте не применяется. Между actor-вызовами состояние не добавлено; лимит2 пачки,
|
||||
mutex, порядок PTS и смещения фрагментов сохраняются. Публикуемые записи расходуются
|
||||
только после успешных encode/publish; очередь при ошибке закрывается прежним путём.
|
||||
|
||||
Opus длиннее capacity-header32-descriptor16 actor удаляет с WARN, содержащим ID,
|
||||
PTS, bytes, max и capacity. Это окончательный отказ конкретной записи, не WAIT
|
||||
до истечения TTL. Следующая запись продолжает обрабатываться. Проверка находится
|
||||
в actor: во время OPENING producer ещё не знает выбранную FEC-ёмкость.
|
||||
feed=1 означает копирование в ограниченную очередь; асинхронный отказ и TTL
|
||||
оговорены в publisher.h и stream.txt. Удаление ошибочной записи не меняет video KEY.
|
||||
DEBUG показывает успешную отдельную аудиопачку и отложенное видео, TRACE — состав
|
||||
каждой опубликованной пачки; WARN сообщает точную причину невозможности упаковки.
|
||||
|
||||
Практический масштаб
|
||||
|
||||
Воспроизведение использует валидный Opus, искусственно увеличенный opus_packet_pad.
|
||||
Обычный desktop/Android encoder настроен AUDIO128kbit/s/20ms; средний бюджет320 байт
|
||||
не является пределом VBR. Потеря обычного естественно закодированного звука не
|
||||
подтверждена. При default8+0 capacity9833 одиночный Opus<=1275 помещается.
|
||||
Ёмкость1125 относится ко всем конфигурациям с n-r=1 при штатном RS-size, включая2/1.
|
||||
|
||||
Проверки
|
||||
|
||||
Временная копия и реальный publisher/RS/uAV2:21/21 PASS, без tracked leaks;
|
||||
/tmp/stream-plan-check.log. Старый бинарник проваливает границу1045:
|
||||
/tmp/stream-plan-baseline.log. Исходники проекта при этой проверке плана не менялись.
|
||||
|
||||
Штатный tests/test_stream_publisher.c и цель tests/Makefile.am:23/23 PASS.
|
||||
Границы1044/1045/1077/1078/1275, точная пачка из двух audio-записей, раздельные
|
||||
пачки, крупная audio-очередь32 записи, unfit в начале/середине/подряд, video-only,
|
||||
mixed и продолжения видео, OPENING, Stop/FIN, n/r1/0,2/1,2/0,8/0, лимит8 Opus.
|
||||
Callback проверяет фактические PTS/длины, декодирует Opus960 frames/channel,
|
||||
проверяет каждый байт/offset видео. Все ожидаемые данные получены до TTL250ms.
|
||||
На Linux wrap uasync_set_timeout запрещает publisher_media timer после крупного
|
||||
Opus и короткого P-кадра: оба должны публиковаться в том же actor. Проверка не
|
||||
сравнивает задержки процессора с порогом5ms. Cleanup каждого ядра восстанавливает
|
||||
tracked allocation count. Другие платформы сохраняют проверки содержимого/порядка.
|
||||
|
||||
./build.sh --no-gui -j4 PASS: /tmp/stream-publisher-fix-build.log.
|
||||
./tests/test_stream_publisher PASS: /tmp/stream-publisher-fix-regression.log.
|
||||
Перед итоговой проверкой origin/master8e746195 объединён без конфликтов в ced9dc23.
|
||||
Повторная ./build.sh --no-gui -j4 PASS: /tmp/stream-publisher-fix-merged-build.log.
|
||||
Профиль8/8 PASS: stream_publisher,core,media,playback,tx_stats,android_stream,service,member;
|
||||
/tmp/stream-publisher-fix-profile-results.log и /tmp/stream-publisher-fix-test_*.log.
|
||||
Полный ./check.sh PASS:200 passed,0 failed,0 skipped; новый publisher-тест764ms.
|
||||
Proxy9/9,burst PASS,load8/8,134217728 байт; полное время175с.
|
||||
Лог /tmp/stream-publisher-fix-full-check.log. git diff --check PASS.
|
||||
Sanitizers, Qt GUI и Android на устройстве в этом исправлении не запускались.
|
||||
Отметки аудита4[V]/10[~]/4[ ] не расширяются исправлением кода.
|
||||
|
||||
8. Аудит stream.c/optimizer и просроченный INFO_REQ — 26.10.2026
|
||||
|
||||
Полностью прочитаны stream.c1249 строк и stream_optimizer.c712 строк; теперь
|
||||
15 из16 реализаций прочитаны полностью. В stream_network.c прочитаны строки1–1615;
|
||||
оставшаяся часть, перегрузка/relay и общая сверка network ещё не завершены.
|
||||
Уточнены stream.h и stream_optimizer.h: общая сессия/позиция raw событий,
|
||||
независимость media cursors, detached ownership, FIN, режим manual/automatic,
|
||||
условия ACTIVE и mobile-supernode, параметры/оценки/сроки и recovery selector.
|
||||
Публичные структуры переформатированы с сохранением полей/порядка/типов.
|
||||
stream.h и optimizer.h отмечены[V]; network.h и priv.h остаются частичными[~].
|
||||
Итог6[V]/12[~]/0[ ]; doc/stream.txt[~]. Все остальные отметки сохранены.
|
||||
|
||||
Подтверждённое расхождение
|
||||
|
||||
prepare_requests формирует uint8_t probe[9]: probe_id8 + media_flag1.
|
||||
Raw dispatch тоже требует INFO_REQ length9. В expire_controls (stream_network.c:1147)
|
||||
для поиска связанного flow ошибочно оставлена проверка m.length != 8.
|
||||
Поэтому любой штатный INFO_REQ, просроченный в управляющей FIFO, удаляется из
|
||||
очереди после4000мс, но соответствующий flow не закрывается с OVERLOAD.
|
||||
Его info_sent остаётся1, info_ready=0, state=INFO. Повтор INFO не ставится.
|
||||
По network_tick он ждёт общий deadline5000мс и получает TRANSPORT; selector
|
||||
обрабатывает такой результат иначе, включая peer backoff. Полный цикл
|
||||
NCD/ETCP/selector и величина последующего backoff в этом воспроизведении не измерялись.
|
||||
|
||||
Диагностика
|
||||
|
||||
Стенд /tmp/stream-audit-info-expiry.c включает настоящую реализацию network,
|
||||
использует uasync/FIFO, control_put, stream_wire_parse и expire_controls.
|
||||
Это изолированная проверка очереди/flow без запуска сетевых подключений:
|
||||
время передаётся непосредственно как queued_ms + TTL, не моделируется потеря реального ETCP.
|
||||
До/на TTL4000мс все записи сохраняются; после4001мс:
|
||||
INFO_REQ9 media0: queue0/stateINFO/reason0, ожидалось CLOSED/OVERLOAD — FAIL.
|
||||
INFO_REQ9 media1: queue0/stateINFO/reason0, ожидалось CLOSED/OVERLOAD — FAIL.
|
||||
INFO_REQ9 с прежним probe_id: queue0/stateINFO — PASS, новую пробу закрывать нельзя.
|
||||
SUBSCRIBE8: queue0/stateCLOSED/reasonOVERLOAD — PASS.
|
||||
Итог2 failed из4, exit1, после cleanup tracked allocations восстановлены.
|
||||
|
||||
Сборка через штатный Makefile/LIBUTUN/COMMON_LIBS и дополнение
|
||||
/tmp/stream-audit-info-expiry.mk PASS; лог /tmp/stream-audit-info-expiry-build.log.
|
||||
Runtime: /tmp/stream-audit-info-expiry.log. Включены stream=debug, video=warn,
|
||||
debug=info; видно удаление control и отсутствие закрытия INFO-flow.
|
||||
Временная копия /tmp/stream-network-info-expiry-checked.c отличается только
|
||||
сверкой length9 вместо8. На ней тот же стенд4/4 PASS, exit0, tracked allocations
|
||||
восстановлены: /tmp/stream-audit-info-expiry-checked-build.log и ...-checked.log.
|
||||
C/C++ реализации проекта не менялись; исправлены только описания.
|
||||
|
||||
Предлагаемое исправление
|
||||
|
||||
Сверять полный INFO_REQ9 (ID8 + media flag1), оставив проверку конкретного probe_id.
|
||||
Просроченная проба должна закрываться существующим flow_close(OVERLOAD,notify0);
|
||||
новые состояния, протокол или дополнительный таймер не нужны. Старый probe_id
|
||||
не должен закрывать новую попытку. Закрепить обе media-формы, SUBSCRIBE8 и границы
|
||||
TTL отдельным штатным regression; дополнительно удержать настоящую ETCP-очередь
|
||||
дольше4с и проверить CLOSED/OVERLOAD до общего connect deadline5с.
|
||||
|
||||
Аудит остановлен по AGENTS.md:10 до изменения реализации. Задача добавлена
|
||||
в doc/tasks.md; текущий статус отражён в stream.txt и внешнем чек-листе.
|
||||
Полная сборка/check и sanitizers в этом продолжении не запускались; прежний
|
||||
check200/200 относится к исправлению publisher и новый дефект не покрывает.
|
||||
|
||||
9. Исправление INFO expiry и проверка ETCP backpressure — 26.10.2026
|
||||
|
||||
expire_controls теперь сопоставляет полный INFO_REQ9 (ID8 + media1) с текущей
|
||||
пробой. Проверка probe_id сохранена; истёкший запрос закрывает flow через прежний
|
||||
flow_close(OVERLOAD,notify0). Wire, состояния и таймеры не менялись.
|
||||
Существующий WARN дополнен bytes/age_ms/flow_closed: видно удаление конкретного
|
||||
control и наличие связанного закрытия. Код исправления c1483512, тесты b116b20b.
|
||||
Во время работы история перенесена поверх GUI-изменений; отсутствие различий
|
||||
в src/lib/tests относительно собранного d89c1efa проверено отдельно.
|
||||
|
||||
Штатный test_stream_control_expiry включён в общий check и напрямую использует
|
||||
реальные control_put/wire parser/expire_controls. Проверены границы3999/4000/4001мс:
|
||||
INFO9 media0/1 после TTL закрывают flow с OVERLOAD, несовпадающий probe_id
|
||||
не закрывает текущую попытку, SUBSCRIBE8 сохраняет прежнюю обработку.
|
||||
4/4 PASS; отслеживаемые allocations после cleanup восстановлены.
|
||||
|
||||
В test_stream_service добавлена проверка на четырёх настоящих CHAT-ядрах с Merkle,
|
||||
NCD и живым ETCP. Она выполняется в общем наборе и отдельно через --control-expiry-only.
|
||||
После прогрева NCD/INFO callback настоящей send_input_q временно снимается.
|
||||
Первый INFO остаётся в ETCP, второй — в control FIFO. Через4200мс в обоих media-режимах:
|
||||
expired_sub0 state=CLOSED reason=OVERLOAD; held_sub1 state=INFO; publication/subscription
|
||||
активны, исходное ETCP-соединение initialized/links_up. После возврата callback
|
||||
приходит INFO_RESP; просроченная проба переоткрывается и получает новый ответ через
|
||||
то же ETCP-соединение. Cleanup всех четырёх ядер восстанавливает tracked allocations.
|
||||
В общем check этот же сценарий также прошёл внутри полного test_stream_service.
|
||||
|
||||
Проверки и условия среды
|
||||
|
||||
./build.sh --no-gui -j4 PASS: /tmp/stream-info-expiry-fix-build.log.
|
||||
test_stream_control_expiry4/4 PASS: /tmp/stream-info-expiry-fix-unit.log.
|
||||
test_stream_service --control-expiry-only PASS: /tmp/stream-info-expiry-fix-integration.log.
|
||||
В изолированном сетевом прогоне включён stream=debug; unit включает video=warn,
|
||||
поэтому проверен также новый WARN с bytes9/flow_closed1 и несовпадением ID/flow_closed0.
|
||||
В fixture остаются логи отрицательных reachability-проб TCP; потоковые asserts,
|
||||
готовность транспорта, восстановление и счётчики ресурсов проверены отдельно.
|
||||
|
||||
Первый ./check.sh:198 passed,3 failed,0 skipped. Все три отказа вызваны bind(::1):
|
||||
EADDRNOTAVAIL в test_ncd_faults, test_ipv6_sockets, test_route_connectivity.
|
||||
IPv6 в основном namespace отключён (all/default/lo=1), ::1 отсутствует,
|
||||
bind(::) работает. Сводка /tmp/stream-info-expiry-fix-full-check.log;
|
||||
состояние среды /tmp/stream-info-expiry-host-ipv6.log. Код из-за этих отказов не менялся.
|
||||
|
||||
Создан отдельный временный network namespace; IPv6 включён только в нём,
|
||||
поднят lo/::1, сами проверки выполнялись от user. Все три прежних отказа PASS,
|
||||
затем ./check.sh:201 passed,0 failed,0 skipped. Новый unit32мс, service80173мс.
|
||||
Proxy9/9,burst PASS,load8/8,134217728 байт; полное время175с.
|
||||
Логи /tmp/stream-info-expiry-fix-ipv6-env.log,
|
||||
/tmp/stream-info-expiry-fix-ipv6-full-check.log и /tmp/stream-info-expiry-ipv6-test_*.log.
|
||||
Основной namespace сохранён: all/default/lo=1 и bind(::1) по-прежнему EADDRNOTAVAIL.
|
||||
Необходимость явной IPv6-диагностики runner записана отдельной задачей в doc/tasks.md.
|
||||
|
||||
git diff --check PASS. Sanitizers, Qt GUI и Android на устройстве в этом исправлении
|
||||
не запускались. Устранение дефекта не расширяет аудит:6[V]/12[~]/0[ ],doc/stream.txt[~].
|
||||
|
||||
10. Завершение сверки заголовков общего stream-ядра — 26.10.2026
|
||||
|
||||
Дочитан stream_network.c1616–2185: relay lifecycle, dispatch, общий tick,
|
||||
перегрузка нескольких пиров, учёт кольца, позиции получателей и телеметрия.
|
||||
Вместе с предыдущими этапами полностью прочитаны все15 C-реализаций и
|
||||
stream_playback_audio.cpp (всего7775 строк), все18 заголовков.
|
||||
Для уточнения контрактов повторно прочитаны полные buffer/media_buffer,
|
||||
member/wire, load/metrics, playback_clock/audio, publisher/viewer.
|
||||
|
||||
Уточнения заголовков
|
||||
|
||||
- network/priv: принадлежность caller-owned handles после detach, manual/internal
|
||||
ограничения, INFO nonce/повторы/сроки, аренда6с и RENEW2с, control TTL>4с,
|
||||
пределы общих бюджетов, готовая INFO-проба без автоматического close,
|
||||
первый DATA как структурная часть до проверки подписи, диапазоны/RTT/validity,
|
||||
restart с сохранением consumer floor, relay READY/BOUND и сроки помощи.
|
||||
- load/metrics: A/D/X/Q, атомарность add/remove, revision и окна роста/rates,
|
||||
live-счётчики против последнего снимка rates, rebase и 16-битный timestamp,
|
||||
первая пара для rate, строгий порог остановки, END и старение истории.
|
||||
- buffer/media_buffer: размер слотов против фактических wire-байтов,
|
||||
заимствованные данные/observer, поиск частей против проверки подписи,
|
||||
неизменяемый набор k после decode, неподписанные timing/telemetry,
|
||||
независимые позиции треков, generation/GAP/revision, status до KEY,
|
||||
bounded MORE/WAIT, audio lookahead и продолжение последнего кадра после finish.
|
||||
- member/wire: parse/format против доверенного load, BGP отдельно от членства,
|
||||
собственный online, роль deleted=0, переключатель audio против codec эпохи,
|
||||
конкретные размеры/предусловия writers/readers, путь, телеметрия и подпись END.
|
||||
- playback_clock/audio: состояние регулятора/история и часы, допустимые входы,
|
||||
producer/consumer и внешняя блокировка, 20-мс stereo output, PLC/разрыв,
|
||||
needs_input/prepare/finish, hardware frame origin, empty до опустошения устройства.
|
||||
- publisher/viewer: асинхронное открытие/stop, время занятия session ID,
|
||||
KEY/revision/admission/TTL, feed как принятие очередью, mute без смены codec,
|
||||
общие ETCP-счётчики instance, notify под lock/исключительный destroy,
|
||||
владение батчем, диапазоны demand и нулевые снимки до обновления actor.
|
||||
|
||||
Последние изменения этих12 заголовков — комментарии/оформление. Сравнение
|
||||
лексических C-токенов с сохранённой до правок базой проверяет имена, типы,
|
||||
порядок полей, объявления и выражения; все12 совпали. Публичный API/ABI,
|
||||
состояния, протокол и C/C++ логика в данном продолжении не менялись.
|
||||
Новых подтверждённых дефектов реализации в этой части аудита не найдено.
|
||||
|
||||
В stream.txt согласованы native-разделы: текущая версия wire7, INFO_REQ9,
|
||||
сроки и закрытие control, совместимость через NCD без собственного
|
||||
route_connectivity probe-движка, структурная часть против подписанной пачки,
|
||||
60-секундные rates, recovery без сброса media и отсутствие пропуска по одному
|
||||
возрасту DATA. Уточнено, что relay может готовить media-индекс по want_media.
|
||||
Старые результаты wire6/utun2 помечены историческими.
|
||||
|
||||
Покрытие:18[V]/0[~]/0[ ] в src/stream. doc/stream.txt[~]: ещё нужны
|
||||
полная сверка desktop/Android capture/decoder/audio/UI и внешнего
|
||||
tests/stream_netns, их аппаратные и старые ASAN-результаты здесь не перепроверены.
|
||||
Завершение заголовков не объявляет эти внешние компоненты проверенными.
|
||||
|
||||
Проверка ./build.sh --no-gui -j4 PASS: /tmp/stream-header-audit-build.log.
|
||||
Полный ./check.sh PASS:201 passed,0 failed,0 skipped; proxy9/9,burst PASS,
|
||||
load8/8,134217728 байт. Полное время172с. Все9 профильных core/media/playback/
|
||||
tx_stats/Android bridge/service/member/publisher/control_expiry наборов PASS.
|
||||
Лог /tmp/stream-header-audit-check.log; условия /tmp/stream-header-audit-netns.log.
|
||||
Прогон выполнен от user в отдельном network namespace с IPv6/lo/::1;
|
||||
основной namespace сохранён (all/default/lo.disable_ipv6=1).
|
||||
Неизменность C-токенов12/12, отсутствие правок C/C++ реализаций/lib/tests
|
||||
относительно5259c746, строки<=150, diff --check и отметки чек-листа PASS:
|
||||
/tmp/stream-header-audit-contract-check.log. После прогона менялись лишь
|
||||
два комментария (Ed25519 public key и ненулевая база expected interval);
|
||||
лексические токены проверены повторно, новый прогон не требовался.
|
||||
Sanitizers, отдельные Qt/C++ audio tests, desktop GUI и Android на устройстве
|
||||
в этом продолжении не запускались. Новых runtime-проверок многопроцессного
|
||||
tests/stream_netns не было; тестовый namespace здесь служил только средой check.sh.
|
||||
+6
-2
@@ -31,7 +31,8 @@ nat=debug
|
||||
Без `enabled=yes` модуль выключен. Без порта используется 3478. Список сохраняет порядок конфига.
|
||||
При включении нужны минимум две строки `server`; для измерения выбираются два ответивших сервера с различными IP.
|
||||
Совпадающие IP после DNS пропускаются. DNS асинхронный, с использованием существующего IPv4 A-резолвера.
|
||||
IPv6, SOCKS UDP и `only_local` пропускаются с явным статусом; TCP не проверяется.
|
||||
IPv6 и отсутствие выбранного IPv4 дают `SKIPPED` с причиной `unsupported socket`.
|
||||
TCP, служебные `probe_only`, SOCKS UDP и `only_local` исключены из списка проб: API возвращает для них -1.
|
||||
|
||||
## Результат
|
||||
|
||||
@@ -40,6 +41,8 @@ IPv6, SOCKS UDP и `only_local` пропускаются с явным стат
|
||||
Имя сначала разрешается в текущий рабочий сокет, затем в группу. API ядра
|
||||
`stun_nat_get_socket_result(instance, socket, &result)` дополнительно проверяет
|
||||
текущие IP/netif и параметры маршрутизации. Несколько рабочих портов получают общий снимок.
|
||||
Нулевой возврат означает наличие снимка, включая PENDING/FAILED/SKIPPED: вызывающий проверяет статус
|
||||
и возраст `measured_at_tb` относительно `STUN_NAT_TTL_TB`. -1 не меняет выходную структуру.
|
||||
|
||||
- `local` — тестовый IP:port; `servers[2]` и `mapped[2]` — реальные серверы и наблюдавшиеся внешние адреса;
|
||||
- `translated`: -1 неизвестно, 0 трансляция не наблюдалась, 1 внешний адрес отличается;
|
||||
@@ -89,7 +92,8 @@ TTL результата 300 секунд, refresh 180 секунд; непол
|
||||
Во время штатного refresh доступен прежний результат до TTL; его возраст не обновляется.
|
||||
Использованные тестовые порты не переиспользуются в течение жизни ядра, включая reload STUN.
|
||||
История портов освобождается только при окончательном destroy ядра.
|
||||
`stun_nat_recheck(instance)` явно повторяет измерение на новых портах, в том числе после смены маршрута без смены IP.
|
||||
`stun_nat_recheck(instance)` сверяет текущие пути и явно повторяет измерение каждого ровно один раз
|
||||
с новым generation/портом и без старого кэша, в том числе после смены маршрута без смены IP.
|
||||
Публичные операции выполняются в uasync-потоке экземпляра.
|
||||
|
||||
INFO содержит начало измерения и итог с адресами, классификацией и причинами UNKNOWN.
|
||||
|
||||
+700
-15
@@ -4,16 +4,665 @@
|
||||
|
||||
## Текущие задачи
|
||||
|
||||
[ ] **member_files: cleanup удаляет чужой temp при отказе создания загрузки** — воспроизведено 24.10.2026 на a198cfb9.
|
||||
test_member_files_network падает в фазе23: локальный O_EXCL open возвращает MF_EXISTS,
|
||||
затем TRANSFER_FINISH без проверки владения вызывает mf_disk_local_remove(t->temp).
|
||||
Прежнее назначение37байт сохраняется, заранее созданный чужой .part исчезает.
|
||||
Проверены общий check.sh и отдельный повтор: одинаковый FAIL preservation case=0, target=1, foreign_temp=0;
|
||||
отсутствие .part дополнительно проверено в обоих оставшихся тестовых каталогах.
|
||||
Нужен учёт успешного создания собственного temp, включая отмену до PREPARE и отказ max_bytes;
|
||||
проверять/удалять можно только созданный этой передачей файл. Код этого модуля в ETCP-ветке не менялся.
|
||||
Логи: /tmp/etcp-solo-native-full-check.log, /tmp/etcp-solo-member-network-repeat.log;
|
||||
подробный первый лог: /tmp/etcp-solo-member-network-suite-failed.log.
|
||||
[+] **BLE/ETCP: ограничить очередь удалёнными ACK GATT-фрагментов** — 29.10.2026.
|
||||
BLE version3: циклический ID16bit каждого DATA GATT-value, отдельная ACK Write/Indicate характеристика.
|
||||
Окно12500байт на направление = фиксированные2Мбит/с ×50ms; локальный completion не возвращает кредит.
|
||||
Следующий ACK не раньше15ms после completion предыдущего; DATA/INVITE получают время даже при медленном ATT.
|
||||
Третья подписка обязательна до HELLO. Дубликаты/старые ACK игнорируются, неверный ACK и15s без прогресса логируются/закрывают link.
|
||||
Новые регрессии: строгий duplex byte bound, ACK>=15ms,70000 IDs, медленный20ms ACK completion, timeout и lifecycle.
|
||||
Desktop central:512/512 echo за72.344s, суммарно21232B/s; Android central:512/512 за115.141s,13340B/s.
|
||||
По768000 проверенных байт в каждом направлении в каждой роли. HCI TX queue <=0.174s/0.358s, ранее до20..26s.
|
||||
Максимум DATA flight8713/10204байт на desktop и4442/4411 на Android, ниже12500 во всех пробах.
|
||||
C-ядро230/230 + proxy/burst/load, BLE/BlueZ/identity, GUI/ETCP/packet-link, Android unit tasks PASS.
|
||||
ASAN/UBSan3/3 без ошибок/утечек; desktop/APK и WinRT cross-build PASS. Физический Windows не проверен.
|
||||
Результаты, параметры проб и пути диагностических журналов: [ble_etcp.txt](ble_etcp.txt).
|
||||
|
||||
[+] **ETCP: учитывать измеренный RTT больше секунды в RTO** — 29.10.2026.
|
||||
На BLE измерялся RTT2..6s, но RTO=1s удалял попытку до ACK и добавлял повтор в ту же очередь.
|
||||
Сохранены формула1.2*max(DATA_RTT,KA_RTT)+1.5*jitter+3ms, минимум10ms и начальное1s без RTT.
|
||||
Верхняя граница теперь UINT32_MAX, расчёт uint64; насыщение логируется WARN.
|
||||
test_etcp_windows:63 сценария PASS, включая RTT2s/ACK1.5s, настоящий таймер7.8672s и переполнение.
|
||||
Исправлена фикстура server_lifecycle: успешную отправку задаёт codec, busy/error не увеличивают keepalive counter.
|
||||
Итоговый ./check.sh230/230 и proxy/burst/load PASS; desktop GUI/probe и Android APK/instrumentation собираются.
|
||||
|
||||
[+] **BLE: ждать ServicesResolved и запрашивать HIGH для incoming Android** — 29.10.2026.
|
||||
BlueZ ждёт Connected/ServicesResolved до подписок и перечитывает MTU; регрессия кеша MTU23→517 проходит.
|
||||
Android использует дополнительный BluetoothGatt handle того же ACL только для HIGH, с явным teardown.
|
||||
HCI подтвердил входящий запрос 11.25..15ms и согласование 15ms/latency0/timeout5s без ручной настройки.
|
||||
Desktop и Android собираются, private-bus BlueZ и профильные BLE/packet-link/unit тесты проходят.
|
||||
|
||||
[+] **BLE: DATA без ATT ACK и профиль скорости desktop/Android** — 29.10.2026.
|
||||
Write Without Response/Notification для DATA в Android/BlueZ/WinRT; INVITE сохраняет Request/Indication.
|
||||
Android HIGH/MTU517/2M, Windows ThroughputOptimized с проверкой API и фактическими параметрами.
|
||||
Сводка скорости/очередей/ожиданий GATT; нагрузочная проба с ограниченным окном и проверкой содержимого.
|
||||
Общие тесты 230/230 + proxy/burst/load, BLE 5/5, ASAN/UBSAN 3/3, Android unit 770/770.
|
||||
Desktop и APK собираются; WinRT cross-build PASS. Ограничение обратной роли вынесено выше.
|
||||
|
||||
[ ] **Windows: единые UTF-8 пути конфигурации** — 29.10.2026.
|
||||
GUI передаёт toUtf8, а config_parser/config_updater используют fopen и ANSI Win32 API.
|
||||
Перевести эти операции на существующий file_io с wide-путями, проверить профиль в каталоге с кириллицей.
|
||||
Пути журнала исправлены при добавлении ротации: wide open/rename/remove, проверены Windows-тестом через Wine.
|
||||
Это отдельный риск; причиной отказа присланного 2.log на Windows он не подтверждён.
|
||||
|
||||
[+] **Вложения: отправка независимо от лимита автозагрузки, метаданные фото/видео и результат GUI** — 28.10.2026.
|
||||
Исходящие файлы не ограничиваются storage_unit_size; defaults автозагрузки/кеша — 100 MiB/1 GiB.
|
||||
Парсер принимает подписанный хвост dur/w/h; канальный файл остаётся в composer до подтверждения, отказ показывает имя.
|
||||
test_chat_attachment и четыре теста GUI PASS; на SM фотография скачалась и совпала с desktop по SHA-256.
|
||||
|
||||
[ ] **Тестовая среда: явно проверять доступность IPv6 loopback перед общим прогоном** — 28.10.2026.
|
||||
При disable_ipv6=1 отсутствует ::1: test_ncd_faults, test_ipv6_sockets и test_route_connectivity падают на bind.
|
||||
Зафиксировать требование к среде или явный SKIP соответствующих сценариев; текущий ./check.sh: 222 PASS, 3 FAIL.
|
||||
|
||||
[+] **chat headless: полный разбор JSON,целые TCP-кадры и результат публикации** — 27.10.2026.
|
||||
SQLite проверяет весь объект до dispatch;параметры типизированы и копируются без усечения,дубли/NUL/JSON5 запрещены.
|
||||
TX сохраняет FIFO при short write/EAGAIN и освобождает отправленный префикс;переполнение закрывает клиента с WARN.
|
||||
Status/candidates/invite names используют checked JSON escaping;прежний event handler сохраняется/восстанавливается.
|
||||
send проверяет результат,attach отвечает из нового media completion после commit/отказа;disconnect отсоединяет адресата.
|
||||
Контекст completion освобождается после закрытия control;quit дописывает bye,init полностью убирает частичные ресурсы.
|
||||
test_chat_headless_control:настоящие TCP/SQLite/media,Unicode/опросы,SQL finalize fault,short write/pressure,
|
||||
async disconnect и handler restore PASS;services/chat_attachment/media_index_regressions PASS.
|
||||
|
||||
[+] **chat: атомарное глобальное имя и подписанный снимок** — 27.10.2026.
|
||||
Один commit сохраняет local_identity,nodes,NODEINFO и полные собственные профили каналов/Merkle.
|
||||
Память/LAN/уведомления меняются после commit;отказ сохраняет все представления,no-op не меняет версии.
|
||||
Чтение timestamp различает отсутствие/SQL,запись snapshot проверяет SQL/finalize под savepoint.
|
||||
test_chat_profile:32 SQL fault/retry,crypto/veto/no-op/foreign txn,два профиля и независимый observer PASS;
|
||||
standalone snapshot finalize failure также откатывается. Контракт:[chat_core.h](../src/chat/chat_core.h).
|
||||
|
||||
[+] **chat_whisper: per-instance модель,повтор после отказа и безопасная остановка** — 27.10.2026.
|
||||
Глобальный init/trigger заменён прямым API:ленивая загрузка в worker,кеш модели,одна задача и явный BUSY.
|
||||
Stop отсоединяет владельца;модель освобождается после join,каждый запрос получает один callback.
|
||||
Opus reader согласован с voice_file_encode (native header/lengths,stereo);WAV/ресемплинг проверяют размеры.
|
||||
Результат отправки транскрипции проверяется;отметка played — только после сохранения текста.
|
||||
Два теста:повтор/смена модели,занятость,изоляция,cancel/restart,allocation,PCM и10 rate/channel Opus вариантов PASS.
|
||||
Backend распознавания управляемый;настоящая модель не запускалась. Контракт:[chat_whisper.h](../src/chat/chat_whisper.h).
|
||||
|
||||
[+] **chat preferences: собственный commit UI-state и рассылка сохранённого звука** — 27.10.2026.
|
||||
Подтверждён commit после injected finalize error прежнего UI-state setter. Новая операция проверяет
|
||||
read/write/finalize/changes/commit под BEGIN IMMEDIATE;отказ сохраняет значение,no-op не вызывает hook.
|
||||
Sound setter возвращает результат и рассылает только committed/no-op;повтор восстанавливает доставку.
|
||||
Getter различает отсутствие/SQL/неверный тип и boolean;initial sync пропускает ошибку чтения.
|
||||
Settings wrapper сохраняет коды registry и не применяет invalid group_autoconnect;trampoline проверяют NUL.
|
||||
В звуковой форме исправлен указатель на временный QByteArray;добавлена проверка18 сохранённых значений.
|
||||
test_chat_preferences:13 UI-state+13 sound+5 reader SQL fault/retry,readonly observer,no-op/veto/triggers,
|
||||
foreign txn,malformed requests,owner stop при rollback failure PASS. Общие результаты — в отчёте.
|
||||
Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Настройки и UI-state»;core ещё частичен.
|
||||
|
||||
[+] **chat member tags: проверенный снимок,конфликт правки и атомарный commit** — 27.10.2026.
|
||||
Подтверждён ложный успех load после finalize error. Чтение закрывает SQL и проверяет owner-подпись/формат.
|
||||
Commit владеет BEGIN IMMEDIATE,отклоняет устаревший снимок (-2),фиксирует подпись/версию/Merkle вместе;
|
||||
отказ сохраняет editor и историю ролей для повтора,no-op не меняет версию/события. Приватный ключ очищается.
|
||||
GUI применяет частичные поля,сохраняя остальные;Android не сохраняет частичный набор после ошибки setter.
|
||||
ver1..INT_MAX проверяется без atoi overflow,JSON — целиком;display/JNI читают историю ролей до8192 байт.
|
||||
test_chat_profile дополнен37 SQL и10 allocation отказами/повторами,sign/veto/foreign/nested rollback,
|
||||
stale/no-op/repeated edits,строгим JSON/control,patch и долгой историей,удалением из callback;PASS.
|
||||
Общая проверка записывается в отчёт;остальные core/headless/JOIN/sync/invite/whisper продолжаются.
|
||||
Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Редактор прав участников».
|
||||
|
||||
[+] **chat profile: проверка прежней подписи, собственная транзакция и безопасные уведомления** — 27.10.2026.
|
||||
Getter публикует все выходы только после SQL/finalize,проверки JOIN/update и целого строкового JSON.
|
||||
Options/avatar читают версию,проверяют допуск,подписывают и меняют запись/Merkle под BEGIN IMMEDIATE;
|
||||
семантический no-op не меняет подпись/версию. Неподписанные старые поля не переподписываются.
|
||||
Неудачный member ROLLBACK TO не сопровождается RELEASE частичных данных;проверяется changes=1 owner/nodes.
|
||||
Callback допускает отмену подписок,вложенный dispatch и удаление группы;JOIN-подписка проверяется при старте.
|
||||
Unicode escapes больше не скрывают следующие поля профиля. test_chat_profile:20 read/54 write/9 owner/4 ACL
|
||||
SQL-отказа с повторами,crypto/trigger/veto/foreign/rollback,readonly observer и startup retry PASS.
|
||||
Общая проверка записывается в отчёт после завершения;глобальное имя/UI-state/tags и core остаются в работе.
|
||||
Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Подписанные профили».
|
||||
|
||||
[+] **chat_core JSON: полное escaping и атомарный результат чтения** — 27.10.2026.
|
||||
Подтверждён невалидный JSON для допустимого подписанного имени с ASCII01. Channels/members используют
|
||||
checked strbuf и полное escaping;все listing проверяют SQL-этапы,включая bind/finalize/вложенные запросы.
|
||||
Отказ оставляет out_len=0 и пустой допустимый buffer;нулевой/точный размер не повреждает canaries.
|
||||
Скрытый NUL/неверный тип/размер полей отклоняются. Committed-канал без history-таблицы виден с msgs=0;
|
||||
чтение не меняет БД и не завершает транзакцию caller. Новая test_chat_json:36 отказов с повторами,
|
||||
ошибка после готовой строки,все ASCII controls,UTF-8/длинные имена/8192-byte tags,IPv4/IPv6/REALITY,
|
||||
повреждённые строки и настоящая deferred activation/retry PASS. Общие результаты — в отчёте.
|
||||
Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«JSON-интроспекция».
|
||||
|
||||
[+] **chat_member/status: полный снимок,границы формата и committed mute** — 27.10.2026.
|
||||
Подтверждён ложный успех снимка после finalize error;теперь SQL/память не дают частичный список.
|
||||
Список ограничен65535 записями,сортируется qsort (online,unsigned node_id);строки wire дополняются NUL.
|
||||
Запросы/канальные ID проверяются до чтения;callback свойств использует один завершённый SELECT.
|
||||
radio_set_member_muted владеет транзакцией,кэш/аудио обновляются после commit;чат готовит снимок до изменения,
|
||||
последующий SQL-отказ не превращает сохранённый переключатель в ошибку. Чужая транзакция запрещена.
|
||||
Диагностика использует checked strbuf;member_detail проверяет finalize и согласованный error codec.
|
||||
test_chat_member:SQL/память/ABORT/IGNORE/veto/foreign/rollback,readonly DB+cache+audio observer,
|
||||
65535/65536 участников,300 линков,OOM и allocator boundary PASS. Общие проверки — в отчёте.
|
||||
Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Снимки участников и диагностика».
|
||||
|
||||
[+] **group_avatar: атомарная публикация, проверенное чтение и безопасное событие приёма** — 27.10.2026.
|
||||
Подтверждён прежний ложный успех при отказе bind SHA256. set/apply владеют BEGIN IMMEDIATE/COMMIT,
|
||||
проверяют все SQL-этапы,отклоняют чужую транзакцию;события/STATE идут после commit.
|
||||
Выход get сохраняется при отказе;ключи читаются как BLOB ровно32 байта и очищаются.
|
||||
Schema savepoint не фиксирует частичные ALTER после неудачного ROLLBACK TO.
|
||||
Приём завершает работу со сборкой/peer до callback,который может отменить задачи группы.
|
||||
Native профиль6/6 PASS;новый test_group_avatar_sql:27 SQL-отказов публикации,16 чтения,9 схемы,
|
||||
signing/ABORT/IGNORE/veto/foreign/no-op/max-version/rollback и readonly observer;повторы PASS.
|
||||
Сетевой test_group_avatar проверяет отмену peer из CHANNEL_UPDATED;header/doc сверены полностью.
|
||||
Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Аватарки групп»;общие результаты — в отчёте.
|
||||
|
||||
[+] **chat_channel: атомарное создание канала и правдивый результат команды** — 27.10.2026.
|
||||
Канальные пары ключей и подпись проверяются до записи; описание, схема, root JOIN, подписанный профиль,
|
||||
приглашение и Merkle сохраняются одним commit. Runtime-подготовка и события выполняются после него.
|
||||
Ошибка подготовки не удаляет сохранённый канал, допускается повтор ensure_channel_ready.
|
||||
Исправлены частичная схема channel_put и незамеченный отказ bind подписи профиля в member_block_put.
|
||||
Auto возвращает committed ID или0; native headless возвращает ошибку при отказе, очищаются временные ключи.
|
||||
test_chat_channel:42 SQL-отказа и19 криптоотказов с успешными повторами,ABORT/IGNORE/veto,
|
||||
чужая транзакция,ошибка rollback,границы и TCP API;readonly-проверка данных/схемы/Merkle до события.
|
||||
Профильные проверки PASS;общая проверка текущего блока записывается в отчёт после завершения.
|
||||
Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Атомарное создание канала».
|
||||
|
||||
[+] **feedback/poll: проверенное чтение версий, собственные транзакции и подтверждение уже сохранённой команды** — 27.10.2026.
|
||||
SQL-ошибка поиска не считается отсутствием старого состояния:отмена не заменяется прежней реакцией.
|
||||
apply/set/page/init владеют BEGIN IMMEDIATE/COMMIT;версия/допуск/запись/Merkle фиксируются вместе,
|
||||
уведомления — после commit. Неудачный rollback не выполняет RELEASE и запрашивает stop владельцем.
|
||||
Проверены SQL-этапы get/put/page/hash и команд/снимков poll. Ошибка чтения после commit даёт успешный
|
||||
committed/snapshot_pending;Qt/Android повторяют только feedback. Ack резервируется до изменения.
|
||||
test_chat_feedback:подтверждён старый дефект,20 SQL-отказов изменения и10 чтения,ABORT/IGNORE/veto,
|
||||
чужой no-op/page,rollback failure,readonly record/root перед событием. test_chat_poll:51 SQL-отказ,
|
||||
8 readback-отказов после commit,2 отказа памяти до изменения,полная строка emoji;повторы PASS.
|
||||
Native/ASAN профиль6/6 до readback,новая unit-регрессия readback PASS;общие результаты — в отчёте.
|
||||
Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Feedback и poll».
|
||||
|
||||
[+] **chat_attachment: неизменяемая привязка, атомарный локальный индекс и восстановление из подписанной истории** — 27.10.2026.
|
||||
bind проверяет собственный commit и запрещает замену message_id/hash; observe сохраняет прежний ключ/флаги точного дубликата.
|
||||
wanted/downloaded обновляются своей транзакцией, события идут после commit; повторное обнаружение cache не сбрасывает error.
|
||||
Отказ производного индекса не скрывает сохранённое сообщение: backfill восстанавливает запись, включая startup/manual download;
|
||||
очередь16 каналов имеет полный scan при переполнении. Ключ из истории не восстанавливается.
|
||||
Outbox фиксирует retry_at до сети; idle scan не вызывает commit hook. Recovery при открытой транзакции сохраняет файлы.
|
||||
Native профиль7/7 и последующие settings/attachment2/2 PASS;ASAN/UBSan/LSan17/17 PASS,detect_leaks=1.
|
||||
Подробности:[chat_audit_findings.txt](chat_audit_findings.txt),«Идентичность и восстановление локального индекса».
|
||||
|
||||
[+] **chat_setting: чтение const без изменения буфера, атомарный отказ валидации и повтор применения LAN** — 27.10.2026.
|
||||
Числовые строки хранятся отдельно по настройке и обновляются setter; NULL/строковый тип дают default.
|
||||
Неудачный set не инициализирует/не меняет state;пути можно очистить. Ошибка apply LAN возвращает -3,
|
||||
сохраняя желаемое значение;идентичный повтор повторяет применение. Удалён пустой общий apply.
|
||||
Новый test_chat_setting проверяет неизменность всех байтов,независимые копии,границы/ошибки и apply retry;
|
||||
native и ASAN/UBSan/LSan PASS. Общая проверка новых изменений записывается отдельным итогом аудита.
|
||||
|
||||
[+] **chat_attachment: исходящий REQUEST и outbox фиксируются вместе до уведомления/отправки** — 27.10.2026.
|
||||
Собственная транзакция повторно проверяет состояние файла,создаёт ровно один запрос и очередь.
|
||||
Внешняя транзакция откладывает весь tick;неудачный rollback не перезапускает таймер после quiesce/stop.
|
||||
test_attachment_requests:52 SQL-отказа/повтора с пустой/существующей очередью,ABORT/IGNORE,commit veto,
|
||||
внешняя транзакция до создания/отправки,readonly reader перед событием/REQUEST,реальный restart
|
||||
при отсутствии маршрута/backpressure/send failure с сохранением даты. Старый код ожидаемо FAIL новой регрессии.
|
||||
Общая проверка ниже:212/212 native,16/16 sanitizers,11/11 GUI/Android host,desktop/APK build PASS.
|
||||
Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Исходящий REQUEST»;/tmp/attachment-outgoing-*.
|
||||
|
||||
[+] **chat_attachment: захват расшифровки до worker и владение completion до commit результата** — 27.10.2026.
|
||||
decrypting=1 фиксируется собственной проверенной транзакцией;completion отличает SQL-отказ от удаления.
|
||||
Готовая задача остаётся у модуля до commit;таймер повторяет только SQL,ключ/файл не теряются и второй worker не создаётся.
|
||||
Лимит16 включает workers/завершения;quiesce останавливает повторы,destroy после workers освобождает остаток.
|
||||
test_chat_attachment:9 отказов claim,15 успешного результата,8 записи ошибки worker,ABORT/IGNORE/veto,
|
||||
внешняя транзакция,delete/stop/restart,rollback failure и16 задержанных авторских задач с сохранением17-го source.
|
||||
Старый sanitizer-runtime подтвердил ready1/infoNULL после bind NOMEM;новый профиль и баланс выделений PASS.
|
||||
Общая проверка:check.sh212/212,0 skipped,proxy9/9,burst1/1,load8/8,177с;
|
||||
ASAN/UBSan/LSan16/16 detect_leaks=1,desktop/APK build,GUI/Android host11/11 PASS.
|
||||
Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Завершение файлов»;/tmp/attachment-jobs-*.
|
||||
|
||||
[+] **chat: удалять attachment-записи атомарно с каналом,файлы — после commit** — 27.10.2026.
|
||||
SQLite-trigger каскадно удаляет ключи/запросы/outbox и проверяет полный результат;topo deletion проверяет finalize
|
||||
и отсутствие канала/Merkle-хешей. Ошибка SQL сохраняет данные/файлы;внешняя транзакция запрещена до отмены ресурсов.
|
||||
Файлы удаляются после commit,незавершённый rollback требует stop/закрытия владельцем;NULL instance безопасен.
|
||||
test_chat_attachment:14 отказов с точным readonly-снимком,ABORT/IGNORE всех таблиц и Merkle,commit veto,
|
||||
внешняя транзакция и rollback fault;до фикса подтверждены reader_keys2 вместо3 и потерянный plaintext при живом канале.
|
||||
Native build/check.sh212/212 PASS,proxy9/9,burst1/1,load8/8 (134217728байт),177с;
|
||||
ASAN/UBSan/LSan16/16 detect_leaks=1,GUI/Android host11/11,desktop/APK build PASS.
|
||||
Логи:/tmp/chat-attachment-followup-{build,full-check,asan-results,desktop-build,android-build,gui-results}.log.
|
||||
Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Удаление канала»;полный аудит chat/attachment остаётся[~].
|
||||
|
||||
[+] **chat_attachment: подтверждать входящий REQUEST после собственного commit** — исправлено27.10.2026.
|
||||
Собственная BEGIN IMMEDIATE/COMMIT включает identity,права,чтение/создание запроса и повтор прежнего ответа.
|
||||
Внешняя транзакция отклоняется без изменений;дубликаты incoming1/state0,2,3,4 сохраняют решение и created_at.
|
||||
Проверены все SQL-этапы;новая строка/очередь требуют changes1,ACK/событие следуют после commit.
|
||||
Общий rollback сохраняет прежние данные;открытая транзакция требует quiesce/stop и закрытия владельцем после poll.
|
||||
test_attachment_requests:247 SQL-отказов/повторов,commit veto,ABORT/IGNORE,внешняя транзакция,
|
||||
неверные состояния/права,независимые reader в ACK/событии,backpressure/send fault и rollback fault.
|
||||
Сеть:первый REQUEST без ACK→очередь B сохранена→restart автора→настоящий повтор→committed ACK→phase10 PASS.
|
||||
Native build/check.sh212/212 PASS,0 skipped,proxy9/9,burst1/1,load8/8 (134217728байт),173с.
|
||||
ASAN/UBSan/LSan16/16 PASS,detect_leaks=1;desktop/APK build PASS,GUI/Android host11/11 PASS.
|
||||
Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Исправление приёма REQUEST»;/tmp/attachment-request-fix-*.
|
||||
Полный аудит attachment/chat остаётся частичным;исходящие запросы и файловые completion/recovery проверяются далее.
|
||||
|
||||
[+] **chat_attachment: фиксировать решение автора вместе с исходящим ответом до успеха/UI** — исправлено27.10.2026.
|
||||
Собственная BEGIN IMMEDIATE/COMMIT включает проверку прав,state и outbox;внешняя транзакция остаётся нетронутой.
|
||||
ak_queue проверяет все SQL-этапы и одну строку,транзакцией владеет вызывающий;допуск автора проверяет finalize.
|
||||
Успех/одно событие — после commit,ошибка сохраняет прежние строки;открытая после rollback БД требует stop владельцем.
|
||||
Trampoline сообщает только ошибку команды,не дублируя успешное обновление. Новых API/форматов нет.
|
||||
test_attachment_decisions:116 отказов/повторов выдачи/отказа с пустой/существующей очередью,
|
||||
настоящий commit veto,ABORT/IGNORE,внешняя транзакция,мембер/направление/повтор,GUI и rollback fault.
|
||||
Readonly-снимки в событии проверяют точные committed key/state/outbox/retry/created и соседний ключ;allocations0->0.
|
||||
Сетевой тест:оба отказа автора→pending без события/очереди→restart→повтор→commit→GRANT→расшифровка phase10 PASS.
|
||||
Build/check.sh211/211 PASS,0 skipped,proxy9/9,burst1/1,load8/8 (134217728байт),177с.
|
||||
ASAN/UBSan/LSan15/15 PASS,detect_leaks=1;desktop/APK build PASS,GUI/Android host11/11 PASS.
|
||||
Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Исправление решения автора»;/tmp/attachment-decision-fix-*.
|
||||
Полный аудит attachment/chat остаётся частичным;отметки chat_attachment и encrypted_attachments остаются[~].
|
||||
|
||||
[+] **chat_attachment: не подтверждать GRANT без сохранённого ключа получателя** — исправлено26.10.2026.
|
||||
Ответы REQUEST_ACK/GRANT/DENY/RESULT_ACK применяются собственной проверенной транзакцией key+state+outbox.
|
||||
Проверяются все SQL-этапы и ожидаемые числа строк;ACK/событие — после commit,внешняя транзакция отклоняется.
|
||||
Полный rollback сохраняет прежний ключ/запрос/очередь;оставшаяся открытой БД требует stop владельцем после poll.
|
||||
Новый test_attachment_results:105 отказов/повторов четырёх ответов,readonly-снимки в ACK/событии,
|
||||
commit veto,ABORT/IGNORE,внешняя транзакция,дубликаты/конфликты/backpressure и rollback fault.
|
||||
Штатный сетевой test_encrypted_attachment дополнен bind-fault→без ACK→повтор GRANT→расшифровка.
|
||||
До Pull native8/8 и ASAN/UBSan/LSan8/8 PASS,detect_leaks=1;исходный сетевой стенд теперь phase10 PASS.
|
||||
После merge845bf258 с доработками редактирования чата:native14/14,ASAN/UBSan/LSan14/14 PASS.
|
||||
check.sh210/210 PASS,0 skipped,proxy9/9,burst1/1,load8/8;GUI/Android host11/11,desktop/APK build PASS.
|
||||
Подробности:[chat_audit_findings.txt](chat_audit_findings.txt),интеграция после Pull;/tmp/grant-key-fix-merge-*.log.
|
||||
Полный аудит attachment/chat остаётся частичным;дальнейший аудит остановлен по просьбе пользователя перед Pull.
|
||||
|
||||
[+] **chat_attachment: проверять сохранение ключа до передачи source и запуска worker** — исправлено26.10.2026.
|
||||
Авторская подготовка фиксируется собственной BEGIN IMMEDIATE/COMMIT до передачи source и запуска worker.
|
||||
Проверяются prepare/восемь bind/step/одна строка/finalize/commit;отказ откатывается,source остаётся caller.
|
||||
Внешняя транзакция запрещена;после commit синхронный отказ worker даёт один MSG_SUBMITTED при возврате0.
|
||||
Неуспешный rollback с открытой транзакцией снимает модуль и запрашивает stop;desktop/Android,включая restart,
|
||||
выходят после poll и освобождают ядро вне callback. Политика WAL/NORMAL и wire не менялись.
|
||||
test_chat_attachment:13 SQL-отказов,реальный commit veto,INSERT ABORT/IGNORE,внешняя транзакция,
|
||||
точный ключ/метаданные через readonly SQLite до worker,настоящие повторы,inline-отказ,restart и rollback fault.
|
||||
Native build/check.sh209/209 PASS,0 skipped,proxy9/9,burst1/1,load8/8 (134217728байт),174с;
|
||||
полный check выполнен в частном netns с IPv6 loopback — на хосте IPv6 lo отключён.
|
||||
ASAN/UBSan/LSan8/8 PASS,detect_leaks=1;core/libuasync/Opus инструментированы согласованно.
|
||||
Desktop/APK build PASS;Qt/Android host7/7 PASS. Исходный стенд теперь defects0,allocations0->0;
|
||||
отдельный старый объект из d877e888 ожидаемо нарушает новую проверку,без отката рабочей копии.
|
||||
Подробности: [chat_audit_findings.txt](chat_audit_findings.txt),исправление chat_attachment;/tmp/attachment-key-fix-*.log.
|
||||
Полный аудит chat_attachment/chat остаётся [~];прочие операции SQL и платформы Windows/FreeBSD не закрыты.
|
||||
|
||||
[+] **member_files: безопасное завершение completion при stop из uasync batch** — исправлено26.10.2026.
|
||||
После rebase на bc623f23 сохранена общая отмена из93ec1222:task удаляется и освобождается
|
||||
из общей очереди или активного batch до освобождения mf_work;локальное обнуление callback/arg убрано.
|
||||
Done доставляется ровно раз,включая обязательные cleanup continuations;нет новых аллокаций/рекурсивного poll.
|
||||
Контракты worker/uasync/docs согласованы:stop из внешней команды допустим,из собственного done запрещён.
|
||||
Постоянный test_member_files:4 барьерных сценария normal/queued destroy/detached drain/detached destroy,
|
||||
по2 работы и cleanup,правильные потоки,единственный done и полный баланс выделений.
|
||||
До rebase на прежнем worker.c новая регрессия выявила heap-use-after-free в строке45 под ASAN;
|
||||
/tmp/member-worker-negative.log подтверждает чтение после освобождения в drain,без изменения рабочей копии.
|
||||
До rebase:native/lifecycle8/8 PASS,JOIN50/50 PASS;ASAN/UBSan/LSan8/8 PASS,detect_leaks=1.
|
||||
До rebase:check.sh207/207 PASS,0 failed/0 skipped,STUN/proxy9/9/burst1/1/load8/8,182с.
|
||||
После rebase на bc623f23:native и ASAN/UBSan/LSan6/6 PASS,JOIN50/50 PASS,обе сборки PASS;
|
||||
runtime отмены/worker совпадает с origin,admin и регрессии сохранены;логи /tmp/admin-worker-rebase-*.log.
|
||||
Первоначальный SIGSEGV и повтор GDB сохранены,стек worker.c:45 → u_async.c:432 → join_faults.c:298.
|
||||
Core /tmp/admin-key-fix-join.core,бинарник /tmp/admin-key-fix-test_chat_join_faults.elf,
|
||||
разбор /tmp/admin-key-fix-join-core-analysis.log,повтор /tmp/admin-key-fix-join-repeat-gdb.log (10-й запуск).
|
||||
worker.h закрыт [V];полный аудит chat/member_files остаётся частичным,платформенные GUI не проверялись.
|
||||
Подробности: [chat_audit_findings.txt](chat_audit_findings.txt),исправление worker;/tmp/member-worker-fix-*.log.
|
||||
|
||||
[+] **chat_admin: атомарный импорт ключей и безопасный отказ NULL transfer** — исправлено26.10.2026.
|
||||
channel_set_privs проверяет savepoint/prepare/все bind/step/одну строку/finalize/RELEASE и откатывает отказ.
|
||||
Получатель отвергает внешнюю транзакцию,подтверждает только commit;незавершённый rollback требует restart.
|
||||
transfer проверяет inst до CC;SQL/crypto/OOM причины логируются без секретных байтов,wire не менялся.
|
||||
Постоянный test_chat_admin:22 SQL/crypto-отказа и отдельный rollback fault,старая пара/другой канал,
|
||||
настоящее veto commit hook,trigger ABORT/IGNORE,неверные ключи,повтор,внешняя транзакция и NULL.
|
||||
Независимое чтение пары/autocommit в событии,отсутствие ложного успеха,allocations восстановлены.
|
||||
Исходный стенд теперь exit0,defects0,bind hits3. Native build/check.sh207/207 PASS,0 failed/0 skipped,
|
||||
STUN/proxy9/9/burst1/1/load8/8 (134217728байт),201с;ASAN/UBSan/LSan5/5 PASS,detect_leaks=1.
|
||||
Первый native-профиль4/5 с SIGSEGV JOIN сохранён;отдельный дефект worker исправлен в задаче выше.
|
||||
[chat_audit_findings.txt](chat_audit_findings.txt),продолжение chat_admin;/tmp/admin-key-fix-*.log.
|
||||
chat_admin.h/topo_node_sqlite.h закрыты [V]. Полный аудит chat остаётся частичным.
|
||||
|
||||
[+] **DM media: восстанавливать завершение TTL после SQL-отказа при EXPIRED** — исправлено 26.10.2026.
|
||||
EXPIRED/REJECT атомарно освобождают назначение и сбрасывают текущий holder; все результаты SQL проверяются.
|
||||
Таймер завершает stored-назначение по сохранённому expires при offline B/потерянном EXPIRED и после restart.
|
||||
Для неподтверждённого STORE сохраняется повтор с ответом custody/tombstone M; квитанция B не отменяется.
|
||||
test_dm_media:18 отказов prepare/bind/step/finalize двух UPDATE и commit для EXPIRED/REJECT,
|
||||
точный rollback, повтор, дубликат и поздний STORED; реальная сеть A/B/S/M с потерянным EXPIRED,
|
||||
SQL-отказом, restart S, восстановлением TTL и поздними STORE/STORED; allocations0->0.
|
||||
Старый координатор ожидаемо FAIL новой регрессии; исходный one-shot IOERR-стенд теперь phase9 PASS.
|
||||
Независимое readonly чтение по точному UUID:на M custody0/tombstone1,на S holder0/stored0/expired1/released1,
|
||||
без ложной квитанции; прежняя доставленная квитанция сохранена. Native/санитайзеры подтверждают те же состояния.
|
||||
Build/DM-профиль8/8 PASS;ASAN/UBSan/LSan8/8 PASS,detect_leaks=1,core/libuasync/Opus инструментированы.
|
||||
Полный check.sh205/205 PASS,0 failed/0 skipped;proxy9/9,burst1/1,load8/8 (134217728байт),exit0,173с.
|
||||
Подробности: [dm_audit_findings.txt](dm_audit_findings.txt),раздел11; /tmp/dm-expiry-fix-*.log.
|
||||
Общий аудит DM остаётся частичным;платформенные GUI/Android/Windows/FreeBSD не проверялись.
|
||||
|
||||
[+] **DM: сохранять границы UTF-8 при усечении имени собеседника** — исправлено 26.10.2026.
|
||||
Единый предел DM_PEER_NAME_MAX=63 байта; создание, загрузка, входящее автосоздание и JSON
|
||||
копируют только целые code points. Повреждённый суффикс отбрасывается с WARN и точным смещением.
|
||||
Снимок нормализует также fallback из nodes без изменения БД; обычная запись сохраняет нормализованное имя.
|
||||
test_dm_names:68 случаев создания/SQL/load/save/fallback/restart, ASCII и 2/3/4-байтовые границы,
|
||||
overlong/surrogate/>U+10FFFF/оборванные последовательности/NUL/JSON escapes; allocations0->0.
|
||||
Независимый Python-парсер:273 JSON-снимка,9452 строки,строгие UTF-8/JSON и предел63 PASS.
|
||||
Старый core ожидаемо FAIL новой регрессии; исходный пример32×«Я» теперь сохраняет62байта и валидный JSON.
|
||||
Сборка/DM-профиль8/8 PASS; ASAN/UBSan/LSan8/8 PASS,detect_leaks=1,core/libuasync/Opus инструментированы.
|
||||
После rebase на fa76447f:build/check.sh205/205 PASS,0 failed/0 skipped;
|
||||
proxy9/9,burst1/1,load8/8 (134217728 байт),exit0,175с;273 JSON-снимка повторно PASS.
|
||||
Подробности: [dm_audit_findings.txt](dm_audit_findings.txt),раздел9; /tmp/dm-utf8-fix-*.log.
|
||||
Исправление закрыто; полный аудит DM остаётся частичным, аппаратные GUI/Android не проверялись.
|
||||
|
||||
[+] **call_audio: сохранить регистрацию при stop/start чата и согласовать Android lifecycle** — исправлено 26.10.2026.
|
||||
Заимствованный audio_ops хранится у UTUN_INSTANCE и переживает пересоздание call_ctx; повторный init не нужен.
|
||||
Идемпотентный init того же owner сохраняет эпоху/занятые TX-слоты. Init/destroy выполняются в uasync;
|
||||
Android worker привязывает voice до SERVICE_STARTED и снимает привязку перед destroy при stop/restart/ошибке запуска.
|
||||
GUI больше не читает raw instance для lazy-init; поздний start без ядра отклоняется.
|
||||
Восемь новых регрессий с реальными ядрами/Opus/worker PASS, allocations0->0; общий voice/desktop38/38 PASS.
|
||||
Диагностика/исправление: [call_audit_findings.txt](call_audit_findings.txt),разделы18–19;
|
||||
/tmp/call-registration-fix-{voice-tests,native-build,android-build,full-check}.log.
|
||||
|
||||
[+] **call/JNI: удалить неиспользуемый межпоточный online-API** — исправлено 26.10.2026.
|
||||
call_is_online обещал любой поток, но напрямую читал uasync-очереди topo_groups/group_nodes без синхронизации.
|
||||
Диагностика на двух настоящих ядрах: owner и post в uasync PASS; прямой worker — два поиска в чужом потоке,
|
||||
ожидаемый FAIL, без конкурентного удаления объектов; cleanup во всех трёх режимах allocations0->0.
|
||||
Внешних потребителей Android-метода нет: удалены Kotlin/JNI/C bridge и публичная декларация,
|
||||
проверка стала static внутри call.c и выполняется из call_start в uasync. Алгоритм проверки сохранён.
|
||||
Native build и Android assembleDebug для arm64-v8a/x86_64 PASS; Android/JVM170/170 PASS.
|
||||
Отсутствие старого API проверено в исходниках, core archive, Kotlin-классе и обеих JNI-библиотеках APK.
|
||||
check.sh204/204 PASS,0 failed/0 skipped;proxy9/9,burst1/1,load8/8,exit0,176с.
|
||||
Подробности: [call_audit_findings.txt](call_audit_findings.txt),раздел17;
|
||||
/tmp/call-online-thread-audit-{owner,worker,posted}.log, /tmp/call-online-fix-{validation,full-check}.log.
|
||||
Общий аудит call остаётся частичным; чек-лист6[V]/5[~]/10[ ] не менялся.
|
||||
|
||||
[+] **Android CallAudioRouter: владение Bluetooth proxy при stop/restart** — исправлено 26.10.2026.
|
||||
Поколение запроса и общий монитор start/stop/BT callbacks исключают принятие proxy старого lifecycle.
|
||||
Disconnect сохраняет owner до close; stop отсоединяет ссылки и закрывает через создавший adapter,
|
||||
поздний connected закрывает свой proxy. Диагностика содержит proxy/generation/accepted и ошибки API.
|
||||
Отдельный JVM-модуль компилирует production router с управляемой моделью Android API:15/15 PASS,
|
||||
включая64 конкурентных callback/stop и I/O start с задержанным callback; после cleanup0 proxy/callbacks.
|
||||
Прежний исходник:штатный контроль PASS,три ownership-сценария ожидаемо FAIL.
|
||||
Android clock/trace/buffer/recovery26/26,voice/desktop30/30,нативная сборка и assembleDebug для двух ABI PASS.
|
||||
check.sh204/204 PASS,0 failed/0 skipped;proxy9/9,burst1/1,load8/8,exit0,173с.
|
||||
Fixtures не входят в APK;Bluetooth/Binder/звук на устройстве не проверялись. Общий аудит call остаётся частичным.
|
||||
Подробности: [call_audit_findings.txt](call_audit_findings.txt),раздел15;
|
||||
/tmp/call-bt-fix-{gradle,negative,voice-tests,full-check}.log.
|
||||
|
||||
[+] **Android call mute: настоящий PCM и флаг mute передаются через JNI до core** — исправлено 26.10.2026.
|
||||
Kotlin передаёт исходный buf и snapshot muted на кадр; NativeLib/JNI/C bridge вызывают feed_pcm_muted.
|
||||
AEC/AGC продолжают получать микрофон, mute зануляет PCM непосредственно перед Opus; wire/DSP/desktop не менялись.
|
||||
Добавлен секундный mutedFrames и контекст feed-отказа. Постоянный test_android_call_mute:5/5 PASS —
|
||||
AEC/AGC on/off,20 кадров до mute/во время/после,PTS/эпоха и360 TX callbacks,allocations0->0;
|
||||
контроль раннего mute воспроизводит ненулевой сигнал. Прежнее тело bridge даёт ожидаемый FAIL с полным cleanup.
|
||||
Native build PASS; voice/desktop-профиль30/30 PASS; Android unit14/14 PASS;
|
||||
assembleDebug arm64-v8a+x86_64 PASS, JVM/JNI descriptor (J[SZJ)Z сверён.
|
||||
check.sh203/203 PASS,0 failed/0 skipped;proxy9/9,burst1/1,load8/8,exit0,168с.
|
||||
Подробности: [call_audit_findings.txt](call_audit_findings.txt),раздел14;
|
||||
/tmp/call-android-mute-fix-{voice-tests,voice-details,negative,android-final-build,android-unit,full-check}.log.
|
||||
Установка/звонок на устройстве не выполнялись; общий аудит голосового и платформенного кода остаётся частичным.
|
||||
|
||||
[+] **call_headless: целостность PCM-кадра при переполнении TCP-очереди** — исправлено 26.10.2026.
|
||||
Header/PCM собираются до отправки; очередь принимает целый кадр или отбрасывает его целиком.
|
||||
Частичная запись сохраняет хвост перед следующими кадрами. Потери учитываются отдельно для клиента/сервиса;
|
||||
WARN содержит причину/состояние очереди, итоговые счётчики выводятся при закрытии/destroy.
|
||||
Постоянный test_call_headless_queue:11/11 PASS — TCP/uasync/Opus, EAGAIN/short-write, точная ёмкость,
|
||||
переполнение после частичного drain, следующий кадр, destroy с очередью и недопустимый размер/NULL.
|
||||
Весь полученный поток сравнивается с независимым decoder, allocations0->0; старый код ожидаемо FAIL.
|
||||
Штатный e2e1/1 PASS; native build PASS; check.sh203/203 PASS,0 failed/0 skipped,
|
||||
proxy9/9,burst1/1,load8/8 (134217728 байт),exit0,188с. Полный аудит call остаётся частичным.
|
||||
Подробности: [call_audit_findings.txt](call_audit_findings.txt),раздел12;
|
||||
/tmp/call-headless-fix-{queue,e2e,full-check}.log. Аппаратный GUI/Android не проверялись.
|
||||
|
||||
[ ] **radio_headless: проверить целостность PCM-кадра при переполнении TCP-очереди**.
|
||||
При сравнении замечен сходный шаблон раздельной отправки header/PCM в rh_write_frame/rh_send_raw.
|
||||
Runtime overflow отдельно не воспроизводился; нужны диагностика и компонентная проверка до решения об исправлении.
|
||||
Наблюдение: [call_audit_findings.txt](call_audit_findings.txt),раздел11. Полный аудит рации не выполнен.
|
||||
|
||||
[+] **DM media: не выдавать отказ квоты при ошибке чтения занятого места** — исправлено 26.10.2026.
|
||||
dm_custody_store проверяет step/finalize отдельно;SQL-ошибка возвращает управление без REJECT/регистрации custody.
|
||||
S сохраняет назначение M и повторяет STORE;настоящий недостаток квоты выдаёт REJECT по прежнему контракту.
|
||||
test_dm_media дополнен Linux wrappers:4 SQL-отказа без записей/REJECT/STORED,настоящая квота с одним REJECT;
|
||||
реальная UDP-цепочка с prepare/step/finalize на первых3 STORE,успешный4-й STORE на том же M,
|
||||
restart S/M при выключенном A,точный файл на B,удаление custody/тела S,поздние control и TTL;allocations0->0.
|
||||
С прежним dm_media регрессия ожидаемо FAIL;исходный one-shot IOERR-стенд и вариант без fault injection PASS.
|
||||
Сборка/DM-профиль7/7 PASS;ASAN/UBSan/LSan7/7 PASS,detect_leaks=1,core/libuasync/Opus инструментированы.
|
||||
Полный check.sh203/203 PASS,0 failed/0 skipped;proxy9/9,burst1/1,load8/8 (134217728 байт),exit0,176с.
|
||||
Подробности полного набора и границы проверки: [dm_audit_findings.txt](dm_audit_findings.txt),раздел6.
|
||||
|
||||
[+] **DM: различать отсутствие беседы и ошибку её загрузки, сохранять seq при отказе** — исправлено 26.10.2026.
|
||||
dm_conv_load различает FOUND/NOT_FOUND/ERROR; только успешный SELECT без строки разрешает создание.
|
||||
SQL/короткие ключи/X25519 возвращают ошибку без перезаписи беседы, изменения hidden или события успеха.
|
||||
Проверены все5 callers; приём/invite не пересоздают беседу после отказа. Ошибка сохранения области передаётся наружу.
|
||||
test_dm_load:79 отказных сценариев, все поля7 DM-таблиц, отсутствие SQL-записей/ложных квитанций,
|
||||
поколения до/после удаления, restart/продолжение seq и отдельные ошибки подготовки медиа; allocations0->0.
|
||||
Прежний core даёт ожидаемый FAIL новой регрессии; исходный SQLITE_DENY-стенд теперь PASS,out1/in2 сохранены.
|
||||
Сборка и DM-профиль7/7 PASS; ASAN/UBSan/LSan7/7 PASS,detect_leaks=1,ядро/libuasync/Opus инструментированы.
|
||||
Окончательный check.sh203/203 PASS,0 failed/0 skipped;proxy9/9,burst1/1,load8/8 (134217728 байт),175с.
|
||||
Первый прогон202/203 с отдельным отказом JOIN сохранён;задача диагностики ниже,критерии теста не менялись.
|
||||
Подробности полного набора и ограничения: [dm_audit_findings.txt](dm_audit_findings.txt), раздел4.
|
||||
|
||||
[ ] **test_chat_join_e2e: диагностировать раннее чтение файла приглашения** — замечено 26.10.2026.
|
||||
Первый общий прогон исправления DM:202/203 PASS; happy A!=C завершился invalid invitation length or prefix у J,
|
||||
затем A/C не нашли подписанного J. Degraded/invalid-key сценарии прошли. Отдельный повтор3/3 PASS.
|
||||
wf(link.txt) создаёт файл до vfprintf/fclose, wait_file проверяет только access(F_OK): возможна гонка публикации.
|
||||
В исходном отказе нет размера/прочитанных байтов, поэтому конкретная причина пока не доказана.
|
||||
Повторный полный check.sh203/203 PASS;это не доказательство устранения причины исходного отказа.
|
||||
Добавить диагностику длины/результатов I/O и при подтверждении публиковать файл атомарно после закрытия.
|
||||
Критерии JOIN не ослаблять. Логи /tmp/dm-load-fix-{full-check-first,join-first,join-repeat}.log;
|
||||
production JOIN и штатный test_chat_join_e2e в исправлении DM не менялись.
|
||||
|
||||
[+] **db_sync: финальная сверка принимает разные хеши при равном count** — исправлено26.10.2026.
|
||||
db_handle_sync_done явно выставляет synced и вызывает done даже при chain_hash mismatch.
|
||||
Дополнительный локальный стенд подтвердил ложный done и разные корни одинаковых подписанных записей
|
||||
с разными неподписанными id. Для200 записей с отличием в первой sparse-поиск даёт fm148;
|
||||
выбранный хвост состоит из дубликатов, простой повтор не сходится. Стенд exit0, без сетевых пакетов,
|
||||
/tmp/db-sync-completion-design.c/.mk, лог /tmp/db-sync-completion-design.log.
|
||||
MESSAGES=2 подключён к общему Merkle; канонические записи/полные корни без local id и сетевого OFFSET.
|
||||
Собственный bulk/chain удалён; PUSH/ACK сохранены. Wire v4 со свежими round_id/accept_id обеих сторон
|
||||
изолирует cancel/start от старых PAGE/DONE/ERROR. RNG failure, дубликаты и CHECK/DONE revision проверены.
|
||||
Локальная вставка и индекс — одна короткая транзакция; страница до16 записей. Preflight16 строк
|
||||
проверяет подписи вне write-lock; индекс16 листьев за commit, timer1 tick между порциями.
|
||||
SQL/OpenSSL/NOMEM/неизвестный ключ сохраняют строки, доказанная порча удаляется; late key возобновляет обход.
|
||||
Deleted автора не фильтрует историю; допуск пересылающего READY-пира проверяется отдельно.
|
||||
Sound/sleep, callback dispatch/remove и сохранность схемы проверены. TTL ms, короткие DELETE/index commits.
|
||||
check.sh203/203 PASS,0 failed/0 skipped; proxy9/9,burst1/1,load8/8,STUN/stream PASS,exit0,179с.
|
||||
ASAN/UBSan/LSan10/10 PASS,detect_leaks=1. Крупный сценарий5000 новых записей:5002/5002,полные корни равны.
|
||||
После общего прогона добавлены только крупный тест и CHECK/DONE:оба PASS, последний также sanitizer PASS.
|
||||
Логи /tmp/history-merkle-verified-check.log, /tmp/history-merkle-asan-results.log, /tmp/history-merkle-large.log.
|
||||
[member_merkle_audit_findings.txt](member_merkle_audit_findings.txt), раздел13; исходный план — раздел12.
|
||||
|
||||
[+] **db_sync: отказ проверки/битый дубликат удаляли корректную историю** — исправлено26.10.2026 после rebase на03b05094.
|
||||
VALID/BAD_SIGNATURE/BAD_FORMAT/DEFERRED/ERROR разделены; перед DELETE проверяется сама сохранённая копия.
|
||||
SQL/OpenSSL/NOMEM/неизвестный ключ сохраняют данные; доказанная порча удаляется в транзакции.
|
||||
Recalc сохраняет pending при отказах; после COMMIT удаления сбрасываются позиции пиров и цепочка строится с0.
|
||||
Прежний стенд сохранения2/2 PASS; новый test_db_sync_integrity:10 введённых отказов, реальные SQL-триггеры,
|
||||
формат0/1/63/65/255/4001, два канала, откат удаления/COMMIT/next_id, несколько батчей, allocations восстановлены.
|
||||
check.sh203/203 PASS,0 failed/0 skipped; proxy9/9,burst1/1,load8/8,exit0,195с.
|
||||
STUN по новому запросу пользователя PASS отдельно и в общем наборе; прежний bind assert не воспроизведён,
|
||||
первый падающий коммит/причина не установлены. ASAN/UBSan/LSan8/8 PASS с detect_leaks=1.
|
||||
Логи /tmp/db-sync-full-final-check.log, /tmp/db-sync-integrity-final.log, /tmp/db-sync-asan-results.log.
|
||||
Подробности: [member_merkle_audit_findings.txt](member_merkle_audit_findings.txt), раздел11.
|
||||
|
||||
[+] **db_sync: неверный SQL и неоднозначная идентичность ACK доставки** — исправлено26.10.2026.
|
||||
ACK_PUSH содержит message_id[32]; один идемпотентный UPDATE адресует только конкретную запись.
|
||||
Счётчик отражает ACK, а не очередь отправки; WAS_SENT — только для локального автора. Все SQL-отказы логируются.
|
||||
Две записи одного автора с одним timestamp проверены отдельно и через настоящие PUSH/ACK;
|
||||
повтор ACK не увеличивает счётчик. Общий и sanitizer прогоны выше PASS, совместимость старого wire не нужна.
|
||||
Подробности: [member_merkle_audit_findings.txt](member_merkle_audit_findings.txt), раздел11.
|
||||
|
||||
[+] **feedback: исключение и отложенное удостоверение не блокируют запуск чата** — исправлено 26.10.2026.
|
||||
Исключённые/ожидающие авторы скрываются из результатов, Merkle и выдачи; записи, ver и отмены сохраняются.
|
||||
Единый member_sync_author_key различает ACTIVE/INACTIVE/PENDING/INVALID/ERROR; radio/attachments остаются строгими.
|
||||
Изменение удостоверения/deleted/общего ключа атомарно обновляет зависимые feedback-листья во всех каналах.
|
||||
Отложенный signer не блокирует MEMBERS; поступление его ключа через другой канал включает допустимую историю.
|
||||
MS_APPLY_FEEDBACK переносится через внешний commit; профиль/stream options не запускают лишние раунды.
|
||||
Отзыв завершает ожидание текущего раунда, сохраняя подписку; возврат запускает обмен по прежнему UP-транспорту.
|
||||
Регрессии: restart, реакция/ответ/отмена, purge, два канала, межканальный ключ, mixed page,
|
||||
SQL/index/commit faults и внешний rollback без событий; новый сетевой D и новые изменения вернувшегося A.
|
||||
Окончательный check.sh200/200 PASS,0 failed/0 skipped; proxy9/9,burst1/1,load8/8 (134217728 байт),exit0,186с.
|
||||
ASAN/UBSan/LSan15/15 PASS,detect_leaks=1; core/libuasync/Opus инструментированы согласованно.
|
||||
Логи /tmp/feedback-author-fix-full-final-check.log и /tmp/feedback-author-fix-asan-final-results.log.
|
||||
Исправление закрыто; полный аудит остальных контрактов остаётся частичным, push не выполнялся.
|
||||
Подробности: [member_merkle_audit_findings.txt](member_merkle_audit_findings.txt), раздел9; исходные дефекты — разделы7–8.
|
||||
|
||||
[+] **member_sync: атомарная очистка повреждённых записей с передачей ошибок** — исправлено 26.10.2026.
|
||||
Очистка владеет транзакцией канала от чтения до commit удаления/индекса; чужая транзакция
|
||||
отклоняется до изменений. Ошибки чтения/проверки/памяти/SQL/commit возвращаются через init/start.
|
||||
Недействительная подпись отделена от отсутствующей зависимости и SQL/OpenSSL-отказа,
|
||||
включая sc_ed25519_verify и SQL-getters ключей. Корректные данные при отказе не удаляются.
|
||||
Props/mute/группа меняются после commit; self-unsubscribe и повторный вход безопасны.
|
||||
Штатный test_member_adapter расширен test_member_purge: SQL/commit/allocator faults,
|
||||
24 crypto faults, identity hash, malformed BLOB, placeholders/deferred и init/start/retry.
|
||||
Реальный stream_member проверяет rollback mute/READY/reader, успешное удаление,
|
||||
сохранение другого канала и внешнего NCD-владельца. Профиль11/11 PASS.
|
||||
Общий набор200/200 PASS в IPv6 namespace; proxy9/9,burst1/1,load8/8,134217728 байт PASS
|
||||
отдельно в root network/mount namespace. Первые отказы среды и повторные прогоны описаны в отчёте.
|
||||
ASAN/UBSan/LSan11/11 PASS,detect_leaks=1; lib/src/Opus инструментированы согласованно.
|
||||
Полный аудит остальных контрактов остаётся частичным; wire/формат БД не менялись.
|
||||
Подробности: [member_merkle_audit_findings.txt](member_merkle_audit_findings.txt).
|
||||
|
||||
[+] **test_radio_mesh: учитывать дедупликацию на любом узле diamond** — исправлено 26.10.2026.
|
||||
Проверяется ровно5 упорядоченных кадров и один FIN у B/D/C, отсутствие эха у A и дедупликация всего burst.
|
||||
Unix-наблюдение ждёт обработки всех отправленных RADIO-пакетов; gate воспроизводит обходную копию раньше A→B/A→D.
|
||||
Обычный сценарий и принудительные dupB6/dupC0,dupD6/dupC0 проходят; паузы/диагностический хвост удалены.
|
||||
Один прогон и16 параллельных повторов (по3 сценария) PASS, allocations0->0; сборка без wrappers также PASS.
|
||||
Отрицательные стенды: старое условие dupC>0 и лишняя локальная доставка ожидаемо FAIL без утечки.
|
||||
После rebase нативная/CMake-сборки PASS, C++ voice17/17 PASS; check.sh202/202 PASS,0 failed/0 skipped,
|
||||
proxy9/9,burst1/1,load8/8 (134217728 байт),exit0,176с. Production radio/uasync не менялись.
|
||||
Логи /tmp/radio-mesh-fix-{test,repeat-results,negative-results,voice-tests,full-check}.log;
|
||||
подробности: [call_audit_findings.txt](call_audit_findings.txt),раздел10. Windows/FreeBSD/GUI/Android не запускались.
|
||||
|
||||
[+] **call_audio: изолировать pending TX-задачи разных привязок instance** — исправлено 26.10.2026.
|
||||
Отдельная эпоха привязки ограждает pending, отправку и поздние post-time/age-drop;
|
||||
старые звонки того же owner освобождают свои слоты, повтор init идемпотентен.
|
||||
Предел8 относится к активной привязке; uasync освобождает старые embedded tasks без дополнительных owner-объектов.
|
||||
Добавлены INFO эпох init/destroy, TRACE отклонённой задачи и ERROR нарушения счётчика.
|
||||
Постоянный test_call_audio_tx:13/13 PASS, allocations0->0; прежний C++ voice-профиль4/4 PASS.
|
||||
Те же тесты со старым voice-архивом:7/13 PASS, шесть ожидаемых отказов; production не подменялся.
|
||||
Нативная/CMake-сборки PASS. Два полных check201/202: test_radio_mesh с dupC=0,
|
||||
одиночный повтор PASS; флак вынесен отдельно. Подробности полного прогона — раздел8 отчёта.
|
||||
После исправления критерия mesh и rebase полный check202/202 PASS; актуальная проверка — раздел10 отчёта.
|
||||
Интеграции выполнены отдельно: proxy9/9,burst1/1,load8/8 (134217728 байт) PASS.
|
||||
Аппаратный GUI/Android и санитайзеры не запускались; полный аудит call/ ещё не завершён.
|
||||
Подробности: [call_audit_findings.txt](call_audit_findings.txt),разделы6–8.
|
||||
|
||||
[+] **call: запрещать второй исходящий вызов при незавершённом обычном звонке** — исправлено 26.10.2026.
|
||||
call_start до session/CM/INVITE отклоняет новый ID при обычной занятости с ENDED/BUSY.
|
||||
Уже зарегистрированный ID, включая ENDED до очистки, отклоняется без события/изменения старого звонка.
|
||||
INFO содержит новый/занятый ID, peer/ch и состояние; нулевые peer/id отклоняются с ERROR.
|
||||
Штатный test_call_admission:547 assertions,22 BUSY,18 отказов без событий,7 сочетаний состояния/роли,
|
||||
CHAT/DM/другой tuple до поиска маршрута, отсутствие новых CM/INVITE/started, сохранение состояния/таймеров,
|
||||
двустороннее Opus RX, новый вызов при старом tombstone, продолжение PHONE_BUSY и взаимодействие с рацией.
|
||||
Полные join/update/tree подписи fixture через member_sync; tracked allocations0->0.
|
||||
Прежний диагностический стенд5/5 теперь PASS. Сборка PASS, C++ voice4/4 PASS,
|
||||
check.sh202/202 PASS,0 failed/0 skipped; proxy9/9,burst PASS,load8/8,134217728 байт,174с.
|
||||
Полный прогон в отдельном IPv6 namespace, сеть хоста сохранена. Санитайзеры/аппаратный GUI/Android не запускались.
|
||||
Подробности: [call_audit_findings.txt](call_audit_findings.txt),раздел5;
|
||||
логи /tmp/call-admission-{fix-regression,voice-tests,full-check,netns}.log. Остальной аудит src/call частичный.
|
||||
|
||||
[+] **stream: закрывать INFO-пробу при истечении её 9-байтового запроса** — исправлено и проверено 26.10.2026.
|
||||
expire_controls сверяет length9 и текущий probe_id; истёкший INFO закрывает flow с OVERLOAD.
|
||||
Лог содержит размер, возраст control и факт закрытия flow; чужой probe_id не закрывает новую попытку.
|
||||
test_stream_control_expiry4/4 PASS: обе media-формы, SUBSCRIBE8, до/на/после TTL и несовпадающий ID.
|
||||
test_stream_service проверяет оба media-режима: настоящая очередь ETCP удерживается4,2с,
|
||||
второй INFO истекает в control FIFO до общего deadline5с; после разблокировки ответ/повторное открытие успешны.
|
||||
Общее ETCP-соединение и handles публикации/подписки сохраняются; cleanup без tracked leaks.
|
||||
Сборка PASS. Первый check198/201: три отказа из-за отсутствующего ::1 при отключённом IPv6 окружения.
|
||||
В отдельном IPv6 namespace check201/201 PASS,0 failed/0 skipped; proxy9/9,burst PASS,load8/8,134217728 байт,175с.
|
||||
Настройки основного namespace не менялись. Логи /tmp/stream-info-expiry-fix-build.log,
|
||||
/tmp/stream-info-expiry-fix-unit.log, ...-integration.log, ...-ipv6-full-check.log.
|
||||
Подробности: [stream_audit_findings.txt](stream_audit_findings.txt),разделы8–9. Аудит остальных контрактов stream частичный.
|
||||
|
||||
[ ] **tests: явно проверять IPv6-предпосылку полного набора** — замечено 26.10.2026.
|
||||
В основном окружении disable_ipv6=1 для all/default/lo, ::1 отсутствует; bind(::1) даёт EADDRNOTAVAIL.
|
||||
Из-за этого падают test_ncd_faults, test_ipv6_sockets и test_route_connectivity до проверки своей логики.
|
||||
Предусмотреть ясную предварительную диагностику/подготовленный отдельный namespace без изменения сети хоста.
|
||||
Все три проверки и полный набор201/201 проходят в таком namespace; пропуски не требуются.
|
||||
Подтверждение: /tmp/stream-info-expiry-host-ipv6.log и /tmp/stream-info-expiry-fix-ipv6-env.log.
|
||||
|
||||
[+] **stream: упаковывать помещающийся Opus без обязательного резерва под видео** — исправлено и проверено 26.10.2026.
|
||||
Без ожидающего видео используется вся ёмкость пачки, включая точное заполнение.
|
||||
Крупный Opus уходит отдельно; ожидающее видео получает второй слот actor без coalescing delay.
|
||||
Пакет, не помещающийся даже отдельно, удаляется с WARN размера/максимума/ID/PTS до блокировки следующей записи.
|
||||
Wire и неделимость Opus сохраняются; feed=1 подтверждает копирование в очередь, в том числе во время OPENING.
|
||||
test_stream_publisher включён в общий check:23 сценария PASS, полученный Opus декодируется,
|
||||
видео проверяется по байтам/offset, P-кадр не запускает media timer; tracked allocations восстановлены.
|
||||
После merge origin/master повторная сборка PASS, профиль8/8 PASS; check.sh200/200 PASS,0 failed/0 skipped.
|
||||
Интеграции: proxy9/9,burst PASS,load8/8 (134217728 байт), полное время175с.
|
||||
Логи /tmp/stream-publisher-fix-merged-build.log, /tmp/stream-publisher-fix-profile-results.log,
|
||||
/tmp/stream-publisher-fix-full-check.log. Полный аудит остальных контрактов stream не закрыт.
|
||||
Подробности: [stream_audit_findings.txt](stream_audit_findings.txt), разделы6–7.
|
||||
|
||||
[+] **stream: учитывать подписанный deleted при загрузке мембера** — исправлено 26.10.2026.
|
||||
stream_member_load читает deleted вместе с профилем и возвращает STREAM_ERR_NOT_MEMBER до crypto,
|
||||
с отдельным логом причины/группы/узла. WAIT для неполного профиля и выходы при отказе сохраняются.
|
||||
test_stream_member включён в общий check и использует fixture test_stream_service в отдельном процессе.
|
||||
Проверены подписанные запрет/разрешение, неверная подпись/старая версия, source/supplier/self,
|
||||
закрытие handles/выдач, отсутствие новых DATA callback, Merkle-остановка publisher,
|
||||
другой канал с рабочей записью и чужое NCD-владение; после разрешения нужны новые handles/эпоха.
|
||||
Воспроизведение: /tmp/stream-audit-member-deleted.c и /tmp/stream-audit-member.mk;
|
||||
до исправления /tmp/stream-audit-member-deleted.log, exit=1; архитектурная копия PASS,
|
||||
/tmp/stream-arch-results-final.log. Штатная сборка PASS, профиль7/7 PASS, tracked allocations=0.
|
||||
check.sh:195/195 PASS,0 failed/0 skipped; proxy9/9,burst PASS,load8/8 (134217728 байт),167с.
|
||||
Логи: /tmp/stream-member-fix-build.log, /tmp/stream-member-fix-profile-results.log,
|
||||
/tmp/stream-member-fix-full-check.log. Полный аудит остальных контрактов stream этим не закрывается.
|
||||
Подробности: [stream_audit_findings.txt](stream_audit_findings.txt).
|
||||
|
||||
[+] **utun_instance: завершить аудит владения, lifecycle и reload** — завершено 26.10.2026 после config_parser.
|
||||
Исправлены вложенные подписки, отказ конструкторов, индекс TCP-реестра, жизнь пути общего logger,
|
||||
selective/full reload, TCP listener teardown, освобождение очередей/подписок и SQLite BUSY.
|
||||
Headless start/stop освобождает контексты/клиентов/таймеры; PCM и radio hop-list читаются выровненно.
|
||||
Заголовок и документ полностью сверены; core-разделы service_lifecycle.md согласованы, общий [~] сохранён.
|
||||
Новый test_instance_contract покрывает callbacks/OOM, reload/fault/retry, duplicate servers, database/logger
|
||||
ownership и headless lifecycle. Сборка PASS; check.sh196/196 PASS,0 skipped,proxy9/9,burst1/1,load8/8,173с.
|
||||
ASAN/UBSan/LSan11/11 PASS,detect_leaks=1; ограничения partial reload и нативных платформ описаны
|
||||
в [utun_instance_audit_findings.txt](utun_instance_audit_findings.txt).
|
||||
|
||||
[+] **config_parser: завершить сверку и строгий разбор значений** — завершено 26.10.2026.
|
||||
Исправлены IPv6 /32, control /0 и порядок ip/port, числовые границы/таймеры, storage overflow,
|
||||
потерянные ошибки обработчиков и памяти, повторное включение proxy, HEX и диагностический dump секретов.
|
||||
Config updater нормализует ошибочные ранние дубли выбранными последними значениями без смены идентичности.
|
||||
Заголовок и документы согласованы; профиль6/6, ASAN/UBSan/LSan6/6, detect_leaks=1.
|
||||
Полный check.sh195/195 PASS,0 skipped,proxy9/9,burst1/1,load8/8,134217728 байт,169с.
|
||||
Подробности, платформенные ограничения и логи: [config_parser_audit_findings.txt](config_parser_audit_findings.txt).
|
||||
|
||||
[+] **chat JOIN: исправить кодек, владение попыткой и покрыть нюансы тестами** — завершено 26.10.2026.
|
||||
Общий C-кодек для Qt/JNI/headless, отдельные request/attempt IDs, защита от старых ответов,
|
||||
потокобезопасный порт команд, целостный запуск и очистка до уведомления, SQL/runtime-откат.
|
||||
Кодек116342 случаев; отказы памяти/таймеров/NCD/send, REALITY, SQL, повторные callbacks,
|
||||
чужие владельцы и JOIN, детерминированная гонка stop/producer и отмена извлечённого batch — PASS.
|
||||
check.sh196/196, proxy9/9, burst1/1, load8/8 (134217728 байт); ASAN/UBSan/LSan6/6, detect_leaks=1.
|
||||
Desktop/Android APK build PASS, Qt invite/local UI2/2. JOIN wire изменён без совместимости со старыми узлами.
|
||||
Подробности: [chat_audit_findings.txt](chat_audit_findings.txt). Коммит/push не выполнялись.
|
||||
|
||||
[ ] **chat: продолжить аудит заголовков и документов** — проверка всего каталога не завершена.
|
||||
Приглашения исправлены по согласованному плану: общий кодек, токены попыток, целостный запуск,
|
||||
SQL/runtime-границы, единое завершение и отдельный потокобезопасный порт команд GUI/JNI.
|
||||
Добавлены test_invite_codec и test_chat_join_faults; подробности и результаты:
|
||||
[chat_audit_findings.txt](chat_audit_findings.txt). Остальные непроверенные пункты чек-листа сохраняются.
|
||||
|
||||
[+] **transport_layer: завершить аудит заголовков и документов** — завершено 26.10.2026.
|
||||
С учётом прежних этапов сверены все32 собственных заголовка и17 документов каталога плюс doc/stun_nat.md.
|
||||
Закрыты auto_socket/socket_monitor/SOCKS/call_ring/REALITY/STUN; исправлены владение endpoints/posts,
|
||||
потеря TCP banner, ошибки запуска/очистки, relay HUP/half-close и двойной STUN recheck.
|
||||
Обычная сборка PASS; check.sh194/194 PASS,0 skipped, proxy9/9,burst1/1,load8/8,134217728 байт,170с.
|
||||
Динамические IPv4/IPv6 auto_socket PASS; проверки build.sh44/44; ASAN/UBSan/LSan12/12,detect_leaks=1.
|
||||
Два первых отказа sanitizer-стенда вызваны отсутствием glibc FORTIFY wrappers; исправлены, повторные прогоны PASS.
|
||||
Нативные сборки/runtime Windows и FreeBSD не выполнялись; платформенные ветки сверены по исходникам.
|
||||
Подробности, ограничения и логи: [transport_layer_audit_findings.txt](transport_layer_audit_findings.txt).
|
||||
|
||||
[+] **socket_monitor: исправить FreeBSD-разбор уведомлений адреса** — исправлено 25–26.10.2026.
|
||||
Прежний bsd_parse_rtm безусловно писал gw[0] при NULL из RTM_NEWADDR/RTM_DELADDR.
|
||||
Ненужный разбор sockaddr удалён; уведомления проверяются по длине/версии и правильным native структурам
|
||||
ifa_msghdr/if_msghdr/if_announcemsghdr/rt_msghdr с копированием выровненного заголовка.
|
||||
Также исправлены исчезновение адресов, IPv6 scope, изменение endpoints внутри callbacks и pending Windows post.
|
||||
Linux native-fixture, init faults и ASAN/UBSan/LSan PASS; FreeBSD определения сверены с releng/14.3.
|
||||
Нативный runtime FreeBSD недоступен; он не подменяется Linux-тестом. Заголовок и lifecycle-контракт согласованы.
|
||||
|
||||
[+] **packet_dump: ограничить чтение входного IPv4-пакета** — исправлено 25.10.2026 по указанию пользователя.
|
||||
Единый hex-цикл читает min(32, total length-IHL) байт; проверяются NULL/version/IHL/total length,
|
||||
хвост после total length игнорируется. Адреса читаются побайтно; последующие IP-фрагменты
|
||||
не выдаются за транспортный заголовок. Исправлены обрезание полного набора TCP-флагов и вывод нулевого порта.
|
||||
Ошибки входа отражаются в строке и логируются в dump/warn, частичный первый фрагмент — dump/debug.
|
||||
Прежний тест не вызывал модуль; теперь проверяет реальный форматтер: протоколы, поля, фрагменты,
|
||||
77 длин захвата, невыровненный вход, сохранность входа и точный hex в2904 случаях.
|
||||
Контракты .h/документа согласованы. Код235c2c4f, тестыc3fa6d4d; rebase на e035f53a без конфликтов.
|
||||
Обычная сборка PASS; полный check.sh после rebase:190/190 PASS,0 skipped,
|
||||
proxy9/9,burst1/1,load8/8 PASS,134217728 байт,214с. ASAN/UBSan/LSan packet_dump PASS сdetect_leaks=1;
|
||||
встроенные lib/src/Opus в изолированной копии собраны с согласованными sanitizer-флагами.
|
||||
Логи: /tmp/packet-dump-fix-test.log, /tmp/packet-dump-fix-full-check.log,
|
||||
/tmp/packet-dump-fix-asan-build.log, /tmp/packet-dump-fix-asan-test.log.
|
||||
|
||||
[+] **member_files: сверить контракты и исправить ошибки владения/повторов** — завершено 24.10.2026.
|
||||
Прочитаны все6 реализаций и6 заголовков C-модуля. Исправлены удаление чужого temp,
|
||||
повторный запуск после раннего cancel с UAF, обрыв массовых мутаций при64 квитанциях
|
||||
и блокирующий open FIFO до проверки типа. Заголовки и серверная/клиентская документация согласованы.
|
||||
Подтверждены сохранность файлов при трёх отказах, 76 созданий/76 удалений и потеря успешного ответа без повторной мутации.
|
||||
Сборка PASS; профиль7/7, ASAN/UBSan/LSan4/4 PASS с detect_leaks=1. Общий GUI/avatar документ остаётся [~].
|
||||
Отчёт и границы: [member_files_audit_findings.txt](member_files_audit_findings.txt).
|
||||
Логи: /tmp/member-files-audit-profile-results.log, /tmp/member-files-sanitizer-results.log.
|
||||
Прежний FAIL удаления чужого temp из ETCP-проверки закрыт коммитом0f42f92f;
|
||||
защита от возобновления после раннего cancel —20b4975d. Повторный фикс не требуется.
|
||||
После объединения с ETCP-диагностикой: сборка PASS, общий check.sh186/186,
|
||||
proxy9/9, burst1/1, load8/8 PASS,134217728 проверенных байт,162с.
|
||||
Сохранность target/temp подтверждена всеми тремя сценариями; test_member_files_network PASS phase21.
|
||||
Логи: /tmp/etcp-master-integration-build.log, /tmp/etcp-master-integration-check.log.
|
||||
|
||||
[+] **media_async/media_delivery: завершить сверку контрактов и исправлений** — завершено 24.10.2026.
|
||||
Проверены все11 заголовков двух каталогов по полным реализациям; исправлены alias файлов,
|
||||
@@ -113,6 +762,9 @@
|
||||
Windows/FreeBSD runtime не проверялся.
|
||||
|
||||
[ ] **ETCP: полезная утилизация нескольких каналов и доставка при зависании части линков** — общая задача открыта.
|
||||
Небольшой недобор до условного порога85% сохраняется как результат измерений;
|
||||
сам по себе не оправдывает изменения/усложнения BBR. Далее по приоритету — STCP backpressure,
|
||||
смена RTT нескольких путей/BW и повторный отказ; строгие критерии — доставка, паузы и рост очередей.
|
||||
После push исследован одиночный дуплекс2Мбит/с на изолированной базе a198cfb9.
|
||||
Точные7763 ACK показывают задержку обратной очереди: пример DATA15.909мс, hold0.029мс,
|
||||
обратный ACK32.219мс; DOWN продолжает ждать flight7644 при цели5915байт.
|
||||
@@ -682,11 +1334,41 @@
|
||||
Получатель принял приглашение и дважды отправил ACK; до проверки в логе отправителя нет приёма этого ACK. Отдельный повтор прошёл.
|
||||
Логи: /tmp/utun-stream-preview-local-transport-failure.log, /tmp/utun-stream-preview-local-transport-retry.log.
|
||||
|
||||
[ ] **Разобрать нестабильную проверку аудиопауз desktop-трансляции** — 17–20.10.2026:
|
||||
[x] **Разобрать нестабильную проверку аудиопауз desktop-трансляции** — исправлено 25.10.2026:
|
||||
test_stream_view::realH264RenderingAndClose нестабильно получает silenceGaps=3–4 вместо 2.
|
||||
Зафиксированы pcm_short с got=96/480 и 288/480; видео продолжало выводиться.
|
||||
Сбой повторялся с общей нагрузкой и после её завершения; изолированные повторы проходили.
|
||||
Проверить актуальные PCM workers/часы под нагрузкой; единичный PASS не подтверждает устранение.
|
||||
Повтор 25.10 при проверке восстановления потоков: silenceGaps=3 вместо 2, got=384/480;
|
||||
stream-перезапросов/GAP в этом локальном воспроизведении не было. Проверка шла рядом с полным C-прогоном.
|
||||
Новый лог: /tmp/utun2-key-qt-tests-verified.log; отдельный повтор: /tmp/utun2-key-qt-isolated.log.
|
||||
Диагностика 25.10 в utun2: изолированный сбой воспроизведён до искусственного stall видеодекодера.
|
||||
В момент провала got=96/480 (8мс тишины), PCM_before=96, tempo=1000, worker_age=239мкс;
|
||||
последний Opus feed был 104585мкс назад. Следующие 100мс аудио уже опубликованы 10771мкс назад,
|
||||
но ещё не поданы в decoder. Источник выдаёт 100мс аудио с фактическими интервалами 95–106мс.
|
||||
Старт с KEY оставляет 33мс аудио до головы; DSP имеет initial_latency=30мс, предзаполнения нет.
|
||||
PCM_target=250мс ограничивает дальнейшую подготовку, но не задаёт минимальный запас перед стартом/возобновлением.
|
||||
reserve включает 200мс очереди устройства и опубликованные, ещё не декодированные пакеты;
|
||||
minimum=233мс при guard=250мс лишь отключает ускорение, не восстанавливает недостающий запас.
|
||||
Это реальная нехватка PCM на границе пакетной подачи и асинхронной подготовки, а не ложный silenceGaps
|
||||
или доказательство блокировки аудио видео-декодером. Проверка после фиксированных 400мс зависит от фазы таймеров.
|
||||
Добавлены снимки PCM/темпа/guard/времени callback и отдельные эпизоды underrun;
|
||||
UTUN_TEST_AUDIO_DIAG=1 включает замеры публикации/кодирования источника и тишины тестового устройства.
|
||||
Воспроизведение: /tmp/utun2-audio-diag-repro.log, /tmp/utun2-audio-episodes-repro.log (повтор 2 из 5).
|
||||
Отдельный TRACE-прогон video-stall прошёл: 500мс задержки видео, 63352 ненулевых sample, новых пауз нет.
|
||||
Лог /tmp/utun2-audio-causal-trace.log; это подтверждает отдельность audio worker, но не устраняет PCM starvation.
|
||||
Исправление: общий PCM consumer при старте/underrun ждёт уже заданные 250мс готового PCM.
|
||||
Накопление сохраняет decoder и PTS; desktop/Android держат темп 1x до возобновления.
|
||||
EOF снимает ожидание порога, ограничение pull по PTS не считается нехваткой PCM.
|
||||
Счётчик preparedFrames публикуется после готового SPSC-слота, чтобы порог не учитывал ещё недоступный PCM.
|
||||
Детерминированная регрессия без таймеров падает до фикса и проходит после: 33мс после KEY,
|
||||
пачки по 100мс с джиттером 95–106мс + 20мс до feed, 767 полных callback без провала после старта.
|
||||
Отдельно проверены 600мс паузы, дробное поступление, возобновление/PTS, ограничение PTS и короткий EOF 20мс.
|
||||
Старые проверки точной длительности/темпа, stereo, конкурентного producer/consumer и позднего аудио проходят.
|
||||
GUI test_stream_view прошёл 8 повторов подряд; итоговый desktop stream-набор 4/4,
|
||||
Android stream unit tests 55/55, APK и основная C-сборка успешны; часы и Android bridge также проверены.
|
||||
Логи: /tmp/utun2-audio-regression-before.log, /tmp/utun2-audio-recovery-test-after.log,
|
||||
/tmp/utun2-audio-fix-view-repeat.log, /tmp/utun2-audio-fix-stream-tests.log, /tmp/utun2-audio-fix-android.log.
|
||||
Логи: /tmp/stream-zoom-full-view-audio-gap.log, /tmp/stream-zoom-full-view.log,
|
||||
/tmp/multiview-review/desktop-review.log, /tmp/multiview-review/audio-original.log.
|
||||
|
||||
@@ -805,13 +1487,16 @@
|
||||
нескольких ретрансляторов, нескольких автоматических источников/групп и mobile standby на устройстве
|
||||
(doc/stream.txt, раздел 16). Сохранённые проверки отдельных этапов не закрывают всю матрицу.
|
||||
|
||||
[ ] **Member files: исчерпание кеша результатов массовых изменений** — 10.10.2026:
|
||||
[+] **Member files: исчерпание кеша результатов массовых изменений** — исправлено 24.10.2026.
|
||||
Воспроизведено test_member_files_browser::recursiveDeletion(server-busy): из 76 объектов удаляются 64,
|
||||
следующая команда получает MF_BUSY, остаются 12. storage.c: STORE_RECEIPTS=64, RECEIPT_TB=1200000 (120 секунд).
|
||||
Лимит общий для успешных COMMIT/MKDIR/RENAME/REMOVE; затрагивает также рекурсивное копирование.
|
||||
Нужен отдельный этап управления временем жизни receipts с сохранением идемпотентности повторных запросов;
|
||||
простое вытеснение старых записей нарушает гарантию повторного ответа. После исправления изменить регрессию
|
||||
на успешное удаление более 64 объектов и проверить повторные запросы/потерю ответа.
|
||||
Сохранены64 квитанции/120с без досрочного вытеснения. BUSY.cursor сообщает ближайшее освобождение,
|
||||
клиент этих мутаций ждёт отменяемый таймер с прежним ID/параметрами и общим сроком120с без продления.
|
||||
Новая сетевая регрессия через настоящий клиентский API:76 созданий, потеря успешного ответа,
|
||||
затем76 удалений; QUOTA подтверждает76 и0 каталогов без дубликатов. Обычный и sanitizer-прогоны PASS.
|
||||
Старый Qt-stub вызывает storage напрямую и по-прежнему видит допустимый сырой MF_BUSY;
|
||||
он не проверяет новый клиентский retry. Подробности: [member_files_audit_findings.txt](member_files_audit_findings.txt).
|
||||
|
||||
[ ] **Desktop: провалы вероятности VAD при компрессии** — диагностика 09.10.2026:
|
||||
Воспроизведение пользователя: длительная гласная, затем временная нечувствительность; ближе к концу лога несколько повторов.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
Аудит оставшихся контрактов transport_layer — 25–26.10.2026
|
||||
|
||||
Область и итог
|
||||
Закрыты оставшиеся auto_socket, socket_monitor, socks_client, call_ring,
|
||||
reality/reality_fingerprint/reality_record/reality_io/reality_relay и stun_nat.
|
||||
Полностью прочитаны их реализации, заголовки и связанные участки владельцев/API.
|
||||
С учётом ранее завершённых ETCP/STCP/crypto/normalizer/NCD/dummynet/packet_dump/CRC
|
||||
сверены все 32 собственных заголовка и 17 документов src/transport_layer, а также doc/stun_nat.md.
|
||||
Вложенный BBR/bbr_v3.c не имеет отдельного заголовка/документа и относится к ранее сверенной
|
||||
реализации etcp_bbr. Это закрытие аудита описаний и найденных
|
||||
при нём дефектов, а не утверждение об отсутствии любых возможных ошибок транспорта.
|
||||
|
||||
socket_monitor
|
||||
- Удалён разбор FreeBSD sockaddr, безусловно писавший gw[0] при переданном NULL.
|
||||
Уведомления разбираются по родному типу if_msghdr/ifa_msghdr/if_announcemsghdr/rt_msghdr;
|
||||
проверяются длина и версия, используется только нужный индекс интерфейса.
|
||||
- При исчезновении адреса сбрасываются старые IPv4/IPv6, сохраняется порт, корректируется IPv6 scope.
|
||||
- Обход callbacks использует ID/поколение: удаление/пересоздание сокетов не оставляет висячий next.
|
||||
- Linux проверяет источник, границы netlink, EINTR/overflow; init откатывает каждый отказ.
|
||||
- Windows объединяет уведомления и отменяет pending post при остановке после завершения native producers.
|
||||
|
||||
auto_socket
|
||||
- UDP factory возвращает именно созданный endpoint; один общий reconcile заменяет дубли IPv4/IPv6 × UDP/TCP.
|
||||
- При замене сначала снимается старый слот, неудачная попытка не оставляет dangling pointer;
|
||||
частичный начальный запуск откатывается, последующие ошибки сохраняют работающие endpoints.
|
||||
- Для wildcard TCP listener отдельно хранится опубликованный IP интерфейса: неизменённый listener стабилен,
|
||||
реальная смена адреса/типа пересоздаёт его. DOWN/исчезнувшие интерфейсы удаляются после полного scan.
|
||||
- Порты SQLite проверяются по типу/диапазону, атомарный INSERT не оставляет открытую транзакцию.
|
||||
RNG проверяется, 65535 доступен. Остановка сохраняет кеш портов, удаление интерфейса очищает его.
|
||||
- Внешние/Windows scans объединяются в отменяемый post; native источники отключаются до освобождения.
|
||||
Внешние producers должны завершиться до destroy согласно контракту.
|
||||
- Android reload сохраняет одинаковые port=0 endpoints, учитывает scope/опции и обновляет ссылки client links
|
||||
до освобождения старого config. Ошибка частичного reload логируется, новый config остаётся владельцу instance.
|
||||
- Проверены длины/границы Linux/FreeBSD уведомлений, ошибки nonblocking/register и Windows API.
|
||||
|
||||
socks_client
|
||||
- CONNECT читает ровно control frame и сохраняет следующий TCP banner для владельца.
|
||||
- Start не вызывает completion inline при немедленном connect; timer/nonblocking обязательны с полным rollback.
|
||||
- Проверяются пределы строк, VER/RSV/REP, relay port/ATYP; wildcard UDP BND IP берётся из TCP peer.
|
||||
- Отправка учитывает EINTR, SIGPIPE и нулевой прогресс, проверяются переключения write monitoring.
|
||||
- UDP send возвращает payload bytes, не wire bytes; размер проверяется до сложения, NULL/0 допустимы.
|
||||
Unwrap сохраняет выходы при отказе и допускает in-place payload.
|
||||
- DNS start остаётся синхронным getaddrinfo с первым адресом; это явно отражено в контракте.
|
||||
|
||||
call_ring
|
||||
- История отдельная для каждого потока; насыщенный count сохраняет последние 1024 записи после wrap idx.
|
||||
- Указатели печатаются через PRIxPTR, включая Win64. Dump учитывает short write/EINTR и отказ файла,
|
||||
продолжает stderr без обычного logger/выделений памяти. snprintf не объявлен async-signal-safe.
|
||||
|
||||
REALITY
|
||||
- Уточнены одноразовость session, zero-init/cleanup, сохранение traffic после Finished,
|
||||
границы codec, отсутствие replay-защиты в stateless обёртках и отдельное владение owner/I/O.
|
||||
- Traffic init сбрасывает generation, KeyUpdate увеличивает его; replay entries различают пустой slot и Unix time=0.
|
||||
- Ошибки client_start полностью удаляют новое I/O; send забирает вход только при успехе.
|
||||
- Resume сохраняет штатный finish при close_notify; transient recv EINTR не закрывает поток.
|
||||
- Relay строго проверяет destination port/timeout/длину prefix, mandatory nonblocking/timer/monitor setup.
|
||||
SIGPIPE/EINTR/send=0 обрабатываются, счётчики учитывают каждый отправленный байт.
|
||||
- HUP дочитывает остаток/EOF: прежде uasync снимал регистрацию без except handler и relay ждал idle timeout.
|
||||
Регрессия отключает idle timeout и требует полного двустороннего half-close.
|
||||
- Закрытые relay остаются в owner до call_soon; отказ его выделения не теряет контекст.
|
||||
Shutdown отменяет отложенные frees и синхронно освобождает оба списка до уничтожения owner/UASYNC.
|
||||
|
||||
STUN
|
||||
- Recheck одним scan перезапускает старые и создаёт новые пути по одному разу; раньше новый путь
|
||||
немедленно открывал второй тестовый fd, расходуя первый свежий порт без измерения.
|
||||
- Уточнены исключение TCP/probe_only/SOCKS/only_local из API, SKIPPED IPv6/неизвестного IPv4,
|
||||
статусы результата, неизменность выхода при -1 и обязанность caller проверять TTL.
|
||||
- Контракты и RFC-последовательность сверены с https://www.rfc-editor.org/rfc/rfc5780
|
||||
и https://www.rfc-editor.org/rfc/rfc8489; обычный MATCH не объявляется доказательством EIM.
|
||||
|
||||
Проверки
|
||||
- ./build.sh --no-gui -j4: PASS, /tmp/transport-audit-final-build.log.
|
||||
- ./check.sh: 194/194 PASS, 0 failed/0 skipped; proxy 9/9, burst 1/1, load 8/8,
|
||||
134217728 проверенных байт, 170 секунд; /tmp/transport-audit-final-check.log.
|
||||
- Динамические IPv4/IPv6 сценарии auto_socket во всех UDP/TCP режимах и 8 циклах: PASS,
|
||||
/tmp/transport-audit-auto-dynamic-final.log, в отдельном Linux netns.
|
||||
- Проверки скрипта сборки: 44/44 PASS, /tmp/transport-audit-build-script.log.
|
||||
- ASAN/UBSan/LSan: 12/12 PASS с detect_leaks=1/ halt_on_error=1:
|
||||
socket_monitor, auto_socket_faults, socks_client, call_ring, reality_record, reality_hello,
|
||||
reality_tls, reality_fallback, reality_relay_faults, stcp_reality_rotation, stun_nat, tun_route.
|
||||
Все встроенные lib/src/Opus инструментированы согласованно в /tmp/utun-packet-dump-asan-20261025.
|
||||
Сборка: /tmp/transport-audit-asan-build.log и /tmp/transport-audit-asan-tests-build.log.
|
||||
Первый прогон /tmp/transport-audit-asan-results.log: 10 PASS и 2 отказа тестовых перехватов glibc FORTIFY.
|
||||
Добавлены checked recvfrom/poll wrappers; оба повторных прогона PASS, без отчётов санитайзеров:
|
||||
/tmp/transport-audit-asan-test_socket_monitor-final.log,
|
||||
/tmp/transport-audit-asan-test_tun_route-final.log. Последние изменения только стенда проверены адресно.
|
||||
Остальные индивидуальные логи: /tmp/transport-audit-asan-test_*.log.
|
||||
- Нативные runtime/сборки Windows и FreeBSD недоступны и не заявляются.
|
||||
FreeBSD типы уведомлений сверены с официальными заголовками releng/14.3:
|
||||
https://github.com/freebsd/freebsd-src/blob/releng/14.3/sys/net/if.h
|
||||
https://github.com/freebsd/freebsd-src/blob/releng/14.3/sys/net/route.h
|
||||
Полнота [V] означает сверку описаний по исходникам; платформенные runtime-ограничения сохранены здесь явно.
|
||||
@@ -0,0 +1,104 @@
|
||||
Аудит utun_instance — 26.10.2026
|
||||
|
||||
Объём
|
||||
Полностью прочитаны utun_instance.c/.h и прежний utun_instance_doc.md; сверены
|
||||
вызовы конструкторов/start/stop/reload из CLI/встраивания и связанные механизмы
|
||||
ETCP/NCD/STCP, sockets, logger, очередей, SQLite и независимых сервисов.
|
||||
Заголовок и документ переписаны по фактическому коду. Core-разделы
|
||||
doc/service_lifecycle.md согласованы; весь поперечный документ остаётся [~].
|
||||
Это не полный аудит реализаций chat/call/radio/stream или GUI/Android.
|
||||
|
||||
Исправления
|
||||
- Activity/peer-sleep подписки удерживают снятые записи до выхода из внешней
|
||||
рассылки. Безопасны удаление себя/соседа, вложенное событие и добавление новой
|
||||
подписки. Повтор fn+arg не дублируется; более новая эффективная activity
|
||||
отменяет остаток старой рассылки. OOM обеих подписок логируется.
|
||||
- Конструкторы проверяют UASYNC/config/path до побочных действий; общий путь
|
||||
создания освобождает частичные ресурсы, сохраняя caller config при отказе.
|
||||
Проверяются crypto derivation, запись/close временного файла, создание каталогов
|
||||
и индекс сетей. Старый TCP-реестр индексируется по node_id, а не указателю link.
|
||||
- Выбранный путь общего logger копируется в постоянный буфер; освобождение config
|
||||
больше не оставляет заимствованную строку в logger. Уже открытый файловый logger
|
||||
сохраняет первый путь при создании/reload другого instance.
|
||||
- Reload готовит config/firewall/networks до изменения ресурсов. Смена владельцев
|
||||
(в том числе db_path, control, TUN/NAT/proxy), подсетей или automatic server-конфига
|
||||
пересоздаёт ядро с прежними core/UTUN/chat/running. Повторные имена server также
|
||||
выбирают полный путь, сохраняя отдельные секции parser без неоднозначного matching.
|
||||
- Manual reload сравнивает все server-параметры и IPv6 scope по конфигурации,
|
||||
сохраняя неизменённые sockets/ephemeral ports. TCP listener закрывается вместе
|
||||
с правильной TCP-моделью; UDP создаётся/удаляется своим API. Устранён лог через
|
||||
освобождённое имя. Probe и endpoints других владельцев не удаляются по отсутствию
|
||||
server-секции. Конфигурационные clients обновляются также без UTUN; неизменённые
|
||||
handles/таймеры сохраняются, копии не ссылаются на освобождённый config.
|
||||
- Destroy обходит sockets/connections от свежей головы после callbacks,
|
||||
освобождает старые TCP queue entries и оставшиеся transport-подписки.
|
||||
Повторный destroy/start/reload при разрушении отклоняется; SQLite close ошибки
|
||||
видны, BUSY завершается close_v2 до финализации заимствованных SQL-объектов.
|
||||
- Три headless bind разбираются одинаково: IPv4 literal, полный decimal port
|
||||
0..65535. Port=0 выбирает ОС; плохое значение не открывает порт по умолчанию.
|
||||
Отказ listener не фатален для чата, частичный контекст очищается.
|
||||
- Call/radio headless stop освобождает контекст/кодеки, снимает клиентов с UASYNC
|
||||
и отменяет cleanup/mix таймеры. Их отказы при старте дают rollback.
|
||||
- UBSan нашёл невыровненный PCM после 11-байтового headless заголовка и uint64_t
|
||||
hop-list в radio_recv. Оба читаются через выровненное хранилище; тест рации также
|
||||
использует memcpy вместо int16_t* к невыровненному payload. Wire не менялся.
|
||||
- Из заголовка удалено объявление несуществующего utun_instance_run; poll принадлежит
|
||||
caller. Уточнены владение, состояния, ошибки init/reload, подписки и диагностика.
|
||||
|
||||
Контракты и ограничения
|
||||
Все lifecycle операции выполняются в потоке своего UASYNC, stop/destroy/reload —
|
||||
вне callbacks останавливаемых модулей. UASYNC принадлежит caller и живёт до конца
|
||||
очистки. Core start при отказе сохраняет подготовленные ресурсы для retry/destroy;
|
||||
init при отказе позднего сервиса сохраняет ранее запущенные сервисы.
|
||||
Create уже выполняет I/O и создаёт listeners/SQLite/router/proxy/monitoring.
|
||||
Create_from_str требует временного файла /tmp и не является чистым buffer parser.
|
||||
Logger общий для процесса; его изменения и config updater не входят в транзакцию reload.
|
||||
|
||||
Partial reload не атомарен: отказ endpoint сохраняет новый config и отсутствие
|
||||
endpoint для следующей попытки. Подготовка clients при ошибке сохраняет прежние
|
||||
самостоятельные handles. Full reload возвращает NULL, если новое ядро не удалось
|
||||
создать/запустить после уничтожения старого; caller использует возвращённый указатель.
|
||||
Порядок списков/padding модели может консервативно выбрать restart. IPv6 selector
|
||||
temporary/permanent меняет адрес в модели при разрешении, поэтому повторный reload
|
||||
может консервативно пересоздать такой endpoint.
|
||||
Caller завершает borrowed statements/backups; close_v2 не разрешает использовать
|
||||
уничтоженный instance. Живые ресурсы в diagnose_leaks сами по себе не доказывают утечку.
|
||||
Отменённые heap-таймеры UASYNC освобождаются при следующем poll, а pool cache — при
|
||||
уничтожении UASYNC; тест сверяет active/counters и общий баланс после обоих шагов.
|
||||
|
||||
Проверки
|
||||
- Полная обычная сборка PASS: /tmp/utun-instance-audit-build-final.log.
|
||||
- Первый полный check.sh: 196/196 PASS, 0 failed/0 skipped; proxy9/9, burst1/1,
|
||||
load8/8, 134217728 байт, 189с; /tmp/utun-instance-audit-final-check.log.
|
||||
Он предшествовал последней правке выравнивания radio hop-list.
|
||||
- Профиль после исправлений: test_instance_contract, test_services, test_ncd_config,
|
||||
test_tun_route, test_etcp_transport, test_stcp, test_node_conn_direct,
|
||||
test_call_headless, test_radio_headless PASS; три изменённых адресных теста
|
||||
повторены в /tmp/utun-instance-audit-final-test_*.log.
|
||||
- Новый test_instance_contract проверяет nested callbacks/OOM, constructor ownership,
|
||||
правильный TCP queue index, неизменённые ephemeral endpoints, malformed config,
|
||||
отказ network queue/create endpoint с retry, UDP/TCP замену и удаление listener,
|
||||
duplicate server names, full reload с сохранением stopped/core-only состояния,
|
||||
SQLite BUSY/finalize, другой UASYNC, db_path restart, recursive mkdir и жизнь
|
||||
logger path после освобождения config. Headless: три port=0, реальные TCP-клиенты,
|
||||
stop/start с сохранением SQLite, три timer faults и malformed binds.
|
||||
- ASAN/UBSan/LSan 11/11 PASS, detect_leaks=1, halt_on_error=1:
|
||||
те же девять instance/transport/headless тестов плюс config_parser/config_updater.
|
||||
Итог: /tmp/utun-instance-audit-asan-results-complete.log; отдельные логи
|
||||
/tmp/utun-instance-audit-asan-complete-test_*.log.
|
||||
Отдельная копия /tmp/utun-config-instance-asan-20261026, все lib/src/Opus
|
||||
инструментированы согласованно. Встроенный Opus не перекрывает CFLAGS в этой
|
||||
тестовой копии; рабочая normal-сборка с sanitizer-объектами не смешивалась.
|
||||
Сборки: /tmp/utun-instance-audit-asan-build.log,
|
||||
/tmp/utun-instance-audit-asan-core-final.log и ...-asan-tests-build.log.
|
||||
Ранние наборы 9/11 и 10/11 выявили описанные alignment defects; окончательный
|
||||
набор после исправления обеих причин полностью PASS.
|
||||
- git diff --check PASS. Нативные Windows/FreeBSD сборки/runtime, GUI/Android и
|
||||
аппаратные TUN/media в этом этапе не запускались; Linux TUN lifecycle проверен mock-тестом.
|
||||
|
||||
Окончательный штатный check.sh после последней правки: 196/196 PASS,
|
||||
0 failed/0 skipped, proxy9/9, burst1/1, load8/8, 134217728 байт, 173с.
|
||||
Лог /tmp/utun-instance-audit-complete-check.log. После этого изменены только комментарии
|
||||
о повторном core_start (он сохраняет running) и итоговые документы/чек-лист.
|
||||
utun_instance.h и utun_instance_doc.md отмечены [V]; ограничения общего документа
|
||||
service_lifecycle.md и прежние записи остальных модулей сохранены.
|
||||
@@ -5,6 +5,9 @@ libuasync_a_SOURCES = \
|
||||
u_async.h \
|
||||
ll_queue.h \
|
||||
ll_queue.c \
|
||||
packet_link.c \
|
||||
packet_link.h \
|
||||
packet_link_backend.h \
|
||||
debug_config.c \
|
||||
debug_config.h \
|
||||
timeout_heap.c \
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
project(vibechat_ble LANGUAGES C CXX)
|
||||
enable_testing()
|
||||
set(CMAKE_C_STANDARD 99)
|
||||
set(BLE_LIB "${CMAKE_CURRENT_SOURCE_DIR}/..")
|
||||
add_library(uasync STATIC ${BLE_LIB}/u_async.c ${BLE_LIB}/ll_queue.c ${BLE_LIB}/timeout_heap.c
|
||||
${BLE_LIB}/memory_pool.c ${BLE_LIB}/mem.c ${BLE_LIB}/debug_config.c
|
||||
${BLE_LIB}/platform_compat.c ${BLE_LIB}/socket_compat.c)
|
||||
target_include_directories(uasync PUBLIC "${BLE_LIB}")
|
||||
if(WIN32)
|
||||
target_link_libraries(uasync PUBLIC ws2_32 iphlpapi bcrypt)
|
||||
else()
|
||||
target_link_libraries(uasync PUBLIC pthread m)
|
||||
endif()
|
||||
endif()
|
||||
add_library(vibechat_ble_core OBJECT ble_transport.c ble_identity.c)
|
||||
target_link_libraries(vibechat_ble_core PUBLIC uasync)
|
||||
target_include_directories(vibechat_ble_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
set_target_properties(vibechat_ble_core PROPERTIES AUTOMOC OFF)
|
||||
if(WIN32)
|
||||
add_library(vibechat_ble STATIC ble_winrt.cpp)
|
||||
target_compile_features(vibechat_ble PRIVATE cxx_std_20)
|
||||
find_path(BLE_WINRT_INCLUDE winrt/Windows.Devices.Bluetooth.h REQUIRED)
|
||||
target_include_directories(vibechat_ble PRIVATE "${BLE_WINRT_INCLUDE}")
|
||||
target_link_libraries(vibechat_ble PRIVATE windowsapp)
|
||||
add_executable(test_ble_winrt tests/test_ble_winrt.cpp)
|
||||
target_compile_features(test_ble_winrt PRIVATE cxx_std_20)
|
||||
target_include_directories(test_ble_winrt PRIVATE "${BLE_WINRT_INCLUDE}")
|
||||
target_link_libraries(test_ble_winrt PRIVATE uasync windowsapp)
|
||||
set_target_properties(test_ble_winrt PROPERTIES AUTOMOC OFF)
|
||||
add_test(NAME test_ble_winrt COMMAND test_ble_winrt)
|
||||
set_tests_properties(test_ble_winrt PROPERTIES TIMEOUT 30)
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(BLE_SYSTEMD REQUIRED IMPORTED_TARGET libsystemd)
|
||||
add_library(vibechat_ble STATIC ble_bluez.c)
|
||||
target_link_libraries(vibechat_ble PRIVATE PkgConfig::BLE_SYSTEMD)
|
||||
else()
|
||||
message(FATAL_ERROR "BLE backend supports Linux and Windows")
|
||||
endif()
|
||||
target_link_libraries(vibechat_ble PUBLIC vibechat_ble_core)
|
||||
set_target_properties(vibechat_ble PROPERTIES AUTOMOC OFF)
|
||||
add_executable(test_ble_transport tests/test_ble_transport.c)
|
||||
target_link_libraries(test_ble_transport PRIVATE vibechat_ble_core)
|
||||
set_target_properties(test_ble_transport PROPERTIES AUTOMOC OFF)
|
||||
add_test(NAME test_ble_transport COMMAND test_ble_transport)
|
||||
set_tests_properties(test_ble_transport PROPERTIES TIMEOUT 60) # Включает настоящий 15s ACK watchdog.
|
||||
add_executable(ble_probe tests/ble_probe.c)
|
||||
target_link_libraries(ble_probe PRIVATE vibechat_ble)
|
||||
set_target_properties(ble_probe PROPERTIES AUTOMOC OFF)
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
find_program(BLE_DBUS_RUN_SESSION dbus-run-session REQUIRED)
|
||||
add_executable(test_ble_bluez tests/test_ble_bluez.c)
|
||||
target_link_libraries(test_ble_bluez PRIVATE vibechat_ble PkgConfig::BLE_SYSTEMD)
|
||||
set_target_properties(test_ble_bluez PROPERTIES AUTOMOC OFF)
|
||||
add_test(NAME test_ble_bluez COMMAND "${BLE_DBUS_RUN_SESSION}" -- $<TARGET_FILE:test_ble_bluez>)
|
||||
set_tests_properties(test_ble_bluez PROPERTIES TIMEOUT 30)
|
||||
endif()
|
||||
add_executable(ble_peer_probe tests/ble_peer_probe.c)
|
||||
target_link_libraries(ble_peer_probe PRIVATE vibechat_ble)
|
||||
set_target_properties(ble_peer_probe PROPERTIES AUTOMOC OFF)
|
||||
add_executable(test_ble_identity tests/test_ble_identity.c ble_identity.c)
|
||||
target_link_libraries(test_ble_identity PRIVATE uasync)
|
||||
target_include_directories(test_ble_identity PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
set_target_properties(test_ble_identity PROPERTIES AUTOMOC OFF)
|
||||
add_test(NAME test_ble_identity COMMAND test_ble_identity)
|
||||
@@ -0,0 +1,40 @@
|
||||
/* Внутренний платформенный контракт. Все вызовы/события в uasync, send/close не вызывают события синхронно.
|
||||
* ACCEPTED send копирует значение и даёт ровно один sent, либо closed отменяет все операции соединения.
|
||||
* DATA sent означает локальное завершение; INVITE/ACK используют Request/Indication.
|
||||
* close/destroy отменяют callbacks; transport создаёт идентификаторы, backend не хранит указатели core-link.
|
||||
* Реализации не создают неограниченных очередей: максимум одна операция send на link. */
|
||||
#ifndef VIBECHAT_BLE_BACKEND_H
|
||||
#define VIBECHAT_BLE_BACKEND_H
|
||||
#include "ble_transport.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define BLE_SERVICE_UUID "f61a9020-4abc-45ac-98e1-761df4c13811"
|
||||
#define BLE_RX_UUID "f61a9021-4abc-45ac-98e1-761df4c13811"
|
||||
#define BLE_TX_UUID "f61a9022-4abc-45ac-98e1-761df4c13811"
|
||||
#define BLE_INVITE_UUID "f61a9023-4abc-45ac-98e1-761df4c13811"
|
||||
#define BLE_ACK_UUID "f61a9024-4abc-45ac-98e1-761df4c13811"
|
||||
#define BLE_VALUE_MAX 512
|
||||
/* Wire-каналы backend; ACK не является прикладным каналом ble_channel. */
|
||||
enum ble_wire_channel { BLE_WIRE_DATA, BLE_WIRE_INVITE, BLE_WIRE_ACK, BLE_WIRE_COUNT };
|
||||
struct ble_backend;
|
||||
struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* config);
|
||||
void ble_backend_destroy(struct ble_backend* backend);
|
||||
int ble_backend_advertise(struct ble_backend* backend, const uint8_t* data, size_t len);
|
||||
int ble_backend_connect(struct ble_backend* backend, ble_link_id id, const struct ble_endpoint* endpoint);
|
||||
void ble_backend_close(struct ble_backend* backend, ble_link_id id);
|
||||
int ble_backend_send(struct ble_backend* backend, ble_link_id id, enum ble_wire_channel channel, const uint8_t* data, size_t len);
|
||||
/* События backend. incoming вызывается до любых других событий нового входящего link; 0 = лимит/отказ. */
|
||||
ble_link_id ble_backend_incoming(struct ble_transport* owner, const struct ble_endpoint* endpoint);
|
||||
void ble_backend_status(struct ble_transport* owner, int ready, const char* reason);
|
||||
void ble_backend_advertising(struct ble_transport* owner, enum ble_state state, const char* reason);
|
||||
void ble_backend_broadcast(struct ble_transport* owner, const struct ble_endpoint* endpoint,
|
||||
const uint8_t* data, size_t len, int rssi);
|
||||
void ble_backend_opened(struct ble_transport* owner, ble_link_id id, unsigned int mtu);
|
||||
void ble_backend_received(struct ble_transport* owner, ble_link_id id, enum ble_wire_channel channel, const uint8_t* data, size_t len);
|
||||
void ble_backend_sent(struct ble_transport* owner, ble_link_id id, int success);
|
||||
void ble_backend_closed(struct ble_transport* owner, ble_link_id id, const char* reason);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,645 @@
|
||||
/* BlueZ D-Bus через sd-bus (без Qt/GLib). Один uasync-поток, без блокирующих вызовов.
|
||||
* Серверные DATA notifications и INVITE/ACK indications адресуются через AcquireNotify fd каждого клиента.
|
||||
* DATA завершается после send(fd); INVITE/ACK ждут байт 1 от BlueZ (ATT completion). */
|
||||
#include "ble_backend.h"
|
||||
#include "../u_async.h"
|
||||
#include "../mem.h"
|
||||
#include "../debug_config.h"
|
||||
#include <systemd/sd-bus.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <time.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#define CAT DEBUG_CATEGORY_DISCOVERY
|
||||
#define ROOT "/org/vibechat/ble"
|
||||
#define SERVICE ROOT "/service0"
|
||||
#define RX SERVICE "/rx"
|
||||
#define TX SERVICE "/tx"
|
||||
#define INVITE SERVICE "/invite"
|
||||
#define ACK SERVICE "/ack"
|
||||
#define ADV ROOT "/advertisement"
|
||||
#define BEACON ROOT "/beacon"
|
||||
#define DEVICE_IF "org.bluez.Device1"
|
||||
#define CHAR_IF "org.bluez.GattCharacteristic1"
|
||||
struct bluez_link {
|
||||
struct ble_backend* backend;
|
||||
ble_link_id id;
|
||||
char device[BLE_ENDPOINT_MAX], rx[BLE_ENDPOINT_MAX], tx[BLE_ENDPOINT_MAX], invite[BLE_ENDPOINT_MAX], ack[BLE_ENDPOINT_MAX];
|
||||
sd_bus_slot *connect, *query, *subscribe, *write;
|
||||
int fd[BLE_WIRE_COUNT], outgoing, opened, sending, buffered, subscribed, channel, connected, resolved, mtu_known;
|
||||
void* io[BLE_WIRE_COUNT];
|
||||
unsigned int mtu;
|
||||
uint8_t value[BLE_VALUE_MAX];
|
||||
size_t value_size;
|
||||
};
|
||||
struct characteristic { struct ble_backend* backend; int tx; };
|
||||
struct advertisement { struct ble_backend* backend; int beacon; };
|
||||
struct ble_backend {
|
||||
struct ble_transport* owner;
|
||||
struct UASYNC* ua;
|
||||
sd_bus* bus;
|
||||
sd_bus_slot *setup, *signals, *objects, *removed, *owner_watch, *service, *rx, *tx, *invite, *ack, *manager, *adv, *beacon, *beacon_op;
|
||||
struct characteristic rx_context, tx_context, invite_context, ack_context;
|
||||
struct advertisement adv_context, beacon_context;
|
||||
struct bluez_link links[BLE_LINKS_MAX];
|
||||
char adapter[BLE_ENDPOINT_MAX], adapter_name[64], daemon_owner[BLE_ENDPOINT_MAX];
|
||||
uint16_t company;
|
||||
uint8_t data[BLE_BROADCAST_MAX], next_data[BLE_BROADCAST_MAX];
|
||||
size_t data_size, next_size;
|
||||
int beacon_registered, beacon_desired, beacon_action, step, failed, processing;
|
||||
void *io, *timer;
|
||||
};
|
||||
static void bus_update(struct ble_backend* b);
|
||||
static void bus_timer(void* arg);
|
||||
static void query_link(struct bluez_link* l);
|
||||
static void beacon_update(struct ble_backend* b);
|
||||
static void fail(struct ble_backend* b, const char* where, int error) {
|
||||
if (b->failed) return;
|
||||
b->failed = 1;
|
||||
DEBUG_ERROR(CAT, "BLE BlueZ %s: %s (%d)", where, strerror(error < 0 ? -error : error), error);
|
||||
ble_backend_status(b->owner, 0, where);
|
||||
}
|
||||
static int response_error(sd_bus_message* m, const char* where) {
|
||||
const sd_bus_error* e = sd_bus_message_get_error(m);
|
||||
if (!e) return 0;
|
||||
DEBUG_ERROR(CAT, "BLE BlueZ %s: %s: %s", where, e->name, e->message ? e->message : ""); return 1;
|
||||
}
|
||||
static void bus_process(struct ble_backend* b) {
|
||||
if (b->processing || b->failed) return;
|
||||
b->processing = 1;
|
||||
int r = 0;
|
||||
for (int i = 0; i < 64; i++) { r = sd_bus_process(b->bus, NULL); if (r <= 0 || b->failed) break; }
|
||||
b->processing = 0;
|
||||
if (r < 0) { fail(b, "D-Bus disconnected", r); return; }
|
||||
bus_update(b);
|
||||
if (r > 0 && !b->failed) {
|
||||
DEBUG_DEBUG(CAT, "BLE D-Bus drain budget exhausted; continuing buffered messages");
|
||||
if (b->timer) uasync_cancel_timeout(b->ua, b->timer);
|
||||
b->timer = uasync_set_timeout(b->ua, 0, b, bus_timer, "ble_dbus_drain");
|
||||
if (!b->timer) fail(b, "D-Bus drain allocation", ENOMEM);
|
||||
}
|
||||
}
|
||||
static void bus_io(int fd, void* arg) { (void)fd; bus_process(arg); }
|
||||
static void bus_timer(void* arg) { struct ble_backend* b = arg; b->timer = NULL; bus_process(b); }
|
||||
static void bus_update(struct ble_backend* b) {
|
||||
if (b->processing || b->failed) return;
|
||||
int events = sd_bus_get_events(b->bus);
|
||||
uint64_t timeout = UINT64_MAX;
|
||||
int r = sd_bus_get_timeout(b->bus, &timeout);
|
||||
if (events < 0 || r < 0) { fail(b, "D-Bus polling failed", events < 0 ? events : r); return; }
|
||||
uasync_set_socket_read(b->ua, b->io, !!(events & POLLIN));
|
||||
uasync_set_socket_write(b->ua, b->io, !!(events & POLLOUT));
|
||||
if (b->timer) { uasync_cancel_timeout(b->ua, b->timer); b->timer = NULL; }
|
||||
if (timeout != UINT64_MAX) {
|
||||
struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
uint64_t now = (uint64_t)ts.tv_sec * 1000000 + ts.tv_nsec / 1000;
|
||||
uint64_t delay = timeout > now ? (timeout - now + 99) / 100 : 0;
|
||||
b->timer = uasync_set_timeout(b->ua, delay > INT_MAX ? INT_MAX : (int)delay, b, bus_timer, "ble_dbus");
|
||||
if (!b->timer) fail(b, "D-Bus timer allocation", ENOMEM);
|
||||
}
|
||||
}
|
||||
static struct bluez_link* find_link(struct ble_backend* b, ble_link_id id) {
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) if (b->links[i].id == id && id) return &b->links[i];
|
||||
return NULL;
|
||||
}
|
||||
static struct bluez_link* find_device(struct ble_backend* b, const char* device) {
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) if (b->links[i].id && !strcmp(b->links[i].device, device)) return &b->links[i];
|
||||
return NULL;
|
||||
}
|
||||
static struct bluez_link* alloc_link(struct ble_backend* b, ble_link_id id, const char* device, int outgoing) {
|
||||
if (strlen(device) >= BLE_ENDPOINT_MAX || find_device(b, device)) return NULL;
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) if (!b->links[i].id) {
|
||||
struct bluez_link* l = &b->links[i]; memset(l, 0, sizeof(*l));
|
||||
l->backend = b; l->id = id;
|
||||
for (int ch = 0; ch < BLE_WIRE_COUNT; ch++) l->fd[ch] = -1;
|
||||
l->mtu = 23; l->outgoing = outgoing; strcpy(l->device, device); return l;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
static struct bluez_link* incoming(struct ble_backend* b, const char* device) {
|
||||
struct bluez_link* l = find_device(b, device);
|
||||
if (l) return l->outgoing ? NULL : l;
|
||||
struct ble_endpoint ep = {0};
|
||||
if (strlen(device) >= sizeof(ep.id)) return NULL;
|
||||
strcpy(ep.id, device);
|
||||
ble_link_id id = ble_backend_incoming(b->owner, &ep);
|
||||
if (!id) return NULL;
|
||||
l = alloc_link(b, id, device, 0);
|
||||
if (!l) ble_backend_closed(b->owner, id, "BlueZ link allocation failed");
|
||||
return l;
|
||||
}
|
||||
static void link_error(struct bluez_link* l, const char* reason) {
|
||||
DEBUG_WARN(CAT, "BLE BlueZ link=%llu failed: %s", (unsigned long long)l->id, reason);
|
||||
ble_backend_closed(l->backend->owner, l->id, reason);
|
||||
}
|
||||
static void notify_read(int fd, void* arg) {
|
||||
struct bluez_link* l = arg;
|
||||
uint8_t data[8]; ssize_t n = recv(fd, data, sizeof(data), MSG_DONTWAIT);
|
||||
if (n < 0 && (errno == EAGAIN || errno == EINTR)) return;
|
||||
if (n < 0) {
|
||||
DEBUG_ERROR(CAT, "BLE notification socket read failed link=%llu fd=%d errno=%d: %s",
|
||||
(unsigned long long)l->id, fd, errno, strerror(errno));
|
||||
link_error(l, "notification socket read failed"); return;
|
||||
}
|
||||
if (!n) { link_error(l, "notification subscription closed"); return; }
|
||||
if (n != 1 || data[0] != 1 || l->channel == BLE_WIRE_DATA || fd != l->fd[l->channel] ||
|
||||
!l->sending || l->buffered) {
|
||||
DEBUG_WARN(CAT, "BLE indication completion invalid fd=%d expected_fd=%d bytes=%zd sending=%d buffered=%d",
|
||||
fd, l->fd[l->channel], n, l->sending, l->buffered);
|
||||
link_error(l, "invalid indication completion"); return;
|
||||
}
|
||||
l->sending = 0;
|
||||
ble_backend_sent(l->backend->owner, l->id, 1);
|
||||
}
|
||||
static void notify_write(int fd, void* arg) {
|
||||
struct bluez_link* l = arg;
|
||||
if (!l->buffered) { uasync_set_socket_write(l->backend->ua, l->io[l->channel], 0); return; }
|
||||
ssize_t n = send(fd, l->value, l->value_size, MSG_DONTWAIT | MSG_NOSIGNAL);
|
||||
if (n < 0 && (errno == EAGAIN || errno == EINTR)) return;
|
||||
if (n != (ssize_t)l->value_size) {
|
||||
DEBUG_ERROR(CAT, "BLE notification socket write failed link=%llu channel=%d expected=%zu sent=%zd errno=%d",
|
||||
(unsigned long long)l->id, l->channel, l->value_size, n, n < 0 ? errno : 0);
|
||||
link_error(l, "notification socket write failed"); return;
|
||||
}
|
||||
l->buffered = 0; uasync_set_socket_write(l->backend->ua, l->io[l->channel], 0);
|
||||
if (l->channel == BLE_WIRE_DATA) {
|
||||
l->sending = 0;
|
||||
ble_backend_sent(l->backend->owner, l->id, 1);
|
||||
}
|
||||
}
|
||||
static int options(sd_bus_message* m, const char** device, uint16_t* mtu, uint16_t* offset) {
|
||||
int r = sd_bus_message_enter_container(m, 'a', "{sv}");
|
||||
if (r < 0) return r;
|
||||
const char *key, *type;
|
||||
while ((r = sd_bus_message_enter_container(m, 'e', "sv")) > 0) {
|
||||
if ((r = sd_bus_message_read(m, "s", &key)) < 0 || (r = sd_bus_message_peek_type(m, NULL, &type)) < 0) return r;
|
||||
if ((r = sd_bus_message_enter_container(m, 'v', type)) < 0) return r;
|
||||
if (!strcmp(key, "device") && !strcmp(type, "o")) r = sd_bus_message_read(m, "o", device);
|
||||
else if (!strcmp(key, "mtu") && !strcmp(type, "q")) r = sd_bus_message_read(m, "q", mtu);
|
||||
else if (!strcmp(key, "offset") && !strcmp(type, "q")) r = sd_bus_message_read(m, "q", offset);
|
||||
else r = sd_bus_message_skip(m, type);
|
||||
if (r < 0) return r;
|
||||
sd_bus_message_exit_container(m); sd_bus_message_exit_container(m);
|
||||
}
|
||||
if (r < 0) return r;
|
||||
return sd_bus_message_exit_container(m);
|
||||
}
|
||||
static int reject(sd_bus_message* m, const char* reason) {
|
||||
DEBUG_WARN(CAT, "BLE BlueZ request rejected: %s", reason);
|
||||
int r = sd_bus_reply_method_errorf(m, "org.bluez.Error.Failed", "%s", reason);
|
||||
if (r < 0) DEBUG_ERROR(CAT, "BLE BlueZ error reply failed: %s", strerror(-r));
|
||||
return r;
|
||||
}
|
||||
static int write_value(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct characteristic* ctx = arg; struct ble_backend* b = ctx->backend;
|
||||
const void* data; size_t len; const char* device = NULL; uint16_t mtu = 23, offset = 0;
|
||||
if (!sd_bus_message_get_sender(m) || strcmp(sd_bus_message_get_sender(m), b->daemon_owner)) return reject(m, "sender is not BlueZ");
|
||||
if (ctx->tx == 1 || sd_bus_message_read_array(m, 'y', &data, &len) < 0 || options(m, &device, &mtu, &offset) < 0 ||
|
||||
!device || offset || !len || len > BLE_VALUE_MAX || mtu < 23) return reject(m, "invalid WriteValue");
|
||||
struct bluez_link* l = incoming(b, device);
|
||||
if (!l) return reject(m, "connection unavailable");
|
||||
ble_link_id id = l->id;
|
||||
enum ble_wire_channel channel = ctx->tx >= 2 ? (enum ble_wire_channel)(ctx->tx - 1) : BLE_WIRE_DATA;
|
||||
ble_backend_received(b->owner, id, channel, data, len);
|
||||
if (!find_link(b, id)) return reject(m, "session rejected");
|
||||
int r = sd_bus_reply_method_return(m, "");
|
||||
if (r < 0) DEBUG_ERROR(CAT, "BLE BlueZ WriteValue reply failed link=%llu: %s", (unsigned long long)id, strerror(-r));
|
||||
return r;
|
||||
}
|
||||
static int acquire_notify(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct characteristic* ctx = arg; struct ble_backend* b = ctx->backend;
|
||||
const char* device = NULL; uint16_t mtu = 23, offset = 0;
|
||||
if (!sd_bus_message_get_sender(m) || strcmp(sd_bus_message_get_sender(m), b->daemon_owner)) return reject(m, "sender is not BlueZ");
|
||||
if (!ctx->tx || options(m, &device, &mtu, &offset) < 0 || !device || offset || mtu < 23) return reject(m, "invalid AcquireNotify");
|
||||
struct bluez_link* l = incoming(b, device);
|
||||
int channel = ctx->tx >= 2 ? ctx->tx - 1 : BLE_WIRE_DATA;
|
||||
if (!l || l->fd[channel] >= 0) return reject(m, "subscription unavailable");
|
||||
int pair[2];
|
||||
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_NONBLOCK | SOCK_CLOEXEC, 0, pair) < 0) {
|
||||
DEBUG_ERROR(CAT, "BLE notification socketpair failed: %s", strerror(errno)); return reject(m, "socketpair failed");
|
||||
}
|
||||
l->fd[channel] = pair[0]; l->mtu = mtu;
|
||||
l->io[channel] = uasync_add_socket(b->ua, l->fd[channel], notify_read, notify_write, notify_read, "ble_notify", l);
|
||||
if (!l->io[channel]) { close(pair[0]); close(pair[1]); l->fd[channel] = -1; return reject(m, "fd registration failed"); }
|
||||
uasync_set_socket_write(b->ua, l->io[channel], 0);
|
||||
int r = sd_bus_reply_method_return(m, "hq", pair[1], mtu); close(pair[1]);
|
||||
if (r < 0) {
|
||||
DEBUG_ERROR(CAT, "BLE BlueZ AcquireNotify reply failed link=%llu: %s", (unsigned long long)l->id, strerror(-r));
|
||||
link_error(l, "AcquireNotify reply failed"); return r;
|
||||
}
|
||||
DEBUG_DEBUG(CAT, "BLE BlueZ subscription link=%llu channel=%d mode=%s mtu=%u",
|
||||
(unsigned long long)l->id, channel, channel == BLE_WIRE_DATA ? "notification" : "indication", mtu);
|
||||
if (l->fd[0] >= 0 && l->fd[1] >= 0 && l->fd[2] >= 0 && !l->opened) {
|
||||
l->opened = 1; ble_backend_opened(b->owner, l->id, mtu);
|
||||
}
|
||||
bus_update(b); return r;
|
||||
}
|
||||
static int get_service(sd_bus* bus, const char* path, const char* interface, const char* property,
|
||||
sd_bus_message* reply, void* arg, sd_bus_error* error) {
|
||||
(void)bus; (void)path; (void)interface; (void)arg; (void)error;
|
||||
return !strcmp(property, "UUID") ? sd_bus_message_append(reply, "s", BLE_SERVICE_UUID) : sd_bus_message_append(reply, "b", 1);
|
||||
}
|
||||
static int get_char(sd_bus* bus, const char* path, const char* interface, const char* property,
|
||||
sd_bus_message* reply, void* arg, sd_bus_error* error) {
|
||||
(void)bus; (void)path; (void)interface; (void)error; struct characteristic* ctx = arg;
|
||||
if (!strcmp(property, "UUID")) return sd_bus_message_append(reply, "s",
|
||||
ctx->tx == 3 ? BLE_ACK_UUID : ctx->tx == 2 ? BLE_INVITE_UUID : ctx->tx ? BLE_TX_UUID : BLE_RX_UUID);
|
||||
if (!strcmp(property, "Service")) return sd_bus_message_append(reply, "o", SERVICE);
|
||||
if (!strcmp(property, "Flags") && ctx->tx >= 2) return sd_bus_message_append(reply, "as", 2, "write", "indicate");
|
||||
if (!strcmp(property, "Flags")) return sd_bus_message_append(reply, "as", 1, ctx->tx ? "notify" : "write-without-response");
|
||||
return sd_bus_message_append(reply, "b", 0); /* NotifyAcquired объявляет поддержку адресного fd API. */
|
||||
}
|
||||
static int get_adv(sd_bus* bus, const char* path, const char* interface, const char* property,
|
||||
sd_bus_message* reply, void* arg, sd_bus_error* error) {
|
||||
(void)bus; (void)path; (void)interface; (void)error;
|
||||
struct advertisement* a = arg; struct ble_backend* b = a->backend;
|
||||
if (!strcmp(property, "Type")) return sd_bus_message_append(reply, "s", "peripheral");
|
||||
if (!strcmp(property, "ServiceUUIDs")) return sd_bus_message_append(reply, "as", 1, BLE_SERVICE_UUID);
|
||||
int r = sd_bus_message_open_container(reply, 'a', "{qv}");
|
||||
if (r >= 0) r = sd_bus_message_open_container(reply, 'e', "qv");
|
||||
if (r >= 0) r = sd_bus_message_append(reply, "q", b->company);
|
||||
if (r >= 0) r = sd_bus_message_open_container(reply, 'v', "ay");
|
||||
if (r >= 0) r = sd_bus_message_append_array(reply, 'y', b->data, b->data_size);
|
||||
if (r >= 0) r = sd_bus_message_close_container(reply);
|
||||
if (r >= 0) r = sd_bus_message_close_container(reply);
|
||||
if (r >= 0) r = sd_bus_message_close_container(reply);
|
||||
return r;
|
||||
}
|
||||
static int adv_release(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct advertisement* a = arg;
|
||||
DEBUG_WARN(CAT, "BLE advertisement released by BlueZ beacon=%d", a->beacon);
|
||||
if (a->beacon) {
|
||||
a->backend->beacon_registered = 0;
|
||||
ble_backend_advertising(a->backend->owner, BLE_CLOSED, "advertisement released");
|
||||
}
|
||||
else fail(a->backend, "connectable advertisement released", EIO);
|
||||
return sd_bus_reply_method_return(m, "");
|
||||
}
|
||||
static const sd_bus_vtable service_table[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("UUID", "s", get_service, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("Primary", "b", get_service, 0, SD_BUS_VTABLE_PROPERTY_CONST), SD_BUS_VTABLE_END
|
||||
};
|
||||
static const sd_bus_vtable rx_table[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("UUID", "s", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("Service", "o", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("Flags", "as", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_METHOD("WriteValue", "aya{sv}", "", write_value, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static const sd_bus_vtable tx_table[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("UUID", "s", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("Service", "o", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("Flags", "as", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("NotifyAcquired", "b", get_char, 0, 0),
|
||||
SD_BUS_METHOD("AcquireNotify", "a{sv}", "hq", acquire_notify, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static const sd_bus_vtable invite_table[] = {
|
||||
SD_BUS_VTABLE_START(0),
|
||||
SD_BUS_METHOD("WriteValue", "aya{sv}", "", write_value, SD_BUS_VTABLE_UNPRIVILEGED),
|
||||
SD_BUS_PROPERTY("UUID", "s", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("Service", "o", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("Flags", "as", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("NotifyAcquired", "b", get_char, 0, 0),
|
||||
SD_BUS_METHOD("AcquireNotify", "a{sv}", "hq", acquire_notify, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static const sd_bus_vtable adv_table[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("Type", "s", get_adv, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("ServiceUUIDs", "as", get_adv, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_METHOD("Release", "", "", adv_release, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static const sd_bus_vtable beacon_table[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("Type", "s", get_adv, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("ManufacturerData", "a{qv}", get_adv, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_METHOD("Release", "", "", adv_release, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static int subscribed(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct bluez_link* l = arg; l->subscribe = sd_bus_slot_unref(l->subscribe);
|
||||
if (response_error(m, "StartNotify")) { link_error(l, "subscription failed"); return 0; }
|
||||
if (++l->subscribed < BLE_WIRE_COUNT) {
|
||||
const char* path = l->subscribed == BLE_WIRE_INVITE ? l->invite : l->ack;
|
||||
int r = sd_bus_call_method_async(l->backend->bus, &l->subscribe, "org.bluez", path, CHAR_IF,
|
||||
"StartNotify", subscribed, l, "");
|
||||
if (r < 0) link_error(l, "control subscription failed");
|
||||
return 0;
|
||||
}
|
||||
DEBUG_INFO(CAT, "BLE BlueZ DATA configured link=%llu write=without_response receive=notification mtu=%u; PHY/interval managed by OS/peer",
|
||||
(unsigned long long)l->id, l->mtu);
|
||||
l->opened = 1; ble_backend_opened(l->backend->owner, l->id, l->mtu); return 0;
|
||||
}
|
||||
/* Snapshot содержит также старые объявления; только live-событие обновляет discovery/last_seen. */
|
||||
static int properties(struct ble_backend* b, const char* path, const char* interface, sd_bus_message* m, int live) {
|
||||
const char *key, *type, *uuid = NULL;
|
||||
int r, resolved = -1, connected = -1, rssi = 127, have_data = 0;
|
||||
uint16_t mtu = 0;
|
||||
uint8_t data[BLE_BROADCAST_MAX]; size_t data_size = 0;
|
||||
if ((r = sd_bus_message_enter_container(m, 'a', "{sv}")) < 0) return r;
|
||||
while ((r = sd_bus_message_enter_container(m, 'e', "sv")) > 0) {
|
||||
if ((r = sd_bus_message_read(m, "s", &key)) < 0 || (r = sd_bus_message_peek_type(m, NULL, &type)) < 0) return r;
|
||||
if ((r = sd_bus_message_enter_container(m, 'v', type)) < 0) return r;
|
||||
if (!strcmp(interface, DEVICE_IF) && !strcmp(key, "ManufacturerData") && !strcmp(type, "a{qv}")) {
|
||||
sd_bus_message_enter_container(m, 'a', "{qv}");
|
||||
while ((r = sd_bus_message_enter_container(m, 'e', "qv")) > 0) {
|
||||
uint16_t company; const char* value_type;
|
||||
sd_bus_message_read(m, "q", &company); sd_bus_message_peek_type(m, NULL, &value_type);
|
||||
sd_bus_message_enter_container(m, 'v', value_type);
|
||||
if (company == b->company && !strcmp(value_type, "ay")) {
|
||||
const void* bytes; size_t size;
|
||||
r = sd_bus_message_read_array(m, 'y', &bytes, &size);
|
||||
if (r >= 0 && size <= sizeof(data)) { memcpy(data, bytes, size); data_size = size; have_data = 1; }
|
||||
else DEBUG_WARN(CAT, "BLE rejected advertisement size=%zu", r < 0 ? 0 : size);
|
||||
} else sd_bus_message_skip(m, value_type);
|
||||
sd_bus_message_exit_container(m); sd_bus_message_exit_container(m);
|
||||
}
|
||||
sd_bus_message_exit_container(m);
|
||||
} else if (!strcmp(interface, DEVICE_IF) && !strcmp(key, "RSSI") && !strcmp(type, "n")) {
|
||||
int16_t value; r = sd_bus_message_read(m, "n", &value); rssi = value;
|
||||
} else if (!strcmp(interface, DEVICE_IF) && !strcmp(type, "b") && (!strcmp(key, "Connected") || !strcmp(key, "ServicesResolved"))) {
|
||||
int value; r = sd_bus_message_read(m, "b", &value);
|
||||
if (!strcmp(key, "Connected")) connected = value; else resolved = value;
|
||||
} else if (!strcmp(interface, CHAR_IF) && !strcmp(key, "UUID") && !strcmp(type, "s")) r = sd_bus_message_read(m, "s", &uuid);
|
||||
else if (!strcmp(interface, CHAR_IF) && !strcmp(key, "MTU") && !strcmp(type, "q")) r = sd_bus_message_read(m, "q", &mtu);
|
||||
else if (!strcmp(interface, CHAR_IF) && !strcmp(key, "Value") && !strcmp(type, "ay")) {
|
||||
const void* bytes; size_t size; r = sd_bus_message_read_array(m, 'y', &bytes, &size);
|
||||
if (r >= 0) for (int i = 0; i < BLE_LINKS_MAX; i++) {
|
||||
struct bluez_link* l = &b->links[i];
|
||||
if (live && size && l->id && l->opened && l->outgoing &&
|
||||
(!strcmp(l->tx, path) || !strcmp(l->invite, path) || !strcmp(l->ack, path)))
|
||||
ble_backend_received(b->owner, l->id, !strcmp(l->ack, path) ? BLE_WIRE_ACK :
|
||||
!strcmp(l->invite, path) ? BLE_WIRE_INVITE : BLE_WIRE_DATA, bytes, size);
|
||||
}
|
||||
} else r = sd_bus_message_skip(m, type);
|
||||
if (r < 0) return r;
|
||||
sd_bus_message_exit_container(m); sd_bus_message_exit_container(m);
|
||||
}
|
||||
if (r < 0) return r;
|
||||
sd_bus_message_exit_container(m);
|
||||
if (!strcmp(interface, "org.bluez.Adapter1") && !b->adapter[0]) {
|
||||
const char* name = strrchr(path, '/');
|
||||
if (strlen(path) < sizeof(b->adapter) && (!b->adapter_name[0] || (name && !strcmp(name + 1, b->adapter_name)))) strcpy(b->adapter, path);
|
||||
}
|
||||
if (!strcmp(interface, DEVICE_IF)) {
|
||||
struct bluez_link* l = find_device(b, path);
|
||||
if (l && ((connected == 0 && (!l->outgoing || l->connected)) || (resolved == 0 && l->resolved)))
|
||||
link_error(l, connected == 0 ? "device disconnected" : "GATT services invalidated");
|
||||
else if (l && l->outgoing && (connected >= 0 || resolved >= 0)) {
|
||||
if (connected >= 0) l->connected = connected;
|
||||
if (resolved >= 0) l->resolved = resolved;
|
||||
DEBUG_DEBUG(CAT, "BLE BlueZ readiness link=%llu connected=%d resolved=%d opened=%d",
|
||||
(unsigned long long)l->id, l->connected, l->resolved, l->opened);
|
||||
if (l->connected && l->resolved && !l->opened && !l->query) query_link(l);
|
||||
}
|
||||
if (live && have_data && strlen(path) < BLE_ENDPOINT_MAX) {
|
||||
struct ble_endpoint ep = {0}; strcpy(ep.id, path); ble_backend_broadcast(b->owner, &ep, data, data_size, rssi);
|
||||
}
|
||||
}
|
||||
if (uuid) for (int i = 0; i < BLE_LINKS_MAX; i++) {
|
||||
struct bluez_link* l = &b->links[i]; size_t prefix = strlen(l->device);
|
||||
if (!l->id || !l->outgoing || l->opened || strncmp(path, l->device, prefix) || path[prefix] != '/' || strlen(path) >= BLE_ENDPOINT_MAX) continue;
|
||||
if (!strcmp(uuid, BLE_RX_UUID)) {
|
||||
strcpy(l->rx, path);
|
||||
if (mtu >= 23) { l->mtu = mtu; l->mtu_known = 1; }
|
||||
}
|
||||
if (!strcmp(uuid, BLE_TX_UUID)) strcpy(l->tx, path);
|
||||
if (!strcmp(uuid, BLE_INVITE_UUID)) strcpy(l->invite, path);
|
||||
if (!strcmp(uuid, BLE_ACK_UUID)) strcpy(l->ack, path);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
static int interfaces(struct ble_backend* b, const char* path, sd_bus_message* m, int live) {
|
||||
int r = sd_bus_message_enter_container(m, 'a', "{sa{sv}}"); const char* iface;
|
||||
if (r < 0) return r;
|
||||
while ((r = sd_bus_message_enter_container(m, 'e', "sa{sv}")) > 0) {
|
||||
if ((r = sd_bus_message_read(m, "s", &iface)) < 0 || (r = properties(b, path, iface, m, live)) < 0) return r;
|
||||
sd_bus_message_exit_container(m);
|
||||
}
|
||||
if (r < 0) return r;
|
||||
return sd_bus_message_exit_container(m);
|
||||
}
|
||||
static int managed_objects(struct ble_backend* b, sd_bus_message* m) {
|
||||
int r = sd_bus_message_enter_container(m, 'a', "{oa{sa{sv}}}"); const char* path;
|
||||
if (r < 0) return r;
|
||||
while ((r = sd_bus_message_enter_container(m, 'e', "oa{sa{sv}}")) > 0) {
|
||||
if ((r = sd_bus_message_read(m, "o", &path)) < 0 || (r = interfaces(b, path, m, 0)) < 0) return r;
|
||||
sd_bus_message_exit_container(m);
|
||||
}
|
||||
if (r < 0) return r;
|
||||
return sd_bus_message_exit_container(m);
|
||||
}
|
||||
static int queried(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct bluez_link* l = arg;
|
||||
/* Оставляем slot ненулевым при разборе, чтобы ServicesResolved не породил рекурсивный запрос. */
|
||||
int r = response_error(m, "GetManagedObjects") ? -EIO : managed_objects(l->backend, m);
|
||||
l->query = sd_bus_slot_unref(l->query);
|
||||
if (!l->id) return 0;
|
||||
if (r < 0) { link_error(l, "service discovery failed"); return 0; }
|
||||
DEBUG_DEBUG(CAT, "BLE BlueZ GATT snapshot link=%llu connected=%d resolved=%d RX=%d TX=%d INVITE=%d ACK=%d mtu=%u known=%d",
|
||||
(unsigned long long)l->id, l->connected, l->resolved, !!l->rx[0], !!l->tx[0], !!l->invite[0], !!l->ack[0], l->mtu, l->mtu_known);
|
||||
if (l->connected && l->resolved && l->rx[0] && l->tx[0] && l->invite[0] && l->ack[0] && !l->subscribe && !l->opened) {
|
||||
if (!l->mtu_known) DEBUG_WARN(CAT, "BLE BlueZ MTU unavailable link=%llu: using ATT minimum 23", (unsigned long long)l->id);
|
||||
r = sd_bus_call_method_async(l->backend->bus, &l->subscribe, "org.bluez", l->tx, CHAR_IF, "StartNotify", subscribed, l, "");
|
||||
if (r < 0) link_error(l, "StartNotify request failed");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
static void query_link(struct bluez_link* l) {
|
||||
if (l->query || l->subscribe || l->opened) return;
|
||||
int r = sd_bus_call_method_async(l->backend->bus, &l->query, "org.bluez", "/", "org.freedesktop.DBus.ObjectManager",
|
||||
"GetManagedObjects", queried, l, "");
|
||||
if (r < 0) link_error(l, "discovery request failed");
|
||||
}
|
||||
static int connected(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct bluez_link* l = arg; l->connect = sd_bus_slot_unref(l->connect);
|
||||
if (response_error(m, "Connect")) { link_error(l, "connect failed"); return 0; }
|
||||
query_link(l); return 0;
|
||||
}
|
||||
static int signal_changed(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct ble_backend* b = arg; const char* iface;
|
||||
int r = sd_bus_message_read(m, "s", &iface);
|
||||
if (r >= 0) r = properties(b, sd_bus_message_get_path(m), iface, m, 1);
|
||||
if (r < 0) DEBUG_WARN(CAT, "BLE invalid BlueZ PropertiesChanged: %s", strerror(-r));
|
||||
return 0;
|
||||
}
|
||||
static int object_added(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct ble_backend* b = arg; const char* path;
|
||||
int r = sd_bus_message_read(m, "o", &path);
|
||||
if (r >= 0) r = interfaces(b, path, m, 1);
|
||||
if (r < 0) DEBUG_WARN(CAT, "BLE invalid BlueZ InterfacesAdded: %s", strerror(-r));
|
||||
return 0;
|
||||
}
|
||||
static int object_removed(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct ble_backend* b = arg; const char* path;
|
||||
if (sd_bus_message_read(m, "o", &path) < 0) { DEBUG_WARN(CAT, "BLE invalid InterfacesRemoved"); return 0; }
|
||||
if (!strcmp(path, b->adapter)) { fail(b, "Bluetooth adapter removed", ENODEV); return 0; }
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) {
|
||||
struct bluez_link* l = &b->links[i];
|
||||
if (l->id && (!strcmp(path, l->device) || !strcmp(path, l->rx) || !strcmp(path, l->tx) ||
|
||||
!strcmp(path, l->invite) || !strcmp(path, l->ack))) link_error(l, "BlueZ object removed");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
static int owner_changed(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; const char *name, *old_owner, *new_owner;
|
||||
if (sd_bus_message_read(m, "sss", &name, &old_owner, &new_owner) >= 0 && !new_owner[0]) fail(arg, "BlueZ stopped", ECONNRESET);
|
||||
return 0;
|
||||
}
|
||||
static int setup_reply(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct ble_backend* b = arg; b->setup = sd_bus_slot_unref(b->setup);
|
||||
if (response_error(m, "startup")) { fail(b, "BlueZ startup failed", EIO); return 0; }
|
||||
int r = 0;
|
||||
switch (b->step++) {
|
||||
case 0:
|
||||
snprintf(b->daemon_owner, sizeof(b->daemon_owner), "%s", sd_bus_message_get_sender(m));
|
||||
r = managed_objects(b, m);
|
||||
if (r < 0 || !b->adapter[0]) { fail(b, "Bluetooth adapter unavailable", r < 0 ? r : ENODEV); return 0; }
|
||||
r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", b->adapter, "org.bluez.GattManager1",
|
||||
"RegisterApplication", setup_reply, b, "oa{sv}", ROOT, 0); break;
|
||||
case 1:
|
||||
r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", b->adapter, "org.bluez.LEAdvertisingManager1",
|
||||
"RegisterAdvertisement", setup_reply, b, "oa{sv}", ADV, 0); break;
|
||||
case 2:
|
||||
r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", b->adapter, "org.bluez.Adapter1",
|
||||
"SetDiscoveryFilter", setup_reply, b, "a{sv}", 2, "Transport", "s", "le", "DuplicateData", "b", 1); break;
|
||||
case 3:
|
||||
r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", b->adapter, "org.bluez.Adapter1", "StartDiscovery", setup_reply, b, ""); break;
|
||||
case 4: ble_backend_status(b->owner, 1, "BlueZ scan/GATT ready"); break;
|
||||
}
|
||||
if (r < 0) fail(b, "BlueZ setup request failed", r);
|
||||
return 0;
|
||||
}
|
||||
static int beacon_reply(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct ble_backend* b = arg; b->beacon_op = sd_bus_slot_unref(b->beacon_op);
|
||||
if (response_error(m, "advertisement update")) { fail(b, "advertisement update failed", EIO); return 0; }
|
||||
b->beacon_registered = b->beacon_action == 1; b->beacon_action = 0;
|
||||
DEBUG_DEBUG(CAT, "BLE broadcast registered=%d bytes=%zu", b->beacon_registered, b->data_size);
|
||||
ble_backend_advertising(b->owner, b->beacon_registered ? BLE_READY : BLE_CLOSED, "BlueZ advertisement registration changed");
|
||||
beacon_update(b); return 0;
|
||||
}
|
||||
static void beacon_update(struct ble_backend* b) {
|
||||
if (b->beacon_op || b->failed) return;
|
||||
int r;
|
||||
if (b->beacon_registered) {
|
||||
if (b->beacon_desired && b->next_size == b->data_size && !memcmp(b->data, b->next_data, b->data_size)) return;
|
||||
b->beacon_action = 2;
|
||||
r = sd_bus_call_method_async(b->bus, &b->beacon_op, "org.bluez", b->adapter, "org.bluez.LEAdvertisingManager1",
|
||||
"UnregisterAdvertisement", beacon_reply, b, "o", BEACON);
|
||||
} else {
|
||||
if (!b->beacon_desired) return;
|
||||
b->beacon_action = 1;
|
||||
b->data_size = b->next_size; memcpy(b->data, b->next_data, b->data_size);
|
||||
r = sd_bus_call_method_async(b->bus, &b->beacon_op, "org.bluez", b->adapter, "org.bluez.LEAdvertisingManager1",
|
||||
"RegisterAdvertisement", beacon_reply, b, "oa{sv}", BEACON, 0);
|
||||
}
|
||||
if (r < 0) fail(b, "advertisement request failed", r);
|
||||
}
|
||||
struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* cfg) {
|
||||
struct ble_backend* b = u_calloc(1, sizeof(*b));
|
||||
if (!b) { DEBUG_ERROR(CAT, "BLE BlueZ allocation failed"); return NULL; }
|
||||
b->owner = owner; b->ua = ua; b->company = cfg->company_id;
|
||||
if (cfg->adapter) snprintf(b->adapter_name, sizeof(b->adapter_name), "%s", cfg->adapter);
|
||||
b->rx_context.backend = b; b->tx_context.backend = b; b->tx_context.tx = 1;
|
||||
b->invite_context.backend = b; b->invite_context.tx = 2;
|
||||
b->ack_context.backend = b; b->ack_context.tx = 3;
|
||||
b->adv_context.backend = b; b->beacon_context.backend = b; b->beacon_context.beacon = 1;
|
||||
int r = sd_bus_open_system(&b->bus);
|
||||
if (r >= 0) r = sd_bus_add_object_manager(b->bus, &b->manager, ROOT);
|
||||
if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->service, SERVICE, "org.bluez.GattService1", service_table, b);
|
||||
if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->rx, RX, CHAR_IF, rx_table, &b->rx_context);
|
||||
if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->tx, TX, CHAR_IF, tx_table, &b->tx_context);
|
||||
if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->invite, INVITE, CHAR_IF, invite_table, &b->invite_context);
|
||||
if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->ack, ACK, CHAR_IF, invite_table, &b->ack_context);
|
||||
if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->adv, ADV, "org.bluez.LEAdvertisement1", adv_table, &b->adv_context);
|
||||
if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->beacon, BEACON, "org.bluez.LEAdvertisement1", beacon_table, &b->beacon_context);
|
||||
if (r >= 0) r = sd_bus_add_match_async(b->bus, &b->signals,
|
||||
"type='signal',sender='org.bluez',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged'", signal_changed, NULL, b);
|
||||
if (r >= 0) r = sd_bus_add_match_async(b->bus, &b->objects,
|
||||
"type='signal',sender='org.bluez',interface='org.freedesktop.DBus.ObjectManager',member='InterfacesAdded'", object_added, NULL, b);
|
||||
if (r >= 0) r = sd_bus_add_match_async(b->bus, &b->removed,
|
||||
"type='signal',sender='org.bluez',interface='org.freedesktop.DBus.ObjectManager',member='InterfacesRemoved'", object_removed, NULL, b);
|
||||
if (r >= 0) r = sd_bus_add_match_async(b->bus, &b->owner_watch,
|
||||
"type='signal',sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.bluez'",
|
||||
owner_changed, NULL, b);
|
||||
if (r >= 0) r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", "/", "org.freedesktop.DBus.ObjectManager",
|
||||
"GetManagedObjects", setup_reply, b, "");
|
||||
if (r >= 0) { b->io = uasync_add_socket(ua, sd_bus_get_fd(b->bus), bus_io, bus_io, bus_io, "ble_dbus", b); if (!b->io) r = -ENOMEM; }
|
||||
if (r < 0) { DEBUG_ERROR(CAT, "BLE BlueZ create: %s", strerror(-r)); ble_backend_destroy(b); return NULL; }
|
||||
bus_update(b); return b;
|
||||
}
|
||||
void ble_backend_close(struct ble_backend* b, ble_link_id id) {
|
||||
struct bluez_link* l = find_link(b, id); if (!l) return;
|
||||
l->connect = sd_bus_slot_unref(l->connect); l->query = sd_bus_slot_unref(l->query);
|
||||
l->subscribe = sd_bus_slot_unref(l->subscribe); l->write = sd_bus_slot_unref(l->write);
|
||||
for (int channel = 0; channel < BLE_WIRE_COUNT; channel++) {
|
||||
if (l->io[channel]) uasync_remove_socket(b->ua, l->io[channel]);
|
||||
if (l->fd[channel] >= 0) close(l->fd[channel]);
|
||||
}
|
||||
/* Не разрываем общий Device1 ACL: он может принадлежать другим профилям/приложениям. */
|
||||
if (l->outgoing && !b->failed) for (int channel = 0; channel < l->subscribed; channel++) {
|
||||
const char* path = channel == BLE_WIRE_ACK ? l->ack : channel == BLE_WIRE_INVITE ? l->invite : l->tx;
|
||||
int r = sd_bus_call_method_async(b->bus, NULL, "org.bluez", path, CHAR_IF, "StopNotify", NULL, NULL, "");
|
||||
if (r < 0) DEBUG_WARN(CAT, "BLE StopNotify failed: %s", strerror(-r));
|
||||
}
|
||||
memset(l, 0, sizeof(*l)); bus_update(b);
|
||||
}
|
||||
void ble_backend_destroy(struct ble_backend* b) {
|
||||
if (!b) return;
|
||||
b->failed = 1;
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) if (b->links[i].id) ble_backend_close(b, b->links[i].id);
|
||||
if (b->timer) uasync_cancel_timeout(b->ua, b->timer);
|
||||
if (b->io) uasync_remove_socket(b->ua, b->io);
|
||||
sd_bus_slot_unref(b->setup); sd_bus_slot_unref(b->signals); sd_bus_slot_unref(b->objects); sd_bus_slot_unref(b->removed);
|
||||
sd_bus_slot_unref(b->owner_watch);
|
||||
sd_bus_slot_unref(b->service); sd_bus_slot_unref(b->rx); sd_bus_slot_unref(b->tx); sd_bus_slot_unref(b->invite);
|
||||
sd_bus_slot_unref(b->ack); sd_bus_slot_unref(b->manager);
|
||||
sd_bus_slot_unref(b->adv); sd_bus_slot_unref(b->beacon); sd_bus_slot_unref(b->beacon_op);
|
||||
/* Закрытие приватного bus освобождает регистрации/scan сессии в BlueZ по owner-disconnect. */
|
||||
sd_bus_close_unref(b->bus); u_free(b);
|
||||
}
|
||||
int ble_backend_advertise(struct ble_backend* b, const uint8_t* data, size_t len) {
|
||||
if (b->failed) return BLE_NOT_READY;
|
||||
b->beacon_desired = data != NULL; b->next_size = len; if (len) memcpy(b->next_data, data, len);
|
||||
if (!b->beacon_op && b->beacon_desired == b->beacon_registered &&
|
||||
(!b->beacon_desired || (len == b->data_size && !memcmp(b->data, b->next_data, len)))) {
|
||||
ble_backend_advertising(b->owner, b->beacon_registered ? BLE_READY : BLE_CLOSED, "advertisement unchanged"); return BLE_OK;
|
||||
}
|
||||
ble_backend_advertising(b->owner, BLE_STARTING, "updating advertisement");
|
||||
beacon_update(b); bus_update(b); return b->failed ? BLE_ERROR : BLE_OK;
|
||||
}
|
||||
int ble_backend_connect(struct ble_backend* b, ble_link_id id, const struct ble_endpoint* ep) {
|
||||
if (b->failed || strncmp(ep->id, b->adapter, strlen(b->adapter)) || !strstr(ep->id, "/dev_")) {
|
||||
DEBUG_WARN(CAT, "BLE invalid BlueZ endpoint=%s", ep->id); return BLE_ERROR;
|
||||
}
|
||||
struct bluez_link* l = alloc_link(b, id, ep->id, 1);
|
||||
if (!l) { DEBUG_WARN(CAT, "BLE BlueZ duplicate/limit endpoint=%s", ep->id); return BLE_ERROR; }
|
||||
int r = sd_bus_call_method_async(b->bus, &l->connect, "org.bluez", l->device, DEVICE_IF, "Connect", connected, l, "");
|
||||
if (r < 0) { DEBUG_ERROR(CAT, "BLE Connect request: %s", strerror(-r)); ble_backend_close(b, id); return BLE_ERROR; }
|
||||
bus_update(b); return BLE_OK;
|
||||
}
|
||||
static int written(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct bluez_link* l = arg; l->write = sd_bus_slot_unref(l->write); l->sending = 0;
|
||||
ble_backend_sent(l->backend->owner, l->id, !response_error(m, "WriteValue")); return 0;
|
||||
}
|
||||
int ble_backend_send(struct ble_backend* b, ble_link_id id, enum ble_wire_channel channel, const uint8_t* data, size_t len) {
|
||||
struct bluez_link* l = find_link(b, id);
|
||||
if ((unsigned)channel >= BLE_WIRE_COUNT || !l || !l->opened || l->sending || !len || len > BLE_VALUE_MAX) {
|
||||
DEBUG_ERROR(CAT, "BLE invalid backend send link=%llu", (unsigned long long)id); return BLE_ERROR;
|
||||
}
|
||||
l->channel = channel;
|
||||
if (!l->outgoing) {
|
||||
memcpy(l->value, data, len); l->value_size = len; l->buffered = l->sending = 1;
|
||||
uasync_set_socket_write(b->ua, l->io[channel], 1); return BLE_OK;
|
||||
}
|
||||
sd_bus_message* m = NULL;
|
||||
const char* path = channel == BLE_WIRE_ACK ? l->ack : channel == BLE_WIRE_INVITE ? l->invite : l->rx;
|
||||
int r = sd_bus_message_new_method_call(b->bus, &m, "org.bluez", path, CHAR_IF, "WriteValue");
|
||||
if (r >= 0) r = sd_bus_message_append_array(m, 'y', data, len);
|
||||
if (r >= 0) r = sd_bus_message_append(m, "a{sv}", 1, "type", "s", channel == BLE_WIRE_DATA ? "command" : "request");
|
||||
if (r >= 0) r = sd_bus_call_async(b->bus, &l->write, m, written, l, 10000000);
|
||||
sd_bus_message_unref(m);
|
||||
if (r < 0) { DEBUG_ERROR(CAT, "BLE WriteValue request: %s", strerror(-r)); return BLE_ERROR; }
|
||||
DEBUG_TRACE(CAT, "BLE WriteValue submitted link=%llu channel=%d type=%s bytes=%zu", (unsigned long long)id, channel,
|
||||
channel == BLE_WIRE_DATA ? "command" : "request", len);
|
||||
l->sending = 1; bus_update(b); return BLE_OK;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "ble_identity.h"
|
||||
#include "../debug_config.h"
|
||||
#include <string.h>
|
||||
/* Строгая проверка scalar value: запрещены overlong, surrogate и > U+10FFFF. */
|
||||
static int character(const unsigned char* p, size_t n) {
|
||||
unsigned v; int k;
|
||||
if (!n || !p[0]) return -1;
|
||||
if (p[0] < 128) return 1;
|
||||
if (p[0] >= 0xc2 && p[0] <= 0xdf) { k = 2; v = p[0] & 31; }
|
||||
else if (p[0] >= 0xe0 && p[0] <= 0xef) { k = 3; v = p[0] & 15; }
|
||||
else if (p[0] >= 0xf0 && p[0] <= 0xf4) { k = 4; v = p[0] & 7; }
|
||||
else return -1;
|
||||
if (n < (size_t)k) return -1;
|
||||
for (int i = 1; i < k; i++) { if ((p[i] & 0xc0) != 0x80) return -1; v = (v << 6) | (p[i] & 63); }
|
||||
if ((k == 3 && v < 0x800) || (k == 4 && v < 0x10000) || (v >= 0xd800 && v <= 0xdfff) || v > 0x10ffff) return -1;
|
||||
return k;
|
||||
}
|
||||
int ble_identity_encode(uint8_t out[24], uint64_t id, const char* name) {
|
||||
if (!out || !id || !name) goto invalid;
|
||||
size_t length = strlen(name), offset = 0;
|
||||
while (offset < length) {
|
||||
int n = character((const unsigned char*)name + offset, length - offset);
|
||||
if (n < 0) goto invalid;
|
||||
if (offset + n > 13) break;
|
||||
offset += n;
|
||||
}
|
||||
out[0] = 'V'; out[1] = 'C'; out[2] = BLE_IDENTITY_VERSION;
|
||||
for (int i = 10; i >= 3; i--) { out[i] = id; id >>= 8; }
|
||||
memcpy(out + 11, name, offset); return (int)(11 + offset);
|
||||
invalid:
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "BLE identity encode: invalid ID/name"); return -1;
|
||||
}
|
||||
int ble_identity_decode(struct ble_identity* out, const uint8_t* data, size_t size) {
|
||||
if (!out || !data || size < 11 || size > 24 || data[0] != 'V' || data[1] != 'C' || data[2] != BLE_IDENTITY_VERSION) return -1;
|
||||
struct ble_identity result = {0};
|
||||
for (int i = 3; i < 11; i++) result.node_id = (result.node_id << 8) | data[i];
|
||||
if (!result.node_id) goto invalid;
|
||||
for (size_t pos = 11; pos < size;) { int n = character(data + pos, size - pos); if (n < 0) goto invalid; pos += n; }
|
||||
memcpy(result.name, data + 11, size - 11); *out = result; return 0;
|
||||
invalid:
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "BLE identity decode: invalid ID/UTF-8"); return -1;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Объявление VibeChat: VC, версия 3, node_id BE64, имя UTF-8 до 13 байт.
|
||||
* encode возвращает размер 11..24 либо -1; decode возвращает 0 либо -1.
|
||||
* Имя сокращается по целому символу; decode отклоняет неверный UTF-8 и нулевой ID. */
|
||||
#ifndef VIBECHAT_BLE_IDENTITY_H
|
||||
#define VIBECHAT_BLE_IDENTITY_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#define BLE_IDENTITY_VERSION 3 /* DATA с 16-bit flow ID и обязательной ACK-характеристикой. */
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
struct ble_identity { uint64_t node_id; char name[14]; };
|
||||
int ble_identity_encode(uint8_t output[24], uint64_t node_id, const char* name);
|
||||
int ble_identity_decode(struct ble_identity* output, const uint8_t* data, size_t size);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,558 @@
|
||||
#include "ble_backend.h"
|
||||
#include "../u_async.h"
|
||||
#include "../mem.h"
|
||||
#include "../debug_config.h"
|
||||
#include <string.h>
|
||||
#define CAT DEBUG_CATEGORY_DISCOVERY
|
||||
#define HELLO 1
|
||||
#define ACCEPT 2
|
||||
#define FRAME 3
|
||||
#define HEADER 7
|
||||
#define FLOW_HEADER 2
|
||||
#define FLOW_MIN_VALUE (FLOW_HEADER + HEADER + 1)
|
||||
#define FLOW_SLOTS (BLE_TX_WINDOW_BYTES / FLOW_MIN_VALUE)
|
||||
#define DEADLINE_US 15000000ULL
|
||||
#define STATS_US 5000000ULL
|
||||
struct ble_stats {
|
||||
uint64_t since, tx_bytes, rx_bytes, wait_us, max_wait_us;
|
||||
unsigned tx_frames, rx_frames, fragments, dropped, missing, ack_tx, ack_rx, max_flight;
|
||||
};
|
||||
struct ble_channel_state {
|
||||
struct ll_queue *tx, *rx;
|
||||
struct ll_entry* current;
|
||||
uint64_t rx_deadline;
|
||||
uint16_t sequence, offset, rx_sequence, rx_size, rx_offset, dropped_sequence, rx_next;
|
||||
int blocked, dropping, rx_seen;
|
||||
uint8_t assembly[BLE_FRAME_MAX];
|
||||
};
|
||||
struct ble_connection {
|
||||
struct ble_transport* owner;
|
||||
ble_link_id id;
|
||||
uint64_t peer, deadline, submitted;
|
||||
struct ble_stats stats;
|
||||
struct ble_endpoint endpoint;
|
||||
struct ble_channel_state channels[BLE_CHANNEL_COUNT];
|
||||
unsigned int capacity;
|
||||
uint16_t pending_bytes;
|
||||
/* ID монотонен по модулю 65536; кольцо хранит только размеры, payload остаётся у ETCP. */
|
||||
uint16_t tx_next, tx_acked, rx_last, flight_head, flight_count, flight_bytes, flight_sizes[FLOW_SLOTS];
|
||||
uint64_t flight_deadline, ack_due, ack_completed;
|
||||
void* ack_timer;
|
||||
int rx_flow_seen, ack_dirty, pending_ack;
|
||||
enum ble_channel pending_channel, next_channel;
|
||||
int outgoing, opened, ready, pending, control_pending;
|
||||
uint8_t control[17];
|
||||
};
|
||||
struct ble_transport {
|
||||
struct UASYNC* ua;
|
||||
struct ble_config config;
|
||||
char adapter[64];
|
||||
struct ble_backend* backend;
|
||||
struct ble_connection links[BLE_LINKS_MAX];
|
||||
ble_link_id sequence;
|
||||
void* timer;
|
||||
int started, ready, reject_incoming;
|
||||
};
|
||||
static uint16_t read16(const uint8_t* p) { return (uint16_t)((p[0] << 8) | p[1]); }
|
||||
static void write16(uint8_t* p, uint16_t v) { p[0] = v >> 8; p[1] = v; }
|
||||
static uint64_t read64(const uint8_t* p) {
|
||||
uint64_t v = 0;
|
||||
for (int i = 0; i < 8; i++) v = (v << 8) | p[i];
|
||||
return v;
|
||||
}
|
||||
static void write64(uint8_t* p, uint64_t v) { for (int i = 7; i >= 0; i--) { p[i] = v; v >>= 8; } }
|
||||
/* Диагностический отпечаток зашифрованного кадра для сопоставления журналов двух узлов. */
|
||||
static uint32_t frame_tag(const uint8_t* data, size_t len) {
|
||||
uint32_t tag = 2166136261u;
|
||||
for (size_t i = 0; i < len; i++) tag = (tag ^ data[i]) * 16777619u;
|
||||
return tag;
|
||||
}
|
||||
static void release_entry(struct ll_entry* e) { if (e) { queue_dgram_free(e); queue_entry_free(e); } }
|
||||
static void pump(struct ble_transport* t, struct ble_connection* c);
|
||||
static struct ble_connection* lookup(struct ble_transport* t, ble_link_id id) {
|
||||
if (t && id) for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id == id) return &t->links[i];
|
||||
return NULL;
|
||||
}
|
||||
/* Сводка раз в пять секунд и при закрытии: payload кадров, очереди и локальное ожидание GATT. */
|
||||
static void log_stats(struct ble_connection* c, uint64_t now) {
|
||||
struct ble_stats* s = &c->stats;
|
||||
uint64_t elapsed = now > s->since ? now - s->since : 1;
|
||||
if (s->tx_frames || s->rx_frames || s->fragments || s->dropped || c->pending || c->flight_count) {
|
||||
DEBUG_DEBUG(CAT, "BLE traffic link=%llu outgoing=%d elapsed_ms=%llu TX_Bps=%llu RX_Bps=%llu TX_frames=%u RX_frames=%u "
|
||||
"fragments=%u drops=%u missing=%u wait_avg_us=%llu wait_max_us=%llu DATA_queue=%d INVITE_queue=%d pending_ms=%llu",
|
||||
(unsigned long long)c->id, c->outgoing, (unsigned long long)(elapsed / 1000),
|
||||
(unsigned long long)(s->tx_bytes * 1000000 / elapsed), (unsigned long long)(s->rx_bytes * 1000000 / elapsed),
|
||||
s->tx_frames, s->rx_frames, s->fragments, s->dropped, s->missing,
|
||||
(unsigned long long)(s->fragments ? s->wait_us / s->fragments : 0), (unsigned long long)s->max_wait_us,
|
||||
queue_entry_count(c->channels[BLE_CHANNEL_DATA].tx) + !!c->channels[BLE_CHANNEL_DATA].current,
|
||||
queue_entry_count(c->channels[BLE_CHANNEL_INVITE].tx) + !!c->channels[BLE_CHANNEL_INVITE].current,
|
||||
(unsigned long long)(c->pending ? (now - c->submitted) / 1000 : 0));
|
||||
DEBUG_DEBUG(CAT, "BLE flow link=%llu flight=%u/%u bytes=%u/%u peak_bytes=%u tx_next=%u acked=%u "
|
||||
"rx_last=%u ACK_tx=%u ACK_rx=%u ack_dirty=%d ack_pending=%d",
|
||||
(unsigned long long)c->id, c->flight_count, (unsigned)FLOW_SLOTS, c->flight_bytes,
|
||||
BLE_TX_WINDOW_BYTES, s->max_flight, c->tx_next, c->tx_acked, c->rx_last,
|
||||
s->ack_tx, s->ack_rx, c->ack_dirty, c->pending_ack && c->pending);
|
||||
}
|
||||
memset(s, 0, sizeof(*s)); s->since = now;
|
||||
}
|
||||
int ble_get_outgoing(struct ble_transport* t, ble_link_id id, int* outgoing) {
|
||||
struct ble_connection* c = lookup(t, id);
|
||||
if (!c || !outgoing) { DEBUG_ERROR(CAT, "BLE direction: invalid link=%llu/output", (unsigned long long)id); return -1; }
|
||||
*outgoing = c->outgoing;
|
||||
return 0;
|
||||
}
|
||||
static void close_link(struct ble_transport* t, struct ble_connection* c, const char* reason) {
|
||||
ble_link_id id = c->id;
|
||||
uint64_t peer = c->peer;
|
||||
log_stats(c, get_time_us());
|
||||
DEBUG_INFO(CAT, "BLE closed link=%llu peer=%016llx reason=%s", (unsigned long long)id, (unsigned long long)peer, reason);
|
||||
if (t->backend) ble_backend_close(t->backend, id);
|
||||
if (c->ack_timer) uasync_cancel_timeout(t->ua, c->ack_timer);
|
||||
for (int i = 0; i < BLE_CHANNEL_COUNT; i++) {
|
||||
struct ble_channel_state* ch = &c->channels[i];
|
||||
release_entry(ch->current);
|
||||
queue_clear(ch->tx); queue_free(ch->tx);
|
||||
queue_clear(ch->rx); queue_free(ch->rx);
|
||||
}
|
||||
memset(c, 0, sizeof(*c));
|
||||
if (t->config.callbacks.connection) t->config.callbacks.connection(t->config.arg, id, peer, BLE_CLOSED, reason);
|
||||
}
|
||||
static struct ble_connection* new_link(struct ble_transport* t, const struct ble_endpoint* ep, int outgoing, uint64_t peer) {
|
||||
if (!t || !t->started || !ep || !memchr(ep->id, 0, sizeof(ep->id)) || !ep->id[0]) {
|
||||
DEBUG_ERROR(CAT, "BLE invalid connection request"); return NULL;
|
||||
}
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id && !strcmp(t->links[i].endpoint.id, ep->id)) {
|
||||
DEBUG_WARN(CAT, "BLE duplicate connection endpoint=%s", ep->id); return NULL;
|
||||
}
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) if (!t->links[i].id) {
|
||||
struct ble_connection* c = &t->links[i];
|
||||
int allocated = 1;
|
||||
for (int ch = 0; ch < BLE_CHANNEL_COUNT; ch++) {
|
||||
c->channels[ch].tx = queue_new(t->ua, 0, 0, 0, "ble_tx");
|
||||
c->channels[ch].rx = queue_new(t->ua, 0, 0, 0, "ble_rx");
|
||||
if (!c->channels[ch].tx || !c->channels[ch].rx) allocated = 0;
|
||||
}
|
||||
if (!allocated) {
|
||||
DEBUG_ERROR(CAT, "BLE queue allocation failed");
|
||||
for (int ch = 0; ch < BLE_CHANNEL_COUNT; ch++) { queue_free(c->channels[ch].tx); queue_free(c->channels[ch].rx); }
|
||||
memset(c, 0, sizeof(*c)); return NULL;
|
||||
}
|
||||
c->owner = t; c->id = ++t->sequence; c->peer = peer; c->endpoint = *ep; c->outgoing = outgoing; c->tx_next = 1;
|
||||
c->capacity = 20; c->stats.since = get_time_us(); c->deadline = c->stats.since + DEADLINE_US;
|
||||
DEBUG_INFO(CAT, "BLE connecting link=%llu endpoint=%s outgoing=%d", (unsigned long long)c->id, ep->id, outgoing);
|
||||
if (t->config.callbacks.connection) t->config.callbacks.connection(t->config.arg, c->id, peer, BLE_STARTING, "connecting");
|
||||
return c;
|
||||
}
|
||||
DEBUG_WARN(CAT, "BLE connection limit=%d", BLE_LINKS_MAX); return NULL;
|
||||
}
|
||||
/* writable отражает и свою очередь, и кредит удалённого приёмника. */
|
||||
static void wake_writer(struct ble_transport* t, struct ble_connection* c, enum ble_channel channel) {
|
||||
struct ble_channel_state* ch = &c->channels[channel];
|
||||
if (!ch->blocked || !ble_channel_tx_ready(t, c->id, channel)) return;
|
||||
ch->blocked = 0;
|
||||
DEBUG_TRACE(CAT, "BLE TX resumed link=%llu channel=%d flight_bytes=%u", (unsigned long long)c->id, channel, c->flight_bytes);
|
||||
void (*writable)(void*, ble_link_id) = channel == BLE_CHANNEL_DATA ? t->config.callbacks.writable : t->config.callbacks.invite_writable;
|
||||
if (writable) writable(t->config.arg, c->id);
|
||||
}
|
||||
static void ack_timer(void* arg) {
|
||||
struct ble_connection* c = arg; c->ack_timer = NULL;
|
||||
pump(c->owner, c);
|
||||
}
|
||||
/* Один отложенный ACK на направление; новые RX заменяют ID, не отодвигая срок. */
|
||||
static int schedule_ack(struct ble_transport* t, struct ble_connection* c) {
|
||||
uint64_t now = get_time_us();
|
||||
if (!c->ack_dirty) c->ack_due = c->ack_completed ? c->ack_completed + BLE_ACK_INTERVAL_US : now + BLE_ACK_INTERVAL_US;
|
||||
c->ack_dirty = 1;
|
||||
if (c->ack_timer || (c->pending && now >= c->ack_due)) return 1;
|
||||
/* uasync использует усечённые TB: дополнительный TB исключает срабатывание раньше 15ms. */
|
||||
uint64_t delay = c->ack_due > now ? (c->ack_due - now + 199) / 100 : 0;
|
||||
c->ack_timer = uasync_set_timeout(t->ua, (int)delay, c, ack_timer, "ble_ack");
|
||||
if (c->ack_timer) return 1;
|
||||
DEBUG_ERROR(CAT, "BLE ACK timer allocation failed link=%llu rx_last=%u", (unsigned long long)c->id, c->rx_last);
|
||||
close_link(t, c, "ACK timer allocation failed"); return 0;
|
||||
}
|
||||
static void pump(struct ble_transport* t, struct ble_connection* c) {
|
||||
if (!c->opened || c->pending) return;
|
||||
uint8_t value[BLE_VALUE_MAX];
|
||||
size_t len;
|
||||
c->pending_bytes = 0;
|
||||
c->pending_ack = c->ack_dirty && get_time_us() >= c->ack_due;
|
||||
c->pending_channel = BLE_CHANNEL_DATA;
|
||||
if (c->pending_ack) { write16(value, c->rx_last); len = 2; }
|
||||
else if (c->control_pending) {
|
||||
if (BLE_TX_WINDOW_BYTES - c->flight_bytes < 17 + FLOW_HEADER || c->flight_count == FLOW_SLOTS) return;
|
||||
memcpy(value + FLOW_HEADER, c->control, 17); len = 17 + FLOW_HEADER;
|
||||
}
|
||||
else {
|
||||
if (!c->ready) return;
|
||||
struct ble_channel_state* ch = NULL;
|
||||
for (int i = 0; i < BLE_CHANNEL_COUNT; i++) {
|
||||
enum ble_channel channel = (c->next_channel + i) % BLE_CHANNEL_COUNT;
|
||||
struct ble_channel_state* candidate = &c->channels[channel];
|
||||
if (channel == BLE_CHANNEL_DATA &&
|
||||
(BLE_TX_WINDOW_BYTES - c->flight_bytes < FLOW_MIN_VALUE || c->flight_count == FLOW_SLOTS)) {
|
||||
candidate->blocked = 1; continue;
|
||||
}
|
||||
if (!candidate->current) {
|
||||
candidate->current = queue_data_get(candidate->tx); candidate->offset = 0;
|
||||
if (candidate->current) ++candidate->sequence;
|
||||
}
|
||||
if (candidate->current) { ch = candidate; c->pending_channel = channel; break; }
|
||||
}
|
||||
if (!ch) return;
|
||||
c->next_channel = (c->pending_channel + 1) % BLE_CHANNEL_COUNT;
|
||||
unsigned prefix = c->pending_channel == BLE_CHANNEL_DATA ? FLOW_HEADER : 0;
|
||||
unsigned capacity = c->capacity;
|
||||
if (prefix && capacity > BLE_TX_WINDOW_BYTES - c->flight_bytes) capacity = BLE_TX_WINDOW_BYTES - c->flight_bytes;
|
||||
size_t n = ch->current->len - ch->offset;
|
||||
if (n > capacity - HEADER - prefix) n = capacity - HEADER - prefix;
|
||||
uint8_t* body = value + prefix;
|
||||
body[0] = FRAME; write16(body + 1, ch->sequence); write16(body + 3, ch->current->len); write16(body + 5, ch->offset);
|
||||
memcpy(body + HEADER, ch->current->dgram + ch->offset, n); len = prefix + HEADER + n; c->pending_bytes = n;
|
||||
}
|
||||
enum ble_wire_channel wire = c->pending_ack ? BLE_WIRE_ACK :
|
||||
c->pending_channel == BLE_CHANNEL_DATA ? BLE_WIRE_DATA : BLE_WIRE_INVITE;
|
||||
if (wire == BLE_WIRE_DATA) write16(value, c->tx_next);
|
||||
int result = ble_backend_send(t->backend, c->id, wire, value, len);
|
||||
if (result == BLE_OK) {
|
||||
c->pending = 1; c->submitted = get_time_us(); c->deadline = c->submitted + DEADLINE_US;
|
||||
if (wire == BLE_WIRE_ACK) {
|
||||
if (c->ack_timer) { uasync_cancel_timeout(t->ua, c->ack_timer); c->ack_timer = NULL; }
|
||||
c->ack_dirty = 0; c->stats.ack_tx++;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE ACK TX link=%llu id=%u", (unsigned long long)c->id, c->rx_last);
|
||||
} else if (wire == BLE_WIRE_DATA) {
|
||||
if (!c->flight_count) c->flight_deadline = c->submitted + DEADLINE_US;
|
||||
c->flight_sizes[(c->flight_head + c->flight_count++) % FLOW_SLOTS] = len;
|
||||
c->flight_bytes += len;
|
||||
if (c->flight_bytes > c->stats.max_flight) c->stats.max_flight = c->flight_bytes;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE DATA TX link=%llu seq=%u type=%u bytes=%zu flight=%u/%u",
|
||||
(unsigned long long)c->id, c->tx_next, value[FLOW_HEADER], len, c->flight_count, c->flight_bytes);
|
||||
c->tx_next++;
|
||||
if (!ble_tx_ready(t, c->id)) c->channels[BLE_CHANNEL_DATA].blocked = 1;
|
||||
}
|
||||
}
|
||||
else { DEBUG_ERROR(CAT, "BLE backend send failed link=%llu result=%d", (unsigned long long)c->id, result);
|
||||
close_link(t, c, "backend send failed"); }
|
||||
}
|
||||
static void tick(void* arg) {
|
||||
struct ble_transport* t = arg; t->timer = NULL;
|
||||
uint64_t now = get_time_us();
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) {
|
||||
struct ble_connection* c = &t->links[i];
|
||||
if (!c->id) continue;
|
||||
if (now - c->stats.since >= STATS_US) log_stats(c, now);
|
||||
if (c->flight_count && now >= c->flight_deadline) {
|
||||
DEBUG_WARN(CAT, "BLE ACK timeout link=%llu acked=%u next=%u flight_packets=%u flight_bytes=%u",
|
||||
(unsigned long long)c->id, c->tx_acked, c->tx_next, c->flight_count, c->flight_bytes);
|
||||
close_link(t, c, "ACK progress timeout"); continue;
|
||||
}
|
||||
if ((!c->ready || c->pending) && now >= c->deadline) {
|
||||
DEBUG_WARN(CAT, "BLE handshake/send timeout link=%llu pending=%d", (unsigned long long)c->id, c->pending);
|
||||
close_link(t, c, "operation timeout"); continue;
|
||||
}
|
||||
for (int channel = 0; channel < BLE_CHANNEL_COUNT; channel++) {
|
||||
struct ble_channel_state* ch = &c->channels[channel];
|
||||
if (ch->rx_size && now >= ch->rx_deadline) {
|
||||
DEBUG_WARN(CAT, "BLE frame timeout link=%llu received=%u expected=%u",
|
||||
(unsigned long long)c->id, ch->rx_offset, ch->rx_size);
|
||||
ch->dropping = 1; ch->dropped_sequence = ch->rx_sequence;
|
||||
c->stats.dropped++;
|
||||
ch->rx_size = ch->rx_offset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
t->timer = uasync_set_timeout(t->ua, 1000, t, tick, "ble_tick");
|
||||
if (!t->timer) { DEBUG_ERROR(CAT, "BLE timer allocation failed"); ble_backend_status(t, 0, "timer allocation failed"); }
|
||||
}
|
||||
struct ble_transport* ble_create(struct UASYNC* ua, const struct ble_config* config) {
|
||||
if (!ua || !config || !config->node_id || config->tx_limit > BLE_QUEUE_MAX ||
|
||||
(config->adapter && strlen(config->adapter) >= 64)) {
|
||||
DEBUG_ERROR(CAT, "BLE create invalid configuration"); return NULL;
|
||||
}
|
||||
struct ble_transport* t = u_calloc(1, sizeof(*t));
|
||||
if (!t) { DEBUG_ERROR(CAT, "BLE create allocation failed"); return NULL; }
|
||||
t->ua = ua; t->config = *config;
|
||||
if (!t->config.tx_limit) t->config.tx_limit = BLE_QUEUE_MAX;
|
||||
if (config->adapter) { strcpy(t->adapter, config->adapter); t->config.adapter = t->adapter; }
|
||||
return t;
|
||||
}
|
||||
int ble_start(struct ble_transport* t) {
|
||||
if (!t || t->started) { DEBUG_ERROR(CAT, "BLE invalid/repeated start"); return BLE_ERROR; }
|
||||
t->started = 1;
|
||||
t->backend = ble_backend_create(t, t->ua, &t->config);
|
||||
if (!t->backend) { t->started = 0; DEBUG_ERROR(CAT, "BLE backend creation failed"); return BLE_ERROR; }
|
||||
t->timer = uasync_set_timeout(t->ua, 1000, t, tick, "ble_tick");
|
||||
if (!t->timer) { DEBUG_ERROR(CAT, "BLE timer allocation failed"); ble_stop(t); return BLE_ERROR; }
|
||||
if (t->config.callbacks.status) t->config.callbacks.status(t->config.arg, BLE_STARTING, "registering BLE service");
|
||||
return BLE_OK;
|
||||
}
|
||||
void ble_stop(struct ble_transport* t) {
|
||||
if (!t || !t->started) return;
|
||||
t->started = t->ready = 0;
|
||||
if (t->timer) { uasync_cancel_timeout(t->ua, t->timer); t->timer = NULL; }
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id) close_link(t, &t->links[i], "transport stopped");
|
||||
ble_backend_destroy(t->backend); t->backend = NULL;
|
||||
if (t->config.callbacks.status) t->config.callbacks.status(t->config.arg, BLE_CLOSED, "stopped");
|
||||
}
|
||||
void ble_set_incoming(struct ble_transport* t, int allowed) {
|
||||
if (!t) { DEBUG_ERROR(CAT, "BLE incoming policy: null transport"); return; }
|
||||
t->reject_incoming = !allowed;
|
||||
DEBUG_INFO(CAT, "BLE incoming allowed=%d", !!allowed);
|
||||
if (!allowed) for (int i = 0; i < BLE_LINKS_MAX; i++)
|
||||
if (t->links[i].id && !t->links[i].outgoing) close_link(t, &t->links[i], "incoming disabled");
|
||||
}
|
||||
void ble_destroy(struct ble_transport* t) { if (t) { ble_stop(t); u_free(t); } }
|
||||
int ble_advertise(struct ble_transport* t, const void* data, size_t len) {
|
||||
if (!t || !t->ready) { DEBUG_WARN(CAT, "BLE advertise before ready"); return BLE_NOT_READY; }
|
||||
if (len > BLE_BROADCAST_MAX || (len && !data)) { DEBUG_ERROR(CAT, "BLE invalid advertisement length=%zu", len); return BLE_ERROR; }
|
||||
return ble_backend_advertise(t->backend, data, len);
|
||||
}
|
||||
ble_link_id ble_connect(struct ble_transport* t, const struct ble_endpoint* ep, uint64_t peer) {
|
||||
if (!t || !t->ready) { DEBUG_WARN(CAT, "BLE connect before ready"); return 0; }
|
||||
struct ble_connection* c = new_link(t, ep, 1, peer);
|
||||
if (!c) return 0;
|
||||
ble_link_id id = c->id;
|
||||
if (ble_backend_connect(t->backend, id, ep) != BLE_OK) { close_link(t, c, "connect rejected"); return 0; }
|
||||
return id;
|
||||
}
|
||||
void ble_disconnect(struct ble_transport* t, ble_link_id id) {
|
||||
struct ble_connection* c = lookup(t, id);
|
||||
if (c) close_link(t, c, "API disconnect");
|
||||
else DEBUG_WARN(CAT, "BLE disconnect unknown link=%llu", (unsigned long long)id);
|
||||
}
|
||||
int ble_channel_tx_ready(struct ble_transport* t, ble_link_id id, enum ble_channel channel) {
|
||||
if ((unsigned)channel >= BLE_CHANNEL_COUNT) { DEBUG_ERROR(CAT, "BLE invalid channel=%d", channel); return 0; }
|
||||
struct ble_connection* c = lookup(t, id);
|
||||
struct ble_channel_state* ch = c ? &c->channels[channel] : NULL;
|
||||
return c && c->ready && (channel != BLE_CHANNEL_DATA ||
|
||||
(BLE_TX_WINDOW_BYTES - c->flight_bytes >= FLOW_MIN_VALUE && c->flight_count < FLOW_SLOTS)) &&
|
||||
(unsigned)queue_entry_count(ch->tx) + !!ch->current < (channel == BLE_CHANNEL_DATA ? t->config.tx_limit : 1);
|
||||
}
|
||||
int ble_channel_tx_put(struct ble_transport* t, ble_link_id id, enum ble_channel channel, struct ll_entry* e) {
|
||||
if ((unsigned)channel >= BLE_CHANNEL_COUNT) { DEBUG_ERROR(CAT, "BLE invalid channel=%d", channel); return BLE_ERROR; }
|
||||
struct ble_connection* c = lookup(t, id);
|
||||
struct ble_channel_state* ch = c ? &c->channels[channel] : NULL;
|
||||
if (!c || !c->ready) { DEBUG_WARN(CAT, "BLE TX link not ready=%llu", (unsigned long long)id); return BLE_NOT_READY; }
|
||||
if (!e || e->owner || e->next || e->prev || !e->dgram || !e->len || e->len > BLE_FRAME_MAX || e->len > e->memlen) {
|
||||
DEBUG_ERROR(CAT, "BLE invalid TX frame link=%llu", (unsigned long long)id); return BLE_ERROR;
|
||||
}
|
||||
if (!ble_channel_tx_ready(t, id, channel)) {
|
||||
if (!ch->blocked) DEBUG_TRACE(CAT, "BLE TX paused link=%llu packets=%u", (unsigned long long)id,
|
||||
(channel == BLE_CHANNEL_DATA ? t->config.tx_limit : 1));
|
||||
ch->blocked = 1; return BLE_WOULD_BLOCK;
|
||||
}
|
||||
/* Без лимита/индекса/consumer put не имеет отказа после проверок и не вызывает чужой callback. */
|
||||
if (queue_data_put(ch->tx, e) != 0) { DEBUG_ERROR(CAT, "BLE TX queue invariant failed"); return BLE_ERROR; }
|
||||
if (!ble_channel_tx_ready(t, id, channel)) ch->blocked = 1;
|
||||
pump(t, c); return BLE_OK;
|
||||
}
|
||||
struct ll_entry* ble_channel_rx_get(struct ble_transport* t, ble_link_id id, enum ble_channel channel) {
|
||||
if ((unsigned)channel >= BLE_CHANNEL_COUNT) { DEBUG_ERROR(CAT, "BLE invalid channel=%d", channel); return 0; }
|
||||
struct ble_connection* c = lookup(t, id);
|
||||
struct ble_channel_state* ch = c ? &c->channels[channel] : NULL;
|
||||
return c ? queue_data_get(ch->rx) : NULL;
|
||||
}
|
||||
int ble_tx_ready(struct ble_transport* t, ble_link_id id) { return ble_channel_tx_ready(t, id, BLE_CHANNEL_DATA); }
|
||||
int ble_tx_put(struct ble_transport* t, ble_link_id id, struct ll_entry* e) { return ble_channel_tx_put(t, id, BLE_CHANNEL_DATA, e); }
|
||||
struct ll_entry* ble_rx_get(struct ble_transport* t, ble_link_id id) { return ble_channel_rx_get(t, id, BLE_CHANNEL_DATA); }
|
||||
ble_link_id ble_backend_incoming(struct ble_transport* t, const struct ble_endpoint* ep) {
|
||||
if (t->reject_incoming) { DEBUG_INFO(CAT, "BLE incoming rejected by policy"); return 0; }
|
||||
struct ble_connection* c = new_link(t, ep, 0, 0); return c ? c->id : 0;
|
||||
}
|
||||
void ble_backend_status(struct ble_transport* t, int ready, const char* reason) {
|
||||
t->ready = ready;
|
||||
if (!ready) {
|
||||
DEBUG_ERROR(CAT, "BLE radio failed: %s", reason);
|
||||
for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id) close_link(t, &t->links[i], reason);
|
||||
} else DEBUG_INFO(CAT, "BLE radio ready node=%016llx", (unsigned long long)t->config.node_id);
|
||||
if (t->config.callbacks.status) t->config.callbacks.status(t->config.arg, ready ? BLE_READY : BLE_CLOSED, reason);
|
||||
}
|
||||
void ble_backend_broadcast(struct ble_transport* t, const struct ble_endpoint* ep, const uint8_t* data, size_t len, int rssi) {
|
||||
if (len > BLE_BROADCAST_MAX) { DEBUG_WARN(CAT, "BLE oversized broadcast bytes=%zu", len); return; }
|
||||
if (t->config.callbacks.broadcast) t->config.callbacks.broadcast(t->config.arg, ep, data, len, rssi);
|
||||
}
|
||||
void ble_backend_advertising(struct ble_transport* t, enum ble_state state, const char* reason) {
|
||||
DEBUG_INFO(CAT, "BLE advertising state=%d reason=%s", state, reason);
|
||||
if (t->config.callbacks.advertising) t->config.callbacks.advertising(t->config.arg, state, reason);
|
||||
}
|
||||
void ble_backend_opened(struct ble_transport* t, ble_link_id id, unsigned int mtu) {
|
||||
struct ble_connection* c = lookup(t, id);
|
||||
if (!c) return;
|
||||
if (mtu < 23) { DEBUG_ERROR(CAT, "BLE invalid MTU=%u", mtu); close_link(t, c, "invalid MTU"); return; }
|
||||
c->capacity = mtu - 3; if (c->capacity > BLE_VALUE_MAX) c->capacity = BLE_VALUE_MAX;
|
||||
c->opened = 1;
|
||||
DEBUG_INFO(CAT, "BLE GATT open link=%llu mtu=%u fragment_payload=%u outgoing=%d DATA=without_response/notify INVITE=request/indicate tx_limit=%u",
|
||||
(unsigned long long)id, mtu, c->capacity - HEADER - FLOW_HEADER, c->outgoing, t->config.tx_limit);
|
||||
DEBUG_INFO(CAT, "BLE flow configured link=%llu rate_bits=%u window_ms=%u window_bytes=%u ACK_interval_us=%u id_bits=16",
|
||||
(unsigned long long)id, BLE_TX_RATE_BITS, BLE_TX_WINDOW_MS, BLE_TX_WINDOW_BYTES, BLE_ACK_INTERVAL_US);
|
||||
if (c->outgoing) {
|
||||
c->control[0] = HELLO; write64(c->control + 1, t->config.node_id); write64(c->control + 9, c->peer);
|
||||
c->control_pending = 1;
|
||||
}
|
||||
pump(t, c);
|
||||
}
|
||||
void ble_backend_received(struct ble_transport* t, ble_link_id id, enum ble_wire_channel wire, const uint8_t* data, size_t len) {
|
||||
struct ble_connection* c = lookup(t, id);
|
||||
if (!c) return;
|
||||
if (!c->opened) {
|
||||
DEBUG_WARN(CAT, "BLE value before mandatory subscriptions link=%llu channel=%d bytes=%zu", (unsigned long long)id, wire, len);
|
||||
close_link(t, c, "value before subscriptions"); return;
|
||||
}
|
||||
if (!data || !len || len > c->capacity || (unsigned)wire >= BLE_WIRE_COUNT) {
|
||||
DEBUG_WARN(CAT, "BLE invalid wire value link=%llu channel=%d bytes=%zu", (unsigned long long)id, wire, len);
|
||||
close_link(t, c, "invalid wire value"); return;
|
||||
}
|
||||
if (wire == BLE_WIRE_ACK) {
|
||||
if (len != 2) {
|
||||
DEBUG_WARN(CAT, "BLE invalid ACK length link=%llu bytes=%zu", (unsigned long long)id, len);
|
||||
close_link(t, c, "invalid ACK length"); return;
|
||||
}
|
||||
uint16_t ack = read16(data), advance = (uint16_t)(ack - c->tx_acked);
|
||||
if (!advance || advance >= 0x8000) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE ACK RX link=%llu id=%u acked=%u flight=%u/%u ignored=stale",
|
||||
(unsigned long long)id, ack, c->tx_acked, c->flight_count, c->flight_bytes); return;
|
||||
}
|
||||
if (advance > c->flight_count) {
|
||||
DEBUG_WARN(CAT, "BLE ACK outside flight link=%llu id=%u acked=%u next=%u count=%u",
|
||||
(unsigned long long)id, ack, c->tx_acked, c->tx_next, c->flight_count);
|
||||
close_link(t, c, "ACK outside flight"); return;
|
||||
}
|
||||
unsigned bytes = 0;
|
||||
for (unsigned i = 0; i < advance; i++) {
|
||||
bytes += c->flight_sizes[c->flight_head]; c->flight_head = (c->flight_head + 1) % FLOW_SLOTS;
|
||||
}
|
||||
c->flight_count -= advance; c->flight_bytes -= bytes; c->tx_acked = ack; c->stats.ack_rx++;
|
||||
c->flight_deadline = c->flight_count ? get_time_us() + DEADLINE_US : 0;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE ACK RX link=%llu id=%u released=%u/%u flight=%u/%u", (unsigned long long)id,
|
||||
ack, advance, bytes, c->flight_count, c->flight_bytes);
|
||||
wake_writer(t, c, BLE_CHANNEL_DATA); pump(t, c); return;
|
||||
}
|
||||
enum ble_channel channel = wire == BLE_WIRE_DATA ? BLE_CHANNEL_DATA : BLE_CHANNEL_INVITE;
|
||||
if (wire == BLE_WIRE_DATA) {
|
||||
if (len <= FLOW_HEADER) {
|
||||
DEBUG_WARN(CAT, "BLE missing DATA flow header link=%llu bytes=%zu", (unsigned long long)id, len);
|
||||
close_link(t, c, "invalid DATA flow header"); return;
|
||||
}
|
||||
uint16_t seq = read16(data), advance = (uint16_t)(seq - c->rx_last);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE DATA RX link=%llu seq=%u type=%u bytes=%zu previous=%u advance=%u",
|
||||
(unsigned long long)id, seq, data[FLOW_HEADER], len, c->rx_last, advance);
|
||||
if (!advance || advance >= 0x8000) {
|
||||
DEBUG_WARN(CAT, "BLE duplicate/stale DATA link=%llu id=%u last=%u", (unsigned long long)id, seq, c->rx_last);
|
||||
if (c->rx_flow_seen) schedule_ack(t, c); return;
|
||||
}
|
||||
if (advance != 1) DEBUG_WARN(CAT, "BLE DATA flow gap link=%llu expected=%u received=%u missing=%u",
|
||||
(unsigned long long)id, (uint16_t)(c->rx_last + 1), seq, advance - 1);
|
||||
c->rx_last = seq; c->rx_flow_seen = 1;
|
||||
if (!schedule_ack(t, c)) return;
|
||||
data += FLOW_HEADER; len -= FLOW_HEADER;
|
||||
}
|
||||
struct ble_channel_state* ch = &c->channels[channel];
|
||||
if (channel == BLE_CHANNEL_DATA && len == 17 && !c->ready && ((data[0] == HELLO && !c->outgoing) || (data[0] == ACCEPT && c->outgoing))) {
|
||||
uint64_t peer = read64(data + 1), target = read64(data + 9);
|
||||
if (!peer || peer == t->config.node_id || (target && target != t->config.node_id) || (c->peer && peer != c->peer)) {
|
||||
DEBUG_WARN(CAT, "BLE invalid HELLO identity link=%llu", (unsigned long long)id); close_link(t, c, "invalid identity"); return;
|
||||
}
|
||||
if (t->config.callbacks.validate && !t->config.callbacks.validate(t->config.arg, id, peer)) {
|
||||
DEBUG_WARN(CAT, "BLE validation rejected link=%llu", (unsigned long long)id); close_link(t, c, "validation rejected"); return;
|
||||
}
|
||||
c->peer = peer; c->ready = 1;
|
||||
if (!c->outgoing) {
|
||||
c->control[0] = ACCEPT; write64(c->control + 1, t->config.node_id); write64(c->control + 9, peer); c->control_pending = 1;
|
||||
}
|
||||
DEBUG_INFO(CAT, "BLE session ready link=%llu peer=%016llx", (unsigned long long)id, (unsigned long long)peer);
|
||||
if (t->config.callbacks.connection) t->config.callbacks.connection(t->config.arg, id, peer, BLE_READY, "HELLO accepted");
|
||||
pump(t, c); return;
|
||||
}
|
||||
if (!c->ready || len <= HEADER || len > c->capacity || data[0] != FRAME) {
|
||||
DEBUG_WARN(CAT, "BLE invalid value link=%llu channel=%d bytes=%zu capacity=%u ready=%d type=%u",
|
||||
(unsigned long long)id, channel, len, c->capacity, c->ready, len ? data[0] : 0);
|
||||
c->stats.dropped++;
|
||||
ch->rx_size = ch->rx_offset = 0; return;
|
||||
}
|
||||
uint16_t seq = read16(data + 1), total = read16(data + 3), offset = read16(data + 5);
|
||||
size_t n = len - HEADER;
|
||||
if (offset && ch->dropping && ch->dropped_sequence == seq) {
|
||||
DEBUG_TRACE(CAT, "BLE discarded frame tail link=%llu seq=%u offset=%u", (unsigned long long)id, seq, offset); return;
|
||||
}
|
||||
if (!offset) ch->dropping = 0;
|
||||
if (!total || total > BLE_FRAME_MAX || offset > total || n > (size_t)(total - offset)) goto invalid;
|
||||
if (!offset) {
|
||||
uint16_t expected = ch->rx_seen ? ch->rx_next : 1;
|
||||
if (seq != expected) {
|
||||
uint16_t gap = (uint16_t)(seq - expected);
|
||||
DEBUG_WARN(CAT, "BLE RX sequence gap link=%llu channel=%d expected=%u received=%u missing=%u",
|
||||
(unsigned long long)id, channel, expected, seq, gap < 0x8000 ? gap : 0);
|
||||
if (gap < 0x8000) c->stats.missing += gap;
|
||||
}
|
||||
ch->rx_seen = 1; ch->rx_next = (uint16_t)(seq + 1);
|
||||
if (ch->rx_size) {
|
||||
DEBUG_WARN(CAT, "BLE incomplete frame replaced link=%llu channel=%d seq=%u received=%u total=%u next=%u",
|
||||
(unsigned long long)id, channel, ch->rx_sequence, ch->rx_offset, ch->rx_size, seq);
|
||||
c->stats.dropped++;
|
||||
}
|
||||
ch->rx_sequence = seq; ch->rx_size = total; ch->rx_offset = 0; ch->rx_deadline = get_time_us() + DEADLINE_US;
|
||||
}
|
||||
if (ch->rx_size != total || ch->rx_sequence != seq || ch->rx_offset != offset) goto invalid;
|
||||
memcpy(ch->assembly + offset, data + HEADER, n); ch->rx_offset += n;
|
||||
if (ch->rx_offset != total) return;
|
||||
ch->rx_size = ch->rx_offset = 0;
|
||||
if (queue_entry_count(ch->rx) >= BLE_QUEUE_MAX) {
|
||||
DEBUG_WARN(CAT, "BLE RX full: frame dropped link=%llu channel=%d", (unsigned long long)id, channel);
|
||||
c->stats.dropped++; return;
|
||||
}
|
||||
struct ll_entry* entry = ll_alloc_lldgram(total);
|
||||
if (!entry) { DEBUG_ERROR(CAT, "BLE RX allocation failed bytes=%u", total); c->stats.dropped++; return; }
|
||||
memcpy(entry->dgram, ch->assembly, total); entry->len = total;
|
||||
if (queue_data_put(ch->rx, entry) != 0) { DEBUG_ERROR(CAT, "BLE RX queue invariant failed"); return; }
|
||||
c->stats.rx_bytes += total; c->stats.rx_frames++;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE wire RX link=%llu channel=%d seq=%u bytes=%u tag=%08x",
|
||||
(unsigned long long)id, channel, seq, total, frame_tag(entry->dgram, entry->len));
|
||||
void (*received)(void*, ble_link_id) = channel == BLE_CHANNEL_DATA ? t->config.callbacks.received : t->config.callbacks.invite_received;
|
||||
if (received) received(t->config.arg, id);
|
||||
return;
|
||||
invalid:
|
||||
c->stats.dropped++;
|
||||
DEBUG_WARN(CAT, "BLE fragment dropped link=%llu channel=%d seq=%u offset=%u expected=%u total=%u bytes=%zu",
|
||||
(unsigned long long)id, channel, seq, offset, ch->rx_offset, total, n);
|
||||
ch->rx_size = ch->rx_offset = 0;
|
||||
ch->dropping = 1; ch->dropped_sequence = seq;
|
||||
}
|
||||
void ble_backend_sent(struct ble_transport* t, ble_link_id id, int success) {
|
||||
struct ble_connection* c = lookup(t, id);
|
||||
if (!c) return;
|
||||
if (!success || !c->pending) { DEBUG_WARN(CAT, "BLE send completion failed link=%llu success=%d pending=%d channel=%d bytes=%u",
|
||||
(unsigned long long)id, success, c->pending, c->pending_channel, c->pending_bytes);
|
||||
close_link(t, c, "send failed"); return; }
|
||||
enum ble_channel channel = c->pending_channel;
|
||||
uint64_t waited = get_time_us() - c->submitted;
|
||||
c->stats.fragments++; c->stats.wait_us += waited;
|
||||
if (waited > c->stats.max_wait_us) c->stats.max_wait_us = waited;
|
||||
c->pending = 0;
|
||||
if (c->pending_ack) {
|
||||
c->pending_ack = 0; c->ack_completed = get_time_us();
|
||||
/* Даже при медленном ACK дать DATA/INVITE следующие 15ms, иначе непрерывный RX вытесняет ответы. */
|
||||
if (c->ack_dirty) {
|
||||
if (c->ack_timer) { uasync_cancel_timeout(t->ua, c->ack_timer); c->ack_timer = NULL; }
|
||||
c->ack_due = c->ack_completed + BLE_ACK_INTERVAL_US;
|
||||
if (!schedule_ack(t, c)) return;
|
||||
}
|
||||
pump(t, c); return;
|
||||
}
|
||||
struct ble_channel_state* ch = &c->channels[channel];
|
||||
if (!c->pending_bytes) c->control_pending = 0;
|
||||
else if (ch->current) {
|
||||
ch->offset += c->pending_bytes;
|
||||
if (ch->offset == ch->current->len) {
|
||||
c->stats.tx_bytes += ch->current->len; c->stats.tx_frames++;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE wire TX link=%llu channel=%d seq=%u bytes=%u tag=%08x",
|
||||
(unsigned long long)id, channel, ch->sequence, ch->current->len,
|
||||
frame_tag(ch->current->dgram, ch->current->len));
|
||||
release_entry(ch->current); ch->current = NULL;
|
||||
}
|
||||
}
|
||||
wake_writer(t, c, channel);
|
||||
pump(t, c);
|
||||
}
|
||||
void ble_backend_closed(struct ble_transport* t, ble_link_id id, const char* reason) {
|
||||
struct ble_connection* c = lookup(t, id); if (c) close_link(t, c, reason);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* BLE transport без Qt: create → start → advertise/connect → tx_put/rx_get → destroy.
|
||||
* Все API и callbacks принадлежат переданному uasync-потоку; callbacks не вызывают stop/destroy/disconnect.
|
||||
* Объявление — повторяемые ОС байты, не надёжная датаграмма. node_id в HELLO не аутентифицирован.
|
||||
* Один сервис обслуживает независимые соединения; данные — непрозрачные кадры 1..1500 байт.
|
||||
* Целые кадры находятся в ll_queue. DATA: Write Without Response/Notification; INVITE: Request/Indication.
|
||||
* DATA имеет накопительный ACK GATT-фрагментов: ограничивает очередь ОС, без собственных ретрансляций.
|
||||
* Доставку целых DATA подтверждает потребитель (ETCP); ACK не является подтверждением приложения.
|
||||
* Диагностика: discovery (BLE-префикс). NULL validate разрешает запрос, как и начальная политика GUI. */
|
||||
#ifndef VIBECHAT_BLE_TRANSPORT_H
|
||||
#define VIBECHAT_BLE_TRANSPORT_H
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "../ll_queue.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define BLE_FRAME_MAX 1500
|
||||
#define BLE_BROADCAST_MAX 24
|
||||
#define BLE_ENDPOINT_MAX 256
|
||||
#define BLE_LINKS_MAX 16
|
||||
#define BLE_QUEUE_MAX 64
|
||||
/* Расчётная скорость фиксирована; окно включает DATA GATT-values с заголовками. */
|
||||
#define BLE_TX_RATE_BITS 2000000u
|
||||
#define BLE_TX_WINDOW_MS 50u
|
||||
#define BLE_TX_WINDOW_BYTES (BLE_TX_RATE_BITS / 8u * BLE_TX_WINDOW_MS / 1000u)
|
||||
#define BLE_ACK_INTERVAL_US 15000u
|
||||
struct ble_transport;
|
||||
struct UASYNC;
|
||||
typedef uint64_t ble_link_id;
|
||||
enum ble_result { BLE_OK = 0, BLE_ERROR = -1, BLE_WOULD_BLOCK = -2, BLE_NOT_READY = -3 };
|
||||
enum ble_channel { BLE_CHANNEL_DATA, BLE_CHANNEL_INVITE, BLE_CHANNEL_COUNT };
|
||||
enum ble_state { BLE_STARTING, BLE_READY, BLE_CLOSED };
|
||||
struct ble_endpoint { char id[BLE_ENDPOINT_MAX]; }; /* Непрозрачный адрес backend, копируется целиком. */
|
||||
struct ble_callbacks {
|
||||
/* Объявление нужного company_id; буфер заимствован до возврата. RSSI=127 означает неизвестный. */
|
||||
void (*broadcast)(void* arg, const struct ble_endpoint* endpoint, const uint8_t* data, size_t len, int rssi);
|
||||
void (*status)(void* arg, enum ble_state state, const char* reason); /* Состояние радио, reason заимствован. */
|
||||
void (*advertising)(void* arg, enum ble_state state, const char* reason); /* Публикация bytes: READY не подтверждает приём. */
|
||||
int (*validate)(void* arg, ble_link_id link, uint64_t node_id); /* Ненулевой результат разрешает HELLO. */
|
||||
void (*connection)(void* arg, ble_link_id link, uint64_t node_id, enum ble_state state, const char* reason);
|
||||
void (*received)(void* arg, ble_link_id link); /* rx_get до NULL; допустим синхронный drain. */
|
||||
void (*writable)(void* arg, ble_link_id link); /* После заполнения TX снова можно вызвать tx_put. */
|
||||
void (*invite_received)(void* arg, ble_link_id link); /* drain INVITE через channel_rx_get. */
|
||||
void (*invite_writable)(void* arg, ble_link_id link); /* Освободилось место в отдельной TX очереди. */
|
||||
};
|
||||
struct ble_config {
|
||||
uint64_t node_id; /* Ненулевой ID для HELLO; не криптографическое доказательство. */
|
||||
uint16_t company_id; /* Manufacturer ID, одинаковый у участников. 0xffff только для разработки. */
|
||||
const char* adapter; /* Linux: hci0 либо NULL (первый адаптер); Windows: NULL. Копируется. */
|
||||
struct ble_callbacks callbacks;
|
||||
void* arg; /* Заимствованный контекст callbacks, живёт до destroy. */
|
||||
unsigned tx_limit; /* Целых TX-пакетов, включая фрагментируемый: 0=64, 1..64. */
|
||||
};
|
||||
/* Создать остановленный transport. ua/config заимствованы только ua; возвращает handle или NULL с логом. */
|
||||
struct ble_transport* ble_create(struct UASYNC* ua, const struct ble_config* config);
|
||||
/* Начать регистрацию сервиса/scan; 0 означает запуск, готовность сообщает status. Повторный start — ошибка. */
|
||||
int ble_start(struct ble_transport* transport);
|
||||
/* Остановить свои объявления/scan/сессии, завершить pending и освободить очереди. Идемпотентно, вне callbacks. */
|
||||
void ble_stop(struct ble_transport* transport);
|
||||
/* Разрешить/запретить новые входящие; запрет закрывает текущие входящие. По умолчанию разрешены. */
|
||||
void ble_set_incoming(struct ble_transport* transport, int allowed);
|
||||
void ble_destroy(struct ble_transport* transport); /* stop + free; NULL допустим. */
|
||||
/* Заменить повторяемое manufacturer-объявление (0..24 байта); копирует данные. NULL,0 выключает его.
|
||||
* Регистрация не гарантирует выход в эфир/получение. Собственное connectable-объявление сервиса независимо. */
|
||||
int ble_advertise(struct ble_transport* transport, const void* data, size_t len);
|
||||
/* Самостоятельно выполнить connect/discovery/subscribe/HELLO. expected_node=0 не проверяет ID ответа.
|
||||
* Handle либо 0 при отказе. Готовность/ошибка — connection; физическое ACL может обслуживать другие сервисы. */
|
||||
ble_link_id ble_connect(struct ble_transport* transport, const struct ble_endpoint* endpoint, uint64_t expected_node);
|
||||
/* Копировать направление существующего GATT в outgoing (0/1). 0 — успех, -1 — неверный handle/аргументы с логом. */
|
||||
int ble_get_outgoing(struct ble_transport* transport, ble_link_id link, int* outgoing);
|
||||
void ble_disconnect(struct ble_transport* transport, ble_link_id link);
|
||||
/* Принять целый ll_entry в TX. При 0 владение передано; при любом отказе entry остаётся вызывающему.
|
||||
* entry должен быть вне очереди, dgram!=NULL, len=1..1500. Заполнение: WOULD_BLOCK, затем writable. */
|
||||
int ble_tx_put(struct ble_transport* transport, ble_link_id link, struct ll_entry* entry);
|
||||
/* Снимок готовности принять пакет; заполнение учитывает текущую фрагментацию.
|
||||
* После заполнения writable вызывается при освобождении места, без обязательного отказа tx_put. */
|
||||
int ble_tx_ready(struct ble_transport* transport, ble_link_id link);
|
||||
/* Извлечь целый кадр из RX или NULL; вызывающий освобождает queue_dgram_free + queue_entry_free. */
|
||||
struct ll_entry* ble_rx_get(struct ble_transport* transport, ble_link_id link);
|
||||
/* Каналы имеют независимые очереди/сборку, но один общий GATT send. INVITE TX ограничен одним пакетом.
|
||||
* Контракт владения и возвратов как у DATA API выше. Планировщик чередует фрагменты каналов. */
|
||||
int ble_channel_tx_ready(struct ble_transport* transport, ble_link_id link, enum ble_channel channel);
|
||||
int ble_channel_tx_put(struct ble_transport* transport, ble_link_id link, enum ble_channel channel, struct ll_entry* entry);
|
||||
struct ll_entry* ble_channel_rx_get(struct ble_transport* transport, ble_link_id link, enum ble_channel channel);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,765 @@
|
||||
/* WinRT backend: события Windows сериализуются в uasync через ограниченный mailbox.
|
||||
* Каждая отправка адресована link/subscribedClient; одна незавершённая операция на link.
|
||||
* Слабые ссылки в WinRT handlers и отменяемый posted_task исключают обращения к уничтоженному core. */
|
||||
#include "ble_backend.h"
|
||||
#include "../u_async.h"
|
||||
#include "../mem.h"
|
||||
#include "../debug_config.h"
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
#include <winrt/Windows.Foundation.Collections.h>
|
||||
#include <winrt/Windows.Foundation.Metadata.h>
|
||||
#include <winrt/Windows.System.Profile.h>
|
||||
#include <winrt/Windows.Devices.Bluetooth.h>
|
||||
#include <winrt/Windows.Devices.Bluetooth.Advertisement.h>
|
||||
#include <winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h>
|
||||
#include <winrt/Windows.Storage.Streams.h>
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
using namespace winrt;
|
||||
using namespace Windows::Foundation;
|
||||
using namespace Windows::Devices::Bluetooth;
|
||||
using namespace Windows::Devices::Bluetooth::Advertisement;
|
||||
using namespace Windows::Devices::Bluetooth::GenericAttributeProfile;
|
||||
using namespace Windows::Storage::Streams;
|
||||
static constexpr auto CAT = DEBUG_CATEGORY_DISCOVERY;
|
||||
static const char* bluetooth_error(BluetoothError error) {
|
||||
switch (error) {
|
||||
case BluetoothError::Success: return "Success";
|
||||
case BluetoothError::RadioNotAvailable: return "RadioNotAvailable";
|
||||
case BluetoothError::ResourceInUse: return "ResourceInUse";
|
||||
case BluetoothError::DeviceNotConnected: return "DeviceNotConnected";
|
||||
case BluetoothError::DisabledByPolicy: return "DisabledByPolicy";
|
||||
case BluetoothError::NotSupported: return "NotSupported";
|
||||
case BluetoothError::DisabledByUser: return "DisabledByUser";
|
||||
case BluetoothError::ConsentRequired: return "ConsentRequired";
|
||||
case BluetoothError::TransportNotSupported: return "TransportNotSupported";
|
||||
default: return "OtherError";
|
||||
}
|
||||
}
|
||||
/* Вызывать только из catch; сохраняем контекст и HRESULT вместо немого catch(...). */
|
||||
static std::string exception_reason(const char* phase, ble_link_id id = 0) {
|
||||
char prefix[192];
|
||||
try { throw; }
|
||||
catch (const hresult_error& e) {
|
||||
std::snprintf(prefix, sizeof(prefix), "BLE WinRT phase=%s link=%llu HRESULT=0x%08x: ",
|
||||
phase, (unsigned long long)id, (unsigned)e.code().value);
|
||||
return prefix + to_string(e.message());
|
||||
} catch (const std::exception& e) {
|
||||
return std::string("BLE WinRT phase=") + phase + ": " + e.what();
|
||||
} catch (...) { return std::string("BLE WinRT phase=") + phase + ": unknown exception"; }
|
||||
}
|
||||
struct WinState;
|
||||
struct WinLink {
|
||||
ble_link_id id = 0;
|
||||
std::string endpoint;
|
||||
hstring device_id;
|
||||
bool outgoing = false, opened = false, sending = false;
|
||||
BluetoothLEDevice device{nullptr};
|
||||
GattDeviceService service{nullptr};
|
||||
GattCharacteristic rx{nullptr}, tx{nullptr}, invite{nullptr}, ack{nullptr};
|
||||
GattSession session{nullptr};
|
||||
BluetoothLEPreferredConnectionParametersRequest speed_request{nullptr}; /* Живёт до закрытия link. */
|
||||
GattSubscribedClient subscriber{nullptr}, invite_subscriber{nullptr}, ack_subscriber{nullptr};
|
||||
IAsyncInfo operation{nullptr};
|
||||
GattCharacteristic::ValueChanged_revoker value_changed, invite_changed, ack_changed;
|
||||
GattSession::SessionStatusChanged_revoker session_changed;
|
||||
GattSession::MaxPduSizeChanged_revoker pdu_changed;
|
||||
BluetoothLEDevice::ConnectionParametersChanged_revoker parameters_changed;
|
||||
BluetoothLEDevice::ConnectionPhyChanged_revoker phy_changed;
|
||||
};
|
||||
struct WriteDeferral {
|
||||
Deferral value;
|
||||
explicit WriteDeferral(Deferral v) : value(v) {}
|
||||
~WriteDeferral() { try { value.Complete(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("write deferral").c_str()); } }
|
||||
};
|
||||
struct WinState : std::enable_shared_from_this<WinState> {
|
||||
/* Имена имеют статическое время жизни; контекст ошибки сохраняется независимо от уровня лога. */
|
||||
struct Event {
|
||||
const char* name;
|
||||
ble_link_id id;
|
||||
std::function<void(WinState&)> run;
|
||||
};
|
||||
struct UASYNC* ua = nullptr;
|
||||
struct ble_transport* owner = nullptr;
|
||||
uint16_t company = 0;
|
||||
std::mutex mutex;
|
||||
std::deque<Event> events;
|
||||
const char* operation = "idle"; /* Только uasync; Windows callbacks передают контекст через Event. */
|
||||
ble_link_id operation_link = 0;
|
||||
struct posted_task* posted = nullptr;
|
||||
bool stopped = false, failed = false, overflow = false, apartment = false, scan_ready = false, service_ready = false;
|
||||
IAsyncInfo startup{nullptr};
|
||||
BluetoothLEAdvertisementWatcher watcher{nullptr};
|
||||
BluetoothLEAdvertisementPublisher publisher{nullptr};
|
||||
GattServiceProvider provider{nullptr};
|
||||
GattLocalCharacteristic local_rx{nullptr}, local_tx{nullptr}, local_invite{nullptr}, local_ack{nullptr};
|
||||
BluetoothLEAdvertisementWatcher::Received_revoker received;
|
||||
BluetoothLEAdvertisementWatcher::Stopped_revoker scan_stopped;
|
||||
BluetoothLEAdvertisementPublisher::StatusChanged_revoker publish_changed;
|
||||
GattServiceProvider::AdvertisementStatusChanged_revoker service_changed;
|
||||
GattLocalCharacteristic::WriteRequested_revoker write_requested, invite_write_requested, ack_write_requested;
|
||||
GattLocalCharacteristic::SubscribedClientsChanged_revoker subscriptions_changed, invite_subscriptions_changed, ack_subscriptions_changed;
|
||||
std::map<ble_link_id, std::unique_ptr<WinLink>> links;
|
||||
std::array<uint8_t, BLE_BROADCAST_MAX> broadcast{};
|
||||
size_t broadcast_size = 0;
|
||||
bool broadcast_enabled = false;
|
||||
uint64_t publish_generation = 0;
|
||||
/* Отмечает конкретный вызов до обращения к WinRT, включая синхронные исключения. */
|
||||
void step(const char* name, ble_link_id id = 0) {
|
||||
operation = name; operation_link = id;
|
||||
DEBUG_TRACE(CAT, "BLE WinRT operation=%s link=%llu", name, (unsigned long long)id);
|
||||
}
|
||||
static void dispatch(void* arg) {
|
||||
auto& s = *static_cast<WinState*>(arg);
|
||||
std::deque<Event> batch;
|
||||
bool overloaded;
|
||||
{ std::lock_guard guard(s.mutex); s.posted = nullptr; batch.swap(s.events); overloaded = s.overflow; s.overflow = false; }
|
||||
if (overloaded) { s.fail("WinRT event queue overflow"); return; }
|
||||
DEBUG_TRACE(CAT, "BLE WinRT dispatch events=%zu links=%zu", batch.size(), s.links.size());
|
||||
for (auto& event : batch) {
|
||||
if (s.stopped || s.failed) break;
|
||||
s.step(event.name, event.id);
|
||||
try { event.run(s); }
|
||||
catch (...) {
|
||||
auto reason = exception_reason(s.operation, s.operation_link);
|
||||
DEBUG_ERROR(CAT, "BLE WinRT event=%s event_link=%llu: %s", event.name,
|
||||
(unsigned long long)event.id, reason.c_str());
|
||||
if (event.id) s.error(event.id, reason.c_str());
|
||||
else s.fail(reason.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
void post(const char* name, ble_link_id id, std::function<void(WinState&)> event) {
|
||||
std::lock_guard guard(mutex);
|
||||
if (stopped) return;
|
||||
if (events.size() >= 256) {
|
||||
if (!overflow) DEBUG_ERROR(CAT, "BLE WinRT event mailbox full queued=%zu limit=256", events.size());
|
||||
overflow = true;
|
||||
}
|
||||
else events.push_back({name, id, std::move(event)});
|
||||
if (posted) return;
|
||||
posted = static_cast<struct posted_task*>(u_calloc(1, sizeof(struct posted_task)));
|
||||
if (!posted) { overflow = true; DEBUG_ERROR(CAT, "BLE WinRT event allocation failed"); return; }
|
||||
posted->callback = dispatch; posted->arg = this; uasync_post_reserved(ua, posted);
|
||||
}
|
||||
WinLink* link(ble_link_id id) { auto it = links.find(id); return it == links.end() ? nullptr : it->second.get(); }
|
||||
void fail(const char* reason) {
|
||||
if (failed) return;
|
||||
DEBUG_ERROR(CAT, "BLE WinRT radio failure scan_ready=%d service_ready=%d links=%zu publisher_generation=%llu",
|
||||
scan_ready, service_ready, links.size(), (unsigned long long)publish_generation);
|
||||
failed = true; DEBUG_ERROR(CAT, "BLE WinRT failed: %s", reason); ble_backend_status(owner, 0, reason);
|
||||
}
|
||||
void error(ble_link_id id, const char* reason) {
|
||||
DEBUG_WARN(CAT, "BLE WinRT link=%llu failed: %s", (unsigned long long)id, reason);
|
||||
ble_backend_closed(owner, id, reason);
|
||||
}
|
||||
void radio_error(const char* phase, BluetoothError error) {
|
||||
char reason[192];
|
||||
std::snprintf(reason, sizeof(reason), "%s: BluetoothError=%d (%s)", phase, (int)error, bluetooth_error(error));
|
||||
fail(reason);
|
||||
}
|
||||
template<typename Result, typename Callback>
|
||||
void await(const char* phase, const IAsyncOperation<Result>& operation, ble_link_id id, Callback callback) {
|
||||
step(phase, id);
|
||||
const bool fragment = !std::strcmp(phase, "write frame") || !std::strcmp(phase, "notify frame");
|
||||
if (!fragment) DEBUG_DEBUG(CAT, "BLE WinRT async begin phase=%s link=%llu", phase, (unsigned long long)id);
|
||||
if (id) { auto* l = link(id); if (!l) { operation.Cancel(); return; } l->operation = operation; }
|
||||
else startup = operation;
|
||||
auto weak = weak_from_this();
|
||||
operation.Completed([weak, id, phase, fragment, callback](const IAsyncOperation<Result>& op, AsyncStatus status) {
|
||||
if (auto s = weak.lock()) s->post(phase, id, [id, op, status, phase, fragment, callback](WinState& self) {
|
||||
auto* l = id ? self.link(id) : nullptr;
|
||||
if (id && !l) {
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT stale completion phase=%s link=%llu status=%d", phase, (unsigned long long)id, (int)status);
|
||||
return;
|
||||
}
|
||||
if (l) l->operation = nullptr; else self.startup = nullptr;
|
||||
if (status != AsyncStatus::Completed) {
|
||||
char reason[192];
|
||||
std::snprintf(reason, sizeof(reason), "phase=%s async_status=%d HRESULT=0x%08x", phase, (int)status,
|
||||
(unsigned)op.ErrorCode().value);
|
||||
if (id) self.error(id, reason); else self.fail(reason);
|
||||
return;
|
||||
}
|
||||
if (!fragment) DEBUG_DEBUG(CAT, "BLE WinRT async complete phase=%s link=%llu", phase, (unsigned long long)id);
|
||||
self.step("IAsyncOperation.GetResults", id);
|
||||
auto result = op.GetResults();
|
||||
self.step(phase, id);
|
||||
callback(self, result);
|
||||
});
|
||||
});
|
||||
}
|
||||
static IBuffer buffer(const uint8_t* data, size_t len) {
|
||||
DataWriter writer; writer.WriteBytes(array_view<const uint8_t>(data, data + len)); return writer.DetachBuffer();
|
||||
}
|
||||
static std::vector<uint8_t> bytes(const IBuffer& buffer) {
|
||||
if (buffer.Length() > BLE_VALUE_MAX) throw hresult_invalid_argument();
|
||||
std::vector<uint8_t> result(buffer.Length()); DataReader::FromBuffer(buffer).ReadBytes(result); return result;
|
||||
}
|
||||
void ready() {
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT readiness scan=%d service=%d", scan_ready, service_ready);
|
||||
if (scan_ready && service_ready) ble_backend_status(owner, 1, "WinRT scan/GATT ready");
|
||||
}
|
||||
/* Только доступные Win11 API: фактические параметры отделены от запрошенного профиля. */
|
||||
void log_connection(ble_link_id id) {
|
||||
auto* l = link(id);
|
||||
if (!l || !l->device) return;
|
||||
try {
|
||||
auto api = l->device.try_as<IBluetoothLEDevice6>();
|
||||
if (!api) return;
|
||||
auto parameters = api.GetConnectionParameters(); auto phy = api.GetConnectionPhy();
|
||||
DEBUG_INFO(CAT, "BLE WinRT negotiated link=%llu connected=%d interval_ms=%.2f latency=%u timeout_ms=%u TX_2M=%d RX_2M=%d TX_coded=%d RX_coded=%d",
|
||||
(unsigned long long)id, l->device.ConnectionStatus() == BluetoothConnectionStatus::Connected,
|
||||
parameters.ConnectionInterval() * 1.25, parameters.ConnectionLatency(), parameters.LinkTimeout() * 10,
|
||||
phy.TransmitInfo().IsUncoded2MPhy(), phy.ReceiveInfo().IsUncoded2MPhy(),
|
||||
phy.TransmitInfo().IsCodedPhy(), phy.ReceiveInfo().IsCodedPhy());
|
||||
} catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("read connection parameters/PHY", id).c_str()); }
|
||||
}
|
||||
void prefer_speed(WinLink& l) {
|
||||
try {
|
||||
auto api = l.device.try_as<IBluetoothLEDevice6>();
|
||||
if (!api) { DEBUG_INFO(CAT, "BLE WinRT speed API unavailable link=%llu; using OS parameters", (unsigned long long)l.id); return; }
|
||||
auto weak = weak_from_this(); auto id = l.id;
|
||||
auto changed = [weak, id](auto const&, auto const&) {
|
||||
if (auto s = weak.lock()) s->post("connection parameters/PHY changed", id, [id](WinState& s) { s.log_connection(id); });
|
||||
};
|
||||
l.parameters_changed = api.ConnectionParametersChanged(auto_revoke, changed);
|
||||
l.phy_changed = api.ConnectionPhyChanged(auto_revoke, changed);
|
||||
l.speed_request = api.RequestPreferredConnectionParameters(BluetoothLEPreferredConnectionParameters::ThroughputOptimized());
|
||||
auto status = l.speed_request.Status();
|
||||
if (status == BluetoothLEPreferredConnectionParametersRequestStatus::Success)
|
||||
DEBUG_INFO(CAT, "BLE WinRT ThroughputOptimized requested link=%llu; PHY selected by OS/peer", (unsigned long long)id);
|
||||
else DEBUG_WARN(CAT, "BLE WinRT ThroughputOptimized request link=%llu status=%d", (unsigned long long)id, (int)status);
|
||||
} catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("request throughput profile", l.id).c_str()); }
|
||||
}
|
||||
void watch_session(WinLink& l) {
|
||||
auto weak = weak_from_this(); auto id = l.id;
|
||||
step("GattSession.SessionStatusChanged registration (auto_revoke)", id);
|
||||
l.session_changed = l.session.SessionStatusChanged(auto_revoke, [weak, id](auto const&, GattSessionStatusChangedEventArgs const& a) {
|
||||
if (auto s = weak.lock()) s->post("GattSession.SessionStatusChanged", id, [id, a](WinState& s) {
|
||||
auto status = a.Status(); auto error = a.Error();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT session link=%llu status=%d error=%d (%s)",
|
||||
(unsigned long long)id, (int)status, (int)error, bluetooth_error(error));
|
||||
if (status == GattSessionStatus::Closed && s.link(id)) s.error(id, "GATT session closed");
|
||||
});
|
||||
});
|
||||
step("GattSession.MaxPduSizeChanged registration (auto_revoke)", id);
|
||||
l.pdu_changed = l.session.MaxPduSizeChanged(auto_revoke, [weak, id](auto const& session, auto const&) {
|
||||
if (auto s = weak.lock()) s->post("GattSession.MaxPduSizeChanged", id, [id, session](WinState& s) {
|
||||
if (!s.link(id)) return;
|
||||
auto mtu = session.MaxPduSize();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT negotiated PDU changed link=%llu mtu=%u", (unsigned long long)id, (unsigned)mtu);
|
||||
});
|
||||
});
|
||||
}
|
||||
WinLink* incoming(const GattSession& session) {
|
||||
step("incoming GattSession.DeviceId");
|
||||
hstring key = session.DeviceId().Id();
|
||||
for (auto& [id, l] : links) if (l->device_id == key) return l->outgoing ? nullptr : l.get();
|
||||
std::string endpoint = to_string(key);
|
||||
if (endpoint.size() >= BLE_ENDPOINT_MAX || links.size() >= BLE_LINKS_MAX) {
|
||||
DEBUG_WARN(CAT, "BLE WinRT incoming endpoint/connection limit"); return nullptr;
|
||||
}
|
||||
struct ble_endpoint ep{}; std::memcpy(ep.id, endpoint.c_str(), endpoint.size() + 1);
|
||||
ble_link_id id = ble_backend_incoming(owner, &ep); if (!id) return nullptr;
|
||||
auto l = std::make_unique<WinLink>(); l->id = id; l->endpoint = endpoint; l->device_id = key; l->session = session;
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT incoming link=%llu endpoint=%s mtu=%u", (unsigned long long)id, endpoint.c_str(), session.MaxPduSize());
|
||||
auto* result = l.get(); links.emplace(id, std::move(l)); watch_session(*result); return result;
|
||||
}
|
||||
void subscriptions() {
|
||||
std::vector<ble_link_id> active;
|
||||
step("GattLocalCharacteristic.SubscribedClients");
|
||||
auto invitations = local_invite.SubscribedClients(), acknowledgements = local_ack.SubscribedClients();
|
||||
auto clients = local_tx.SubscribedClients();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT subscriptions DATA=%u INVITE=%u ACK=%u links=%zu",
|
||||
clients.Size(), invitations.Size(), acknowledgements.Size(), links.size());
|
||||
for (auto const& client : clients) {
|
||||
step("GattSubscribedClient.Session/DeviceId");
|
||||
auto same_device = [&client](auto const& other) { return client.Session().DeviceId().Id() == other.Session().DeviceId().Id(); };
|
||||
auto invitation = std::find_if(invitations.begin(), invitations.end(), same_device);
|
||||
auto acknowledgement = std::find_if(acknowledgements.begin(), acknowledgements.end(), same_device);
|
||||
if (invitation == invitations.end() || acknowledgement == acknowledgements.end()) {
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT subscriptions incomplete device=%s DATA=1 INVITE=%d ACK=%d",
|
||||
to_string(client.Session().DeviceId().Id()).c_str(),
|
||||
invitation != invitations.end(), acknowledgement != acknowledgements.end());
|
||||
continue;
|
||||
}
|
||||
auto* l = incoming(client.Session()); if (!l) continue;
|
||||
l->subscriber = client; l->invite_subscriber = *invitation; l->ack_subscriber = *acknowledgement; active.push_back(l->id);
|
||||
if (!l->opened) {
|
||||
step("GattSubscribedClient.MaxNotificationSize", l->id);
|
||||
auto capacity = std::min({client.MaxNotificationSize(), (*invitation).MaxNotificationSize(), (*acknowledgement).MaxNotificationSize()});
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT subscribed DATA/INVITE/ACK link=%llu capacity=%u", (unsigned long long)l->id, capacity);
|
||||
l->opened = true; ble_backend_opened(owner, l->id, capacity + 3);
|
||||
}
|
||||
}
|
||||
std::vector<ble_link_id> gone;
|
||||
for (auto& [id, l] : links) if (!l->outgoing && l->opened && std::find(active.begin(), active.end(), id) == active.end()) gone.push_back(id);
|
||||
for (auto id : gone) error(id, "notification subscription removed");
|
||||
}
|
||||
void make_server() {
|
||||
await("create GATT service", GattServiceProvider::CreateAsync(guid(BLE_SERVICE_UUID)), 0, [](WinState& s, GattServiceProviderResult const& result) {
|
||||
if (result.Error() != BluetoothError::Success) { s.radio_error("create GATT service", result.Error()); return; }
|
||||
s.provider = result.ServiceProvider();
|
||||
GattLocalCharacteristicParameters parameters;
|
||||
parameters.CharacteristicProperties(GattCharacteristicProperties::WriteWithoutResponse);
|
||||
parameters.WriteProtectionLevel(GattProtectionLevel::Plain);
|
||||
s.await("create RX characteristic", s.provider.Service().CreateCharacteristicAsync(guid(BLE_RX_UUID), parameters), 0,
|
||||
[](WinState& s, GattLocalCharacteristicResult const& result) {
|
||||
if (result.Error() != BluetoothError::Success) { s.radio_error("create RX characteristic", result.Error()); return; }
|
||||
s.local_rx = result.Characteristic(); s.make_tx();
|
||||
});
|
||||
});
|
||||
}
|
||||
void make_tx() {
|
||||
GattLocalCharacteristicParameters parameters; parameters.CharacteristicProperties(GattCharacteristicProperties::Notify);
|
||||
parameters.ReadProtectionLevel(GattProtectionLevel::Plain);
|
||||
await("create TX characteristic", provider.Service().CreateCharacteristicAsync(guid(BLE_TX_UUID), parameters), 0,
|
||||
[](WinState& s, GattLocalCharacteristicResult const& result) {
|
||||
if (result.Error() != BluetoothError::Success) { s.radio_error("create TX characteristic", result.Error()); return; }
|
||||
s.local_tx = result.Characteristic(); s.make_invite();
|
||||
});
|
||||
}
|
||||
void make_invite() {
|
||||
GattLocalCharacteristicParameters parameters;
|
||||
parameters.CharacteristicProperties(GattCharacteristicProperties::Write | GattCharacteristicProperties::Indicate);
|
||||
parameters.WriteProtectionLevel(GattProtectionLevel::Plain); parameters.ReadProtectionLevel(GattProtectionLevel::Plain);
|
||||
await("create invitation characteristic", provider.Service().CreateCharacteristicAsync(guid(BLE_INVITE_UUID), parameters), 0,
|
||||
[](WinState& s, GattLocalCharacteristicResult const& result) {
|
||||
if (result.Error() != BluetoothError::Success) { s.radio_error("create invitation characteristic", result.Error()); return; }
|
||||
s.local_invite = result.Characteristic(); s.make_ack();
|
||||
});
|
||||
}
|
||||
void make_ack() {
|
||||
GattLocalCharacteristicParameters parameters;
|
||||
parameters.CharacteristicProperties(GattCharacteristicProperties::Write | GattCharacteristicProperties::Indicate);
|
||||
parameters.WriteProtectionLevel(GattProtectionLevel::Plain); parameters.ReadProtectionLevel(GattProtectionLevel::Plain);
|
||||
await("create ACK characteristic", provider.Service().CreateCharacteristicAsync(guid(BLE_ACK_UUID), parameters), 0,
|
||||
[](WinState& s, GattLocalCharacteristicResult const& result) {
|
||||
if (result.Error() != BluetoothError::Success) { s.radio_error("create ACK characteristic", result.Error()); return; }
|
||||
s.local_ack = result.Characteristic(); s.start_server();
|
||||
});
|
||||
}
|
||||
GattLocalCharacteristic::WriteRequested_revoker watch_write(GattLocalCharacteristic const& characteristic, enum ble_wire_channel channel) {
|
||||
auto weak = weak_from_this();
|
||||
step("GattLocalCharacteristic.WriteRequested registration (auto_revoke)");
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT register WriteRequested channel=%d", channel);
|
||||
return characteristic.WriteRequested(auto_revoke, [weak, channel](auto const&, GattWriteRequestedEventArgs const& args) {
|
||||
const char* phase = "WriteRequested.GetDeferral";
|
||||
try {
|
||||
auto deferral = std::make_shared<WriteDeferral>(args.GetDeferral());
|
||||
phase = "WriteRequested.GetRequestAsync"; auto request = args.GetRequestAsync();
|
||||
phase = "WriteRequested.Session"; auto session = args.Session();
|
||||
phase = "WriteRequested.GetRequestAsync.Completed registration";
|
||||
request.Completed([weak, deferral, session, channel](auto const& operation, AsyncStatus status) {
|
||||
if (auto s = weak.lock()) s->post("GattLocalCharacteristic.WriteRequested", 0,
|
||||
[deferral, session, operation, status, channel](WinState& s) {
|
||||
if (status != AsyncStatus::Completed) {
|
||||
DEBUG_WARN(CAT, "BLE WinRT GetRequest channel=%d status=%d HRESULT=0x%08x",
|
||||
channel, (int)status, (unsigned)operation.ErrorCode().value);
|
||||
return;
|
||||
}
|
||||
s.step("GattWriteRequestedEventArgs.GetRequestAsync.GetResults");
|
||||
auto request = operation.GetResults(); if (!request) { DEBUG_WARN(CAT, "BLE WinRT expired write request"); return; }
|
||||
s.step("GattWriteRequest.Option/Offset/Value");
|
||||
bool response = request.Option() == GattWriteOption::WriteWithResponse;
|
||||
auto expected = channel == BLE_WIRE_DATA ? GattWriteOption::WriteWithoutResponse : GattWriteOption::WriteWithResponse;
|
||||
if (request.Offset() || request.Option() != expected || !request.Value().Length() || request.Value().Length() > BLE_VALUE_MAX) {
|
||||
DEBUG_WARN(CAT, "BLE WinRT invalid write offset=%u option=%d bytes=%u", request.Offset(),
|
||||
(int)request.Option(), request.Value().Length());
|
||||
if (response) request.RespondWithProtocolError(0x06);
|
||||
return;
|
||||
}
|
||||
auto* l = s.incoming(session);
|
||||
if (!l) { if (response) request.RespondWithProtocolError(0x08); return; }
|
||||
if (!l->opened) {
|
||||
/* WriteRequested может обогнать posted SubscribedClientsChanged: перечитать реальные CCC. */
|
||||
auto id = l->id; s.subscriptions(); l = s.link(id);
|
||||
if (!l || !l->opened) {
|
||||
DEBUG_WARN(CAT, "BLE WinRT write before mandatory subscriptions link=%llu", (unsigned long long)id);
|
||||
if (response) request.RespondWithProtocolError(0x08);
|
||||
return;
|
||||
}
|
||||
}
|
||||
auto id = l->id;
|
||||
s.step("GattWriteRequest.Value/DataReader", id); auto data = bytes(request.Value());
|
||||
DEBUG_TRACE(CAT, "BLE WinRT RX write link=%llu channel=%d bytes=%zu response=%d",
|
||||
(unsigned long long)id, channel, data.size(), response);
|
||||
ble_backend_received(s.owner, id, channel, data.data(), data.size());
|
||||
s.step("GattWriteRequest.Respond", id);
|
||||
if (response) { if (s.link(id)) request.Respond(); else request.RespondWithProtocolError(0x08); }
|
||||
});
|
||||
});
|
||||
} catch (...) {
|
||||
auto reason = exception_reason(phase);
|
||||
if (auto s = weak.lock()) s->post("WriteRequested failure", 0, [reason](WinState& s) { s.fail(reason.c_str()); });
|
||||
}
|
||||
});
|
||||
}
|
||||
void start_server() {
|
||||
auto weak = weak_from_this();
|
||||
write_requested = watch_write(local_rx, BLE_WIRE_DATA);
|
||||
invite_write_requested = watch_write(local_invite, BLE_WIRE_INVITE);
|
||||
ack_write_requested = watch_write(local_ack, BLE_WIRE_ACK);
|
||||
step("ACK.SubscribedClientsChanged registration (auto_revoke)");
|
||||
ack_subscriptions_changed = local_ack.SubscribedClientsChanged(auto_revoke, [weak](auto const&, auto const&) {
|
||||
if (auto s = weak.lock()) s->post("ACK.SubscribedClientsChanged", 0, [](WinState& s) { s.subscriptions(); });
|
||||
});
|
||||
step("INVITE.SubscribedClientsChanged registration (auto_revoke)");
|
||||
invite_subscriptions_changed = local_invite.SubscribedClientsChanged(auto_revoke, [weak](auto const&, auto const&) {
|
||||
if (auto s = weak.lock()) s->post("INVITE.SubscribedClientsChanged", 0, [](WinState& s) { s.subscriptions(); });
|
||||
});
|
||||
step("DATA.SubscribedClientsChanged registration (auto_revoke)");
|
||||
subscriptions_changed = local_tx.SubscribedClientsChanged(auto_revoke, [weak](auto const&, auto const&) {
|
||||
if (auto s = weak.lock()) s->post("DATA.SubscribedClientsChanged", 0, [](WinState& s) { s.subscriptions(); });
|
||||
});
|
||||
step("GattServiceProvider.AdvertisementStatusChanged registration (auto_revoke)");
|
||||
service_changed = provider.AdvertisementStatusChanged(auto_revoke, [weak](auto const&, auto const& args) {
|
||||
if (auto s = weak.lock()) s->post("GattServiceProvider.AdvertisementStatusChanged", 0, [args](WinState& s) {
|
||||
auto status = args.Status(); auto error = args.Error();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT service advertising state=%d error=%d (%s)", (int)status, (int)error, bluetooth_error(error));
|
||||
if (status == GattServiceProviderAdvertisementStatus::Started) { s.service_ready = true; s.ready(); }
|
||||
else if (status == GattServiceProviderAdvertisementStatus::Aborted) s.radio_error("GATT advertising aborted", error);
|
||||
else DEBUG_INFO(CAT, "BLE WinRT service advertising state=%d", (int)status);
|
||||
});
|
||||
});
|
||||
GattServiceProviderAdvertisingParameters parameters; parameters.IsConnectable(true); parameters.IsDiscoverable(true);
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT start server service=%s connectable=1 discoverable=1", BLE_SERVICE_UUID);
|
||||
step("GattServiceProvider.StartAdvertising"); provider.StartAdvertising(parameters);
|
||||
step("BluetoothLEAdvertisementWatcher construction/ScanningMode");
|
||||
watcher = BluetoothLEAdvertisementWatcher(); watcher.ScanningMode(BluetoothLEScanningMode::Active);
|
||||
step("Watcher.Received registration (auto_revoke)");
|
||||
received = watcher.Received(auto_revoke, [weak](auto const&, BluetoothLEAdvertisementReceivedEventArgs const& args) {
|
||||
auto state = weak.lock(); if (!state) return;
|
||||
try {
|
||||
bool matching = false;
|
||||
for (auto const& value : args.Advertisement().ManufacturerData()) if (value.CompanyId() == state->company) matching = true;
|
||||
if (!matching) return;
|
||||
} catch (...) {
|
||||
auto reason = exception_reason("Received manufacturer filter");
|
||||
state->post("Watcher.Received filter failure", 0, [reason](WinState& s) { s.fail(reason.c_str()); });
|
||||
return;
|
||||
}
|
||||
state->post("Watcher.Received", 0, [args](WinState& s) {
|
||||
s.step("Received.Advertisement.ManufacturerData");
|
||||
for (auto const& value : args.Advertisement().ManufacturerData()) {
|
||||
if (value.CompanyId() != s.company || value.Data().Length() > BLE_BROADCAST_MAX) continue;
|
||||
struct ble_endpoint ep{};
|
||||
s.step("Received.QueryInterface(IBluetoothLEAdvertisementReceivedEventArgs2)");
|
||||
auto address_api = args.try_as<IBluetoothLEAdvertisementReceivedEventArgs2>();
|
||||
if (!address_api) {
|
||||
s.fail("Received: IBluetoothLEAdvertisementReceivedEventArgs2 unavailable; Windows 10 2004+ required"); return;
|
||||
}
|
||||
s.step("Received.BluetoothAddressType"); auto type = address_api.BluetoothAddressType();
|
||||
s.step("Received.BluetoothAddress"); auto address = args.BluetoothAddress();
|
||||
std::snprintf(ep.id, sizeof(ep.id), "%012llx/%d", (unsigned long long)address, (int)type);
|
||||
s.step("Received manufacturer buffer/DataReader");
|
||||
auto data = bytes(value.Data());
|
||||
s.step("Received.RawSignalStrengthInDBm"); auto rssi = args.RawSignalStrengthInDBm();
|
||||
DEBUG_TRACE(CAT, "BLE WinRT broadcast endpoint=%s company=%04x bytes=%zu rssi=%d", ep.id, s.company,
|
||||
data.size(), rssi);
|
||||
s.step("ble_backend_broadcast");
|
||||
ble_backend_broadcast(s.owner, &ep, data.data(), data.size(), rssi);
|
||||
}
|
||||
});
|
||||
});
|
||||
step("Watcher.Stopped registration (auto_revoke)");
|
||||
scan_stopped = watcher.Stopped(auto_revoke, [weak](auto const&, auto const& args) {
|
||||
if (auto s = weak.lock()) s->post("Watcher.Stopped", 0, [args](WinState& s) {
|
||||
s.radio_error("scan stopped", args.Error());
|
||||
});
|
||||
});
|
||||
step("Watcher.Start"); watcher.Start();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT scan requested mode=active status=%d company=%04x", (int)watcher.Status(), company);
|
||||
scan_ready = true; ready();
|
||||
}
|
||||
void publish() {
|
||||
step("Publisher.Status");
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT publish requested enabled=%d bytes=%zu generation=%llu", broadcast_enabled,
|
||||
broadcast_size, (unsigned long long)publish_generation);
|
||||
if (publisher) {
|
||||
auto status = publisher.Status();
|
||||
if (status == BluetoothLEAdvertisementPublisherStatus::Started || status == BluetoothLEAdvertisementPublisherStatus::Waiting) {
|
||||
publisher.Stop(); return;
|
||||
}
|
||||
if (status == BluetoothLEAdvertisementPublisherStatus::Stopping) return;
|
||||
publish_changed.revoke(); publisher = nullptr;
|
||||
}
|
||||
if (!broadcast_enabled) return;
|
||||
step("Publisher construction/manufacturer data");
|
||||
BluetoothLEAdvertisement advertisement;
|
||||
BluetoothLEManufacturerData data; data.CompanyId(company); data.Data(buffer(broadcast.data(), broadcast_size));
|
||||
advertisement.ManufacturerData().Append(data); publisher = BluetoothLEAdvertisementPublisher(advertisement);
|
||||
auto weak = weak_from_this(); auto generation = ++publish_generation;
|
||||
step("Publisher.StatusChanged registration (auto_revoke)");
|
||||
publish_changed = publisher.StatusChanged(auto_revoke, [weak, generation](auto const&, auto const& args) {
|
||||
if (auto s = weak.lock()) s->post("Publisher.StatusChanged", 0, [args, generation](WinState& s) {
|
||||
if (generation != s.publish_generation) {
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT stale publisher event generation=%llu current=%llu",
|
||||
(unsigned long long)generation, (unsigned long long)s.publish_generation);
|
||||
return;
|
||||
}
|
||||
auto status = args.Status(); auto error = args.Error();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT broadcast state=%d error=%d (%s) generation=%llu", (int)status, (int)error,
|
||||
bluetooth_error(error), (unsigned long long)generation);
|
||||
if (status == BluetoothLEAdvertisementPublisherStatus::Aborted) s.radio_error("broadcast publication aborted", error);
|
||||
else if (status == BluetoothLEAdvertisementPublisherStatus::Stopped) {
|
||||
ble_backend_advertising(s.owner, BLE_CLOSED, "publisher stopped"); s.publish();
|
||||
} else if (status == BluetoothLEAdvertisementPublisherStatus::Started) ble_backend_advertising(s.owner, BLE_READY, "publisher started");
|
||||
else if (status == BluetoothLEAdvertisementPublisherStatus::Waiting)
|
||||
ble_backend_advertising(s.owner, BLE_STARTING, "publisher waiting for radio resources");
|
||||
else if (status == BluetoothLEAdvertisementPublisherStatus::Stopping)
|
||||
ble_backend_advertising(s.owner, BLE_STARTING, "publisher stopping");
|
||||
else ble_backend_advertising(s.owner, BLE_STARTING, "publisher created");
|
||||
});
|
||||
});
|
||||
step("Publisher.Start"); publisher.Start();
|
||||
}
|
||||
void connect(ble_link_id id, uint64_t address, BluetoothAddressType type) {
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT connect link=%llu address=%012llx type=%d", (unsigned long long)id, (unsigned long long)address, (int)type);
|
||||
await("open BLE device", BluetoothLEDevice::FromBluetoothAddressAsync(address, type), id, [id](WinState& s, BluetoothLEDevice const& device) {
|
||||
auto* l = s.link(id);
|
||||
if (!device) { s.error(id, "BLE device unavailable"); return; }
|
||||
l->device = device; l->device_id = device.BluetoothDeviceId().Id();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT device link=%llu endpoint=%s status=%d", (unsigned long long)id,
|
||||
to_string(l->device_id).c_str(), (int)device.ConnectionStatus());
|
||||
s.await("discover GATT service", device.GetGattServicesForUuidAsync(guid(BLE_SERVICE_UUID), BluetoothCacheMode::Uncached), id,
|
||||
[id](WinState& s, GattDeviceServicesResult const& result) {
|
||||
auto protocol = result.ProtocolError();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT services link=%llu status=%d ATT_error=%d count=%u", (unsigned long long)id,
|
||||
(int)result.Status(), protocol ? int(protocol.Value()) : -1,
|
||||
result.Status() == GattCommunicationStatus::Success ? result.Services().Size() : 0);
|
||||
if (result.Status() != GattCommunicationStatus::Success || result.Services().Size() != 1) {
|
||||
DEBUG_WARN(CAT, "BLE WinRT service unavailable link=%llu status=%d ATT_error=%d",
|
||||
(unsigned long long)id, (int)result.Status(), protocol ? int(protocol.Value()) : -1);
|
||||
s.error(id, "VibeChat GATT service unavailable/ambiguous"); return;
|
||||
}
|
||||
auto* l = s.link(id); l->service = result.Services().GetAt(0);
|
||||
s.await("discover GATT characteristics", l->service.GetCharacteristicsAsync(BluetoothCacheMode::Uncached), id,
|
||||
[id](WinState& s, GattCharacteristicsResult const& result) {
|
||||
auto protocol = result.ProtocolError();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT characteristics link=%llu status=%d ATT_error=%d count=%u", (unsigned long long)id,
|
||||
(int)result.Status(), protocol ? int(protocol.Value()) : -1,
|
||||
result.Status() == GattCommunicationStatus::Success ? result.Characteristics().Size() : 0);
|
||||
if (result.Status() != GattCommunicationStatus::Success) {
|
||||
DEBUG_WARN(CAT, "BLE WinRT discovery failed link=%llu status=%d ATT_error=%d",
|
||||
(unsigned long long)id, (int)result.Status(), protocol ? int(protocol.Value()) : -1);
|
||||
s.error(id, "GATT discovery failed"); return;
|
||||
}
|
||||
auto* l = s.link(id);
|
||||
for (auto const& ch : result.Characteristics()) {
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT characteristic link=%llu uuid=%s properties=0x%x", (unsigned long long)id,
|
||||
to_string(to_hstring(ch.Uuid())).c_str(), (unsigned)ch.CharacteristicProperties());
|
||||
if (ch.Uuid() == guid(BLE_RX_UUID)) l->rx = ch;
|
||||
if (ch.Uuid() == guid(BLE_TX_UUID)) l->tx = ch;
|
||||
if (ch.Uuid() == guid(BLE_INVITE_UUID)) l->invite = ch;
|
||||
if (ch.Uuid() == guid(BLE_ACK_UUID)) l->ack = ch;
|
||||
}
|
||||
if (!l->rx || !l->tx || !l->invite || !l->ack) { s.error(id, "RX/TX/INVITE/ACK characteristics missing"); return; }
|
||||
s.subscribe(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
void subscribe(ble_link_id id) {
|
||||
auto* l = link(id);
|
||||
await("open GATT session", GattSession::FromDeviceIdAsync(l->device.BluetoothDeviceId()), id, [id](WinState& s, GattSession const& session) {
|
||||
auto* l = s.link(id);
|
||||
if (!session) { s.error(id, "GATT session unavailable"); return; }
|
||||
l->session = session; session.MaintainConnection(true); s.watch_session(*l);
|
||||
s.prefer_speed(*l);
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT session opened link=%llu mtu=%u maintain_connection=1", (unsigned long long)id, session.MaxPduSize());
|
||||
s.log_connection(id);
|
||||
l->value_changed = s.watch_value(id, l->tx, BLE_WIRE_DATA);
|
||||
l->invite_changed = s.watch_value(id, l->invite, BLE_WIRE_INVITE);
|
||||
l->ack_changed = s.watch_value(id, l->ack, BLE_WIRE_ACK);
|
||||
s.subscribe_channel(id, BLE_WIRE_DATA);
|
||||
});
|
||||
}
|
||||
GattCharacteristic::ValueChanged_revoker watch_value(ble_link_id id, GattCharacteristic const& characteristic, enum ble_wire_channel channel) {
|
||||
auto weak = weak_from_this();
|
||||
step("GattCharacteristic.ValueChanged registration (auto_revoke)", id);
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT register ValueChanged link=%llu channel=%d", (unsigned long long)id, channel);
|
||||
return characteristic.ValueChanged(auto_revoke, [weak, id, channel](auto const&, GattValueChangedEventArgs const& args) {
|
||||
if (auto s = weak.lock()) s->post("GattCharacteristic.ValueChanged", id, [id, channel, args](WinState& s) {
|
||||
if (!s.link(id)) return;
|
||||
s.step("ValueChanged.CharacteristicValue/DataReader", id);
|
||||
auto data = bytes(args.CharacteristicValue());
|
||||
DEBUG_TRACE(CAT, "BLE WinRT RX value link=%llu channel=%d bytes=%zu", (unsigned long long)id, channel, data.size());
|
||||
ble_backend_received(s.owner, id, channel, data.data(), data.size());
|
||||
});
|
||||
});
|
||||
}
|
||||
void subscribe_channel(ble_link_id id, enum ble_wire_channel channel) {
|
||||
auto* l = link(id);
|
||||
auto characteristic = channel == BLE_WIRE_ACK ? l->ack : channel == BLE_WIRE_DATA ? l->tx : l->invite;
|
||||
auto mode = channel == BLE_WIRE_DATA ? GattClientCharacteristicConfigurationDescriptorValue::Notify :
|
||||
GattClientCharacteristicConfigurationDescriptorValue::Indicate;
|
||||
step("GattCharacteristic.WriteCCCDWithResultAsync", id);
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT subscribe request link=%llu channel=%d mode=%d", (unsigned long long)id, channel, (int)mode);
|
||||
await("subscribe channel", characteristic.WriteClientCharacteristicConfigurationDescriptorWithResultAsync(mode), id,
|
||||
[id, channel](WinState& s, GattWriteResult const& result) {
|
||||
auto status = result.Status(); auto protocol = result.ProtocolError();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT subscribe link=%llu channel=%d status=%d", (unsigned long long)id, channel, (int)status);
|
||||
if (status != GattCommunicationStatus::Success) {
|
||||
DEBUG_WARN(CAT, "BLE WinRT subscription failed link=%llu channel=%d status=%d ATT_error=%d",
|
||||
(unsigned long long)id, channel, (int)status, protocol ? int(protocol.Value()) : -1);
|
||||
s.error(id, "GATT subscription failed"); return;
|
||||
}
|
||||
if (channel == BLE_WIRE_DATA) { s.subscribe_channel(id, BLE_WIRE_INVITE); return; }
|
||||
if (channel == BLE_WIRE_INVITE) { s.subscribe_channel(id, BLE_WIRE_ACK); return; }
|
||||
auto* l = s.link(id); l->opened = true; ble_backend_opened(s.owner, id, l->session.MaxPduSize());
|
||||
});
|
||||
}
|
||||
void close(ble_link_id id) {
|
||||
auto it = links.find(id); if (it == links.end()) return;
|
||||
auto l = std::move(it->second); links.erase(it);
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT close link=%llu outgoing=%d opened=%d sending=%d pending=%d", (unsigned long long)id,
|
||||
l->outgoing, l->opened, l->sending, bool(l->operation));
|
||||
l->value_changed.revoke(); l->invite_changed.revoke(); l->ack_changed.revoke(); l->session_changed.revoke(); l->pdu_changed.revoke();
|
||||
l->parameters_changed.revoke(); l->phy_changed.revoke();
|
||||
try { if (l->speed_request) l->speed_request.Close(); }
|
||||
catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("close throughput request", id).c_str()); }
|
||||
try {
|
||||
if (l->operation) l->operation.Cancel();
|
||||
if (l->outgoing) {
|
||||
if (l->session) { l->session.MaintainConnection(false); l->session.Close(); }
|
||||
if (l->service) l->service.Close();
|
||||
if (l->device) l->device.Close();
|
||||
}
|
||||
} catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("close", id).c_str()); }
|
||||
}
|
||||
void shutdown() {
|
||||
struct posted_task* task;
|
||||
std::deque<Event> discarded;
|
||||
{ std::lock_guard guard(mutex); stopped = true; task = posted; posted = nullptr; discarded.swap(events); }
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT shutdown links=%zu discarded_events=%zu pending_dispatch=%d", links.size(), discarded.size(), task != nullptr);
|
||||
discarded.clear();
|
||||
if (task) uasync_cancel_post(ua, task);
|
||||
received.revoke(); scan_stopped.revoke(); publish_changed.revoke(); service_changed.revoke();
|
||||
write_requested.revoke(); subscriptions_changed.revoke(); invite_write_requested.revoke(); invite_subscriptions_changed.revoke();
|
||||
ack_write_requested.revoke(); ack_subscriptions_changed.revoke();
|
||||
while (!links.empty()) close(links.begin()->first);
|
||||
try { if (startup) startup.Cancel(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("cancel startup").c_str()); }
|
||||
try { if (watcher) watcher.Stop(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("stop scan").c_str()); }
|
||||
try { if (publisher) publisher.Stop(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("stop publisher").c_str()); }
|
||||
try { if (provider) provider.StopAdvertising(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("stop server").c_str()); }
|
||||
watcher = nullptr; publisher = nullptr; provider = nullptr;
|
||||
local_rx = nullptr; local_tx = nullptr; local_invite = nullptr; local_ack = nullptr; startup = nullptr;
|
||||
if (apartment) { uninit_apartment(); apartment = false; }
|
||||
}
|
||||
};
|
||||
struct ble_backend { std::shared_ptr<WinState> state; };
|
||||
extern "C" struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* cfg) {
|
||||
if (cfg->adapter) { DEBUG_ERROR(CAT, "BLE WinRT explicit adapter selection unsupported"); return nullptr; }
|
||||
auto state = std::make_shared<WinState>(); state->owner = owner; state->ua = ua; state->company = cfg->company_id;
|
||||
try {
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT initialize company=%04x node=%016llx apartment=MTA", cfg->company_id, (unsigned long long)cfg->node_id);
|
||||
init_apartment(apartment_type::multi_threaded); state->apartment = true;
|
||||
state->step("ApiInformation.IsPropertyPresent(BluetoothAddressType)");
|
||||
bool address_type = Windows::Foundation::Metadata::ApiInformation::IsPropertyPresent(
|
||||
L"Windows.Devices.Bluetooth.Advertisement.BluetoothLEAdvertisementReceivedEventArgs", L"BluetoothAddressType");
|
||||
DEBUG_INFO(CAT, "BLE WinRT API BluetoothAddressType=%d required=Windows10-2004 wire=3 channels=DATA/INVITE/ACK", address_type);
|
||||
if (!address_type) throw hresult_error(E_NOINTERFACE, L"BLE requires BluetoothAddressType (Windows 10 2004 / build 19041 or newer)");
|
||||
try {
|
||||
auto version = Windows::System::Profile::AnalyticsInfo::VersionInfo();
|
||||
DEBUG_INFO(CAT, "BLE WinRT OS family=%s version=%s", to_string(version.DeviceFamily()).c_str(),
|
||||
to_string(version.DeviceFamilyVersion()).c_str());
|
||||
} catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("OS version diagnostics").c_str()); }
|
||||
state->await("get default adapter", BluetoothAdapter::GetDefaultAsync(), 0, [](WinState& s, BluetoothAdapter const& adapter) {
|
||||
if (!adapter) { s.fail("no default Bluetooth adapter"); return; }
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT adapter id=%s address=%012llx BLE=%d central=%d peripheral=%d", to_string(adapter.DeviceId()).c_str(),
|
||||
(unsigned long long)adapter.BluetoothAddress(), adapter.IsLowEnergySupported(),
|
||||
adapter.IsCentralRoleSupported(), adapter.IsPeripheralRoleSupported());
|
||||
try {
|
||||
auto advertising = adapter.try_as<IBluetoothAdapter3>();
|
||||
auto phy = adapter.try_as<IBluetoothAdapter4>();
|
||||
DEBUG_DEBUG(CAT, "BLE WinRT adapter extended_advertising=%d advertisement_max=%d PHY_2M_supported=%d PHY_coded_supported=%d (-1=API unavailable)",
|
||||
advertising ? int(advertising.IsExtendedAdvertisingSupported()) : -1,
|
||||
advertising ? int(advertising.MaxAdvertisementDataLength()) : -1,
|
||||
phy ? int(phy.IsLowEnergyUncoded2MPhySupported()) : -1, phy ? int(phy.IsLowEnergyCodedPhySupported()) : -1);
|
||||
} catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("optional adapter capabilities").c_str()); }
|
||||
if (!adapter.IsLowEnergySupported() || !adapter.IsCentralRoleSupported() || !adapter.IsPeripheralRoleSupported()) {
|
||||
s.fail("adapter lacks BLE central/peripheral support"); return;
|
||||
}
|
||||
s.make_server();
|
||||
});
|
||||
void* memory = u_malloc(sizeof(ble_backend));
|
||||
if (!memory) { DEBUG_ERROR(CAT, "BLE WinRT allocation failed"); state->shutdown(); return nullptr; }
|
||||
return new(memory) ble_backend{state};
|
||||
} catch (...) { DEBUG_ERROR(CAT, "%s", exception_reason(state->operation).c_str()); state->shutdown(); return nullptr; }
|
||||
}
|
||||
extern "C" void ble_backend_destroy(struct ble_backend* b) {
|
||||
if (!b) return;
|
||||
b->state->shutdown(); b->~ble_backend(); u_free(b);
|
||||
}
|
||||
extern "C" int ble_backend_advertise(struct ble_backend* b, const uint8_t* data, size_t len) {
|
||||
try {
|
||||
auto& s = *b->state; s.broadcast_enabled = data != nullptr; s.broadcast_size = len;
|
||||
if (len) std::memcpy(s.broadcast.data(), data, len);
|
||||
s.publish(); return BLE_OK;
|
||||
} catch (...) { DEBUG_ERROR(CAT, "%s", exception_reason(b->state->operation).c_str()); return BLE_ERROR; }
|
||||
}
|
||||
extern "C" int ble_backend_connect(struct ble_backend* b, ble_link_id id, const struct ble_endpoint* ep) {
|
||||
unsigned long long address; int type, consumed = 0;
|
||||
if (std::sscanf(ep->id, "%llx/%d%n", &address, &type, &consumed) != 2 || ep->id[consumed] || address >> 48 || type < 0 || type > 2) {
|
||||
DEBUG_ERROR(CAT, "BLE invalid WinRT endpoint=%s", ep->id); return BLE_ERROR;
|
||||
}
|
||||
try {
|
||||
auto& s = *b->state; auto l = std::make_unique<WinLink>(); l->id = id; l->endpoint = ep->id; l->outgoing = true;
|
||||
s.links.emplace(id, std::move(l)); s.connect(id, address, static_cast<BluetoothAddressType>(type)); return BLE_OK;
|
||||
} catch (...) { DEBUG_ERROR(CAT, "%s", exception_reason("connect", id).c_str()); b->state->close(id); return BLE_ERROR; }
|
||||
}
|
||||
extern "C" void ble_backend_close(struct ble_backend* b, ble_link_id id) { b->state->close(id); }
|
||||
extern "C" int ble_backend_send(struct ble_backend* b, ble_link_id id, enum ble_wire_channel channel, const uint8_t* data, size_t len) {
|
||||
auto& s = *b->state; auto* l = s.link(id);
|
||||
if ((unsigned)channel >= BLE_WIRE_COUNT || !l || !l->opened || l->sending) {
|
||||
DEBUG_ERROR(CAT, "BLE invalid WinRT send link=%llu exists=%d opened=%d sending=%d bytes=%zu", (unsigned long long)id,
|
||||
l != nullptr, l && l->opened, l && l->sending, len);
|
||||
return BLE_ERROR;
|
||||
}
|
||||
try {
|
||||
DEBUG_TRACE(CAT, "BLE WinRT TX fragment link=%llu outgoing=%d channel=%d bytes=%zu",
|
||||
(unsigned long long)id, l->outgoing, channel, len);
|
||||
s.step("TX DataWriter buffer", id);
|
||||
l->sending = true; auto buffer = WinState::buffer(data, len);
|
||||
auto option = channel == BLE_WIRE_DATA ? GattWriteOption::WriteWithoutResponse : GattWriteOption::WriteWithResponse;
|
||||
uint64_t started = get_time_us();
|
||||
s.step(l->outgoing ? "GattCharacteristic.WriteValueWithResultAsync" : "GattLocalCharacteristic.NotifyValueAsync", id);
|
||||
if (l->outgoing) s.await("write frame", (channel == BLE_WIRE_ACK ? l->ack : channel == BLE_WIRE_INVITE ? l->invite : l->rx)
|
||||
.WriteValueWithResultAsync(buffer, option), id,
|
||||
[id, len, channel, started](WinState& s, GattWriteResult const& result) {
|
||||
auto* l = s.link(id); l->sending = false;
|
||||
auto protocol = result.ProtocolError();
|
||||
if (result.Status() != GattCommunicationStatus::Success)
|
||||
DEBUG_WARN(CAT, "BLE WinRT write failed link=%llu channel=%d bytes=%zu status=%d ATT_error=%d", (unsigned long long)id,
|
||||
channel, len, (int)result.Status(), protocol ? int(protocol.Value()) : -1);
|
||||
else DEBUG_TRACE(CAT, "BLE WinRT write complete link=%llu channel=%d bytes=%zu elapsed_us=%llu",
|
||||
(unsigned long long)id, channel, len, (unsigned long long)(get_time_us() - started));
|
||||
ble_backend_sent(s.owner, id, result.Status() == GattCommunicationStatus::Success);
|
||||
});
|
||||
else s.await("notify frame", (channel == BLE_WIRE_ACK ? s.local_ack : channel == BLE_WIRE_INVITE ? s.local_invite : s.local_tx)
|
||||
.NotifyValueAsync(buffer, channel == BLE_WIRE_ACK ? l->ack_subscriber :
|
||||
channel == BLE_WIRE_INVITE ? l->invite_subscriber : l->subscriber), id,
|
||||
[id, len, channel, started](WinState& s, GattClientNotificationResult const& result) {
|
||||
auto* l = s.link(id); l->sending = false;
|
||||
s.step("GattClientNotificationResult.Status/BytesSent", id);
|
||||
if (result.Status() != GattCommunicationStatus::Success || result.BytesSent() != len)
|
||||
DEBUG_WARN(CAT, "BLE WinRT notification failed link=%llu channel=%d expected=%zu sent=%u status=%d", (unsigned long long)id,
|
||||
channel, len, result.BytesSent(), (int)result.Status());
|
||||
else DEBUG_TRACE(CAT, "BLE WinRT notification complete link=%llu channel=%d bytes=%zu elapsed_us=%llu",
|
||||
(unsigned long long)id, channel, len, (unsigned long long)(get_time_us() - started));
|
||||
ble_backend_sent(s.owner, id, result.Status() == GattCommunicationStatus::Success && result.BytesSent() == len);
|
||||
});
|
||||
return BLE_OK;
|
||||
} catch (...) { l->sending = false; DEBUG_ERROR(CAT, "%s", exception_reason(s.operation, id).c_str()); return BLE_ERROR; }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Реальное Android↔desktop радио. Android instrumentation включает только тестовый echo.
|
||||
* ble_peer_probe [connect|listen] [seconds] [peer_hex]; 96 пакетов, TX saturation, побайтовая сверка. */
|
||||
#include "ble_transport.h"
|
||||
#include "ble_identity.h"
|
||||
#include "u_async.h"
|
||||
#include "debug_config.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
static struct ble_transport* transport;
|
||||
static ble_link_id session;
|
||||
static unsigned sent, received_count, blocked;
|
||||
static int failed, dial, seen, radio_ready;
|
||||
static uint64_t peer, expected_peer, started, verified_bytes, retry;
|
||||
static const unsigned lengths[] = {1,13,14,20,244,512,1499,1500};
|
||||
static unsigned length(unsigned n) { return lengths[n % 8]; }
|
||||
static uint8_t probe_byte(unsigned n, unsigned i) { return (uint8_t)(n + i * 37); }
|
||||
static void send_more(void* arg, ble_link_id id) {
|
||||
(void)arg;
|
||||
while (sent < 96) {
|
||||
struct ll_entry* e = ll_alloc_lldgram(length(sent));
|
||||
if (!e) { failed = 1; return; }
|
||||
e->len = length(sent);
|
||||
for (unsigned i = 0; i < e->len; i++) e->dgram[i] = probe_byte(sent, i);
|
||||
int result = ble_tx_put(transport, id, e);
|
||||
if (result != BLE_OK) {
|
||||
queue_dgram_free(e); queue_entry_free(e);
|
||||
if (result == BLE_WOULD_BLOCK) blocked++; else failed = 1;
|
||||
return;
|
||||
}
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
static void received(void* arg, ble_link_id id) {
|
||||
(void)arg; struct ll_entry* e;
|
||||
while ((e = ble_rx_get(transport, id))) {
|
||||
unsigned n = received_count;
|
||||
if (n >= 96 || e->len != length(n)) failed = 1;
|
||||
for (unsigned i = 0; i < e->len; i++) if (e->dgram[i] != probe_byte(n, i)) failed = 1;
|
||||
printf("VERIFY packet=%u bytes=%u ok=%d\n", n, e->len, !failed); fflush(stdout);
|
||||
verified_bytes += e->len; received_count++; queue_dgram_free(e); queue_entry_free(e);
|
||||
}
|
||||
}
|
||||
static void connected(void* arg, ble_link_id id, uint64_t node, enum ble_state state, const char* reason) {
|
||||
(void)arg;
|
||||
printf("CONNECTION link=%llu peer=%016llx state=%d reason=%s\n", (unsigned long long)id, (unsigned long long)node, state, reason);
|
||||
if (state == BLE_READY) { session = id; peer = node; started = get_time_us(); send_more(NULL, id); }
|
||||
if (state == BLE_CLOSED && received_count < 96) {
|
||||
if (started) failed = 1;
|
||||
else {
|
||||
session = 0; retry = get_time_us() + 1000000;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE probe closed before transfer; retrying discovery");
|
||||
}
|
||||
}
|
||||
}
|
||||
static void status(void* arg, enum ble_state state, const char* reason) {
|
||||
(void)arg; printf("STATUS state=%d reason=%s\n", state, reason);
|
||||
radio_ready = state == BLE_READY;
|
||||
if (state == BLE_READY) {
|
||||
uint8_t data[24]; int n = ble_identity_encode(data, 0x5643424c45544553ULL, "Desktop-test");
|
||||
if (ble_advertise(transport, data, n) != BLE_OK) failed = 1;
|
||||
}
|
||||
}
|
||||
static void broadcast(void* arg, const struct ble_endpoint* ep, const uint8_t* data, size_t len, int rssi) {
|
||||
(void)arg; struct ble_identity identity;
|
||||
if (ble_identity_decode(&identity, data, len) < 0) return;
|
||||
if (expected_peer && identity.node_id != expected_peer) return;
|
||||
if (!seen) printf("DISCOVER node=%016llx name=%s rssi=%d endpoint=%s\n",
|
||||
(unsigned long long)identity.node_id, identity.name, rssi, ep->id);
|
||||
seen = 1;
|
||||
if (dial && radio_ready && !session && get_time_us() >= retry) {
|
||||
session = ble_connect(transport, ep, identity.node_id);
|
||||
if (!session) { DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "BLE probe connect refused; retrying discovery"); retry = get_time_us() + 1000000; }
|
||||
}
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
dial = argc < 2 || !strcmp(argv[1], "connect");
|
||||
int seconds = argc > 2 ? atoi(argv[2]) : 90;
|
||||
if (argc > 3) {
|
||||
char* end; errno = 0; expected_peer = strtoull(argv[3], &end, 16);
|
||||
if (errno || !expected_peer || !argv[3][0] || *end) { fprintf(stderr, "Invalid peer_hex\n"); return 2; }
|
||||
}
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_set_category_level(DEBUG_CATEGORY_DISCOVERY, DEBUG_LEVEL_DEBUG);
|
||||
if (getenv("UTUN_TEST_DEBUG")) debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_TRACE);
|
||||
struct UASYNC* ua = uasync_create(); struct ble_config cfg = {0};
|
||||
cfg.node_id = 0x5643424c45544553ULL; cfg.company_id = 0xffff;
|
||||
cfg.callbacks.status = status; cfg.callbacks.broadcast = broadcast; cfg.callbacks.connection = connected;
|
||||
cfg.callbacks.received = received; cfg.callbacks.writable = send_more;
|
||||
transport = ble_create(ua, &cfg);
|
||||
if (!transport || ble_start(transport) != BLE_OK) failed = 1;
|
||||
uint64_t end = get_time_us() + (uint64_t)seconds * 1000000;
|
||||
while (!failed && received_count < 96 && get_time_us() < end) uasync_poll(ua, 100);
|
||||
int ok = !failed && received_count == 96 && seen && blocked;
|
||||
printf("RESULT ok=%d sent=%u verified=%u blocked=%u discovered=%d peer=%016llx\n",
|
||||
ok, sent, received_count, blocked, seen, (unsigned long long)peer);
|
||||
if (started) {
|
||||
double seconds = (get_time_us() - started) / 1000000.0;
|
||||
printf("THROUGHPUT verified_bytes_each_direction=%llu seconds=%.3f aggregate_bytes_per_second=%.0f\n",
|
||||
(unsigned long long)verified_bytes, seconds, seconds > 0 ? verified_bytes * 2 / seconds : 0);
|
||||
}
|
||||
ble_destroy(transport); uasync_destroy(ua, 0); return !ok;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Реальное радио, запуск вручную: ble_probe [seconds]. Публикует только тестовое объявление. */
|
||||
#include "ble_transport.h"
|
||||
#include "u_async.h"
|
||||
#include "debug_config.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
static int ready, failed, published, changed, republished;
|
||||
static enum ble_state advertising = BLE_STARTING;
|
||||
static struct ble_transport* transport;
|
||||
static void status(void* arg, enum ble_state state, const char* reason) {
|
||||
(void)arg; DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE probe status=%d reason=%s", state, reason);
|
||||
if (state == BLE_READY) { ready = 1; if (ble_advertise(transport, "vibechat-probe", 14) != BLE_OK) failed = 1; }
|
||||
if (state == BLE_CLOSED && strcmp(reason, "stopped")) failed = 1;
|
||||
}
|
||||
static void advertising_status(void* arg, enum ble_state state, const char* reason) {
|
||||
(void)arg; advertising = state;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE probe advertising=%d updated=%d reason=%s", state, changed, reason);
|
||||
if (state == BLE_READY) { if (changed) republished = 1; else published = 1; }
|
||||
}
|
||||
static void broadcast(void* arg, const struct ble_endpoint* ep, const uint8_t* data, size_t len, int rssi) {
|
||||
(void)arg; (void)data;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "BLE probe broadcast endpoint=%s bytes=%zu rssi=%d", ep->id, len, rssi);
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_set_category_level(DEBUG_CATEGORY_DISCOVERY, DEBUG_LEVEL_DEBUG);
|
||||
int seconds = argc > 1 ? atoi(argv[1]) : 5;
|
||||
if (seconds < 1 || seconds > 60) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "BLE probe seconds must be 1..60"); return 2; }
|
||||
struct UASYNC* ua = uasync_create();
|
||||
if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "BLE probe cannot create uasync"); return 1; }
|
||||
struct ble_config cfg = {0}; cfg.node_id = 0x5643424c45544553ULL; cfg.company_id = 0xffff;
|
||||
cfg.callbacks.status = status; cfg.callbacks.broadcast = broadcast;
|
||||
cfg.callbacks.advertising = advertising_status;
|
||||
transport = ble_create(ua, &cfg);
|
||||
if (!transport || ble_start(transport) != BLE_OK) failed = 1;
|
||||
uint64_t start = get_time_us(), end = start + (uint64_t)seconds * 1000000;
|
||||
while (!failed && get_time_us() < end) {
|
||||
uasync_poll(ua, 100);
|
||||
if (published && !changed && get_time_us() >= start + 1000000) {
|
||||
changed = 1; if (ble_advertise(transport, "vibechat-updated", 16) != BLE_OK) failed = 1;
|
||||
}
|
||||
}
|
||||
int ok = ready && published && republished && advertising == BLE_READY && !failed;
|
||||
if (ok) DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE probe PASS: radio ready, advertisement published and updated");
|
||||
else DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "BLE probe FAIL: ready=%d published=%d updated=%d advertising=%d failed=%d seconds=%d",
|
||||
ready, published, republished, advertising, failed, seconds);
|
||||
ble_destroy(transport); uasync_destroy(ua, 0);
|
||||
return !ok;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/* Private D-Bus + настоящий backend: DATA без ATT ACK, адресные INVITE/ACK indications, два клиента. */
|
||||
#include "ble_backend.h"
|
||||
#include "u_async.h"
|
||||
#include "mem.h"
|
||||
#include "debug_config.h"
|
||||
#include <systemd/sd-bus.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#define CHECK(v) do { if (!(v)) { fprintf(stderr, "FAIL line=%d: %s errno=%d\n", __LINE__, #v, errno); exit(1); } } while (0)
|
||||
#define ROOT "/org/vibechat/ble"
|
||||
#define ADAPTER "/org/bluez/hci0"
|
||||
#define REMOTE ADAPTER "/dev_C"
|
||||
static struct UASYNC* ua;
|
||||
static sd_bus* bus;
|
||||
static struct ble_transport* transport;
|
||||
static char application[256];
|
||||
static int ready, broadcasts, ready_links, advertisement_count;
|
||||
static ble_link_id links[4];
|
||||
static unsigned commands, requests, ack_requests, ack_values;
|
||||
static int ack_fds[2] = {-1, -1};
|
||||
static int remote_resolved;
|
||||
struct reply { sd_bus_slot* slot; int done, fd, failed; };
|
||||
static void poll_all(void) {
|
||||
for (int i = 0; i < 500; i++) {
|
||||
CHECK(sd_bus_process(bus, NULL) >= 0); uasync_poll(ua, 10);
|
||||
for (int j = 0; j < 2; j++) if (ack_fds[j] >= 0) {
|
||||
uint8_t ack[8]; ssize_t n = recv(ack_fds[j], ack, sizeof(ack), MSG_DONTWAIT);
|
||||
if (n < 0) { CHECK(errno == EAGAIN); continue; }
|
||||
CHECK(n == 2); ack_values++; CHECK(send(ack_fds[j], "\1", 1, MSG_NOSIGNAL) == 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
static int register_app(sd_bus_message* m, void* arg, sd_bus_error* e) {
|
||||
(void)arg; (void)e; snprintf(application, sizeof(application), "%s", sd_bus_message_get_sender(m));
|
||||
return sd_bus_reply_method_return(m, "");
|
||||
}
|
||||
static int simple(sd_bus_message* m, void* arg, sd_bus_error* e) { (void)arg; (void)e; return sd_bus_reply_method_return(m, ""); }
|
||||
static int advertise(sd_bus_message* m, void* arg, sd_bus_error* e) {
|
||||
(void)arg; (void)e; advertisement_count++;
|
||||
return sd_bus_reply_method_return(m, "");
|
||||
}
|
||||
static int property(sd_bus* b, const char* p, const char* i, const char* name, sd_bus_message* m, void* arg, sd_bus_error* e) {
|
||||
(void)b; (void)p; (void)i; (void)name; (void)arg; (void)e; return sd_bus_message_append(m, "b", 1);
|
||||
}
|
||||
static const sd_bus_vtable adapter_vtable[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("Powered", "b", property, 0, 0),
|
||||
SD_BUS_METHOD("SetDiscoveryFilter", "a{sv}", "", simple, SD_BUS_VTABLE_UNPRIVILEGED),
|
||||
SD_BUS_METHOD("StartDiscovery", "", "", simple, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static const sd_bus_vtable gatt_vtable[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_METHOD("RegisterApplication", "oa{sv}", "", register_app, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static const sd_bus_vtable adv_vtable[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_METHOD("RegisterAdvertisement", "oa{sv}", "", advertise, SD_BUS_VTABLE_UNPRIVILEGED),
|
||||
SD_BUS_METHOD("UnregisterAdvertisement", "o", "", simple, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static int remote_property(sd_bus* b, const char* path, const char* i, const char* name,
|
||||
sd_bus_message* m, void* arg, sd_bus_error* e) {
|
||||
(void)b; (void)i; (void)arg; (void)e;
|
||||
if (!strcmp(name, "MTU")) return sd_bus_message_append(m, "q", remote_resolved ? 517 : 23);
|
||||
const char* uuid = strstr(path, "/rx") ? BLE_RX_UUID : strstr(path, "/tx") ? BLE_TX_UUID :
|
||||
strstr(path, "/ack") ? BLE_ACK_UUID : BLE_INVITE_UUID;
|
||||
return sd_bus_message_append(m, "s", uuid);
|
||||
}
|
||||
static int remote_write(sd_bus_message* m, void* arg, sd_bus_error* e) {
|
||||
(void)arg; (void)e;
|
||||
const void* data; size_t len; const char *key, *type;
|
||||
CHECK(sd_bus_message_read_array(m, 'y', &data, &len) > 0 && len > 0);
|
||||
CHECK(sd_bus_message_enter_container(m, 'a', "{sv}") > 0);
|
||||
CHECK(sd_bus_message_enter_container(m, 'e', "sv") > 0);
|
||||
CHECK(sd_bus_message_read(m, "s", &key) > 0 && !strcmp(key, "type"));
|
||||
CHECK(sd_bus_message_enter_container(m, 'v', "s") > 0);
|
||||
CHECK(sd_bus_message_read(m, "s", &type) > 0);
|
||||
int invitation = strstr(sd_bus_message_get_path(m), "/invite") != NULL;
|
||||
int ack = strstr(sd_bus_message_get_path(m), "/ack") != NULL;
|
||||
CHECK(!strcmp(type, invitation || ack ? "request" : "command"));
|
||||
if (ack) { CHECK(len == 2); ack_requests++; } else if (invitation) requests++; else commands++;
|
||||
int r = sd_bus_reply_method_return(m, "");
|
||||
if (!invitation && !ack && len == 19 && ((const uint8_t*)data)[2] == 1) {
|
||||
sd_bus_message* signal = NULL; uint8_t accept[19] = {0, 1, 2}; accept[10] = 3; accept[18] = 200;
|
||||
CHECK(sd_bus_message_new_signal(bus, &signal, REMOTE "/tx", "org.freedesktop.DBus.Properties", "PropertiesChanged") >= 0);
|
||||
CHECK(sd_bus_message_append(signal, "sa{sv}as", "org.bluez.GattCharacteristic1", 1, "Value", "ay", 19,
|
||||
0,1,2,0,0,0,0,0,0,0,accept[10],0,0,0,0,0,0,0,accept[18], 0) >= 0);
|
||||
CHECK(sd_bus_send(bus, signal, NULL) >= 0); sd_bus_message_unref(signal);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
static int remote_state(sd_bus* b, const char* path, const char* i, const char* name,
|
||||
sd_bus_message* m, void* arg, sd_bus_error* e) {
|
||||
(void)b; (void)path; (void)i; (void)arg; (void)e;
|
||||
if (!strcmp(name, "ManufacturerData")) return sd_bus_message_append(m, "a{qv}", 1, 0xffff, "ay", 3, 'a', 'b', 'c');
|
||||
return sd_bus_message_append(m, "b", !strcmp(name, "Connected") || remote_resolved);
|
||||
}
|
||||
static const sd_bus_vtable device_vtable[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_METHOD("Connect", "", "", simple, SD_BUS_VTABLE_UNPRIVILEGED),
|
||||
SD_BUS_PROPERTY("Connected", "b", remote_state, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
|
||||
SD_BUS_PROPERTY("ServicesResolved", "b", remote_state, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
|
||||
SD_BUS_PROPERTY("ManufacturerData", "a{qv}", remote_state, 0, 0),
|
||||
SD_BUS_VTABLE_END
|
||||
};
|
||||
static const sd_bus_vtable remote_vtable[] = {
|
||||
SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("UUID", "s", remote_property, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_PROPERTY("MTU", "q", remote_property, 0, SD_BUS_VTABLE_PROPERTY_CONST),
|
||||
SD_BUS_METHOD("StartNotify", "", "", simple, SD_BUS_VTABLE_UNPRIVILEGED),
|
||||
SD_BUS_METHOD("StopNotify", "", "", simple, SD_BUS_VTABLE_UNPRIVILEGED),
|
||||
SD_BUS_METHOD("WriteValue", "aya{sv}", "", remote_write, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END
|
||||
};
|
||||
static void status(void* arg, enum ble_state state, const char* reason) {
|
||||
(void)arg; (void)reason; if (state == BLE_READY) ready++;
|
||||
}
|
||||
static void connection(void* arg, ble_link_id id, uint64_t node, enum ble_state state, const char* reason) {
|
||||
(void)arg; (void)reason;
|
||||
if (state == BLE_READY) { CHECK(node < 4); links[node] = id; ready_links++; }
|
||||
}
|
||||
static void broadcast(void* arg, const struct ble_endpoint* ep, const uint8_t* data, size_t len, int rssi) {
|
||||
(void)arg; CHECK(strstr(ep->id, "dev_A")); CHECK(len == 3 && !memcmp(data, "abc", 3)); CHECK(rssi == -42); broadcasts++;
|
||||
}
|
||||
static int replied(sd_bus_message* m, void* arg, sd_bus_error* error) {
|
||||
(void)error; struct reply* reply = arg;
|
||||
reply->failed = sd_bus_message_is_method_error(m, NULL);
|
||||
if (reply->failed) { const sd_bus_error* e = sd_bus_message_get_error(m); fprintf(stderr, "reply error %s: %s\n", e->name, e->message); }
|
||||
if (!reply->failed && !strcmp(sd_bus_message_get_signature(m, 1), "hq")) {
|
||||
int fd; uint16_t mtu; CHECK(sd_bus_message_read(m, "hq", &fd, &mtu) > 0); CHECK(mtu == 23);
|
||||
reply->fd = fcntl(fd, F_DUPFD_CLOEXEC, 3); CHECK(reply->fd >= 0);
|
||||
}
|
||||
reply->done = 1; return 0;
|
||||
}
|
||||
static int acquire(const char* device, int channel) {
|
||||
struct reply reply = {.fd=-1};
|
||||
const char* path = channel == 2 ? ROOT "/service0/ack" : channel ? ROOT "/service0/invite" : ROOT "/service0/tx";
|
||||
CHECK(sd_bus_call_method_async(bus, &reply.slot, application, path, "org.bluez.GattCharacteristic1",
|
||||
"AcquireNotify", replied, &reply, "a{sv}", 2, "device", "o", device, "mtu", "q", 23) >= 0);
|
||||
poll_all(); CHECK(reply.done && !reply.failed && reply.fd >= 0); sd_bus_slot_unref(reply.slot); return reply.fd;
|
||||
}
|
||||
static void write_value(const char* device, const uint8_t* data, size_t len, int invitation) {
|
||||
struct reply reply = {0}; sd_bus_message* m = NULL;
|
||||
CHECK(sd_bus_message_new_method_call(bus, &m, application, invitation ? ROOT "/service0/invite" : ROOT "/service0/rx", "org.bluez.GattCharacteristic1", "WriteValue") >= 0);
|
||||
CHECK(sd_bus_message_append_array(m, 'y', data, len) >= 0);
|
||||
CHECK(sd_bus_message_append(m, "a{sv}", 2, "device", "o", device, "mtu", "q", 23) >= 0);
|
||||
CHECK(sd_bus_call_async(bus, &reply.slot, m, replied, &reply, 1000000) >= 0); sd_bus_message_unref(m);
|
||||
poll_all(); CHECK(reply.done && !reply.failed); sd_bus_slot_unref(reply.slot);
|
||||
}
|
||||
static void announce(void) {
|
||||
sd_bus_message* m = NULL;
|
||||
CHECK(sd_bus_message_new_signal(bus, &m, ADAPTER "/dev_A", "org.freedesktop.DBus.Properties", "PropertiesChanged") >= 0);
|
||||
CHECK(sd_bus_message_append(m, "sa{sv}as", "org.bluez.Device1", 2,
|
||||
"ManufacturerData", "a{qv}", 1, 0xffff, "ay", 3, 'a', 'b', 'c', "RSSI", "n", -42, 0) >= 0);
|
||||
CHECK(sd_bus_send(bus, m, NULL) >= 0); sd_bus_message_unref(m); poll_all(); CHECK(broadcasts == 1);
|
||||
}
|
||||
static void hello(const char* device, int fd, uint8_t node) {
|
||||
uint8_t data[32] = {0, 1, 1}; data[10] = node; data[18] = 200;
|
||||
write_value(device, data, 19, 0);
|
||||
CHECK(recv(fd, data, sizeof(data), MSG_DONTWAIT) == 19); CHECK(data[2] == 2 && data[10] == 200 && data[18] == node);
|
||||
poll_all(); /* ACCEPT отправляется Notification: подтверждения от клиента нет. */
|
||||
}
|
||||
static struct ll_entry* frame(int value) {
|
||||
struct ll_entry* e = ll_alloc_lldgram(31); CHECK(e); e->len = 31; memset(e->dgram, value, e->len); return e;
|
||||
}
|
||||
int main(void) {
|
||||
CHECK(getenv("DBUS_SESSION_BUS_ADDRESS")); CHECK(!setenv("DBUS_SYSTEM_BUS_ADDRESS", getenv("DBUS_SESSION_BUS_ADDRESS"), 1));
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
|
||||
size_t before = u_get_allocated_count();
|
||||
CHECK(sd_bus_open_user(&bus) >= 0); CHECK(sd_bus_request_name(bus, "org.bluez", 0) >= 0);
|
||||
sd_bus_slot *manager = NULL, *adapter = NULL, *gatt = NULL, *adv = NULL, *remote[5] = {0};
|
||||
CHECK(sd_bus_add_object_manager(bus, &manager, "/") >= 0);
|
||||
CHECK(sd_bus_add_object_vtable(bus, &adapter, ADAPTER, "org.bluez.Adapter1", adapter_vtable, NULL) >= 0);
|
||||
CHECK(sd_bus_add_object_vtable(bus, &gatt, ADAPTER, "org.bluez.GattManager1", gatt_vtable, NULL) >= 0);
|
||||
CHECK(sd_bus_add_object_vtable(bus, &adv, ADAPTER, "org.bluez.LEAdvertisingManager1", adv_vtable, NULL) >= 0);
|
||||
CHECK(sd_bus_add_object_vtable(bus, &remote[0], REMOTE, "org.bluez.Device1", device_vtable, NULL) >= 0);
|
||||
const char* paths[] = {REMOTE "/rx", REMOTE "/tx", REMOTE "/invite", REMOTE "/ack"};
|
||||
for (int i = 0; i < 4; i++)
|
||||
CHECK(sd_bus_add_object_vtable(bus, &remote[i+1], paths[i], "org.bluez.GattCharacteristic1", remote_vtable, NULL) >= 0);
|
||||
ua = uasync_create(); CHECK(ua);
|
||||
struct ble_config config = {0}; config.node_id = 200; config.company_id = 0xffff;
|
||||
config.callbacks.status = status; config.callbacks.connection = connection; config.callbacks.broadcast = broadcast;
|
||||
transport = ble_create(ua, &config); CHECK(transport && !ble_start(transport)); poll_all(); CHECK(ready == 1 && broadcasts == 0);
|
||||
CHECK(!ble_advertise(transport, "abc", 3)); poll_all(); CHECK(advertisement_count == 2);
|
||||
CHECK(!ble_advertise(transport, "def", 3)); poll_all(); CHECK(advertisement_count == 3);
|
||||
announce();
|
||||
int fd1 = acquire(ADAPTER "/dev_A", 0), fd2 = acquire(ADAPTER "/dev_B", 0);
|
||||
int invite1 = acquire(ADAPTER "/dev_A", 1), invite2 = acquire(ADAPTER "/dev_B", 1);
|
||||
CHECK(ready_links == 0); /* Без обязательной ACK-подписки opened не наступает. */
|
||||
ack_fds[0] = acquire(ADAPTER "/dev_A", 2); ack_fds[1] = acquire(ADAPTER "/dev_B", 2);
|
||||
hello(ADAPTER "/dev_A", fd1, 1); hello(ADAPTER "/dev_B", fd2, 2); CHECK(ready_links == 2);
|
||||
CHECK(!ble_tx_put(transport, links[1], frame(0xa1))); CHECK(!ble_tx_put(transport, links[2], frame(0xb2))); poll_all();
|
||||
uint8_t data[64];
|
||||
/* Все DATA-фрагменты обоим клиентам проходят без единого ATT ACK. */
|
||||
for (int offset = 0; offset < 31; offset += 11) {
|
||||
CHECK(recv(fd1, data, sizeof(data), MSG_DONTWAIT) == (offset == 22 ? 18 : 20));
|
||||
CHECK(data[8] == offset && data[9] == 0xa1);
|
||||
CHECK(recv(fd2, data, sizeof(data), MSG_DONTWAIT) == (offset == 22 ? 18 : 20));
|
||||
CHECK(data[8] == offset && data[9] == 0xb2);
|
||||
}
|
||||
CHECK(recv(fd1, data, sizeof(data), MSG_DONTWAIT) < 0 && errno == EAGAIN);
|
||||
CHECK(recv(fd2, data, sizeof(data), MSG_DONTWAIT) < 0 && errno == EAGAIN);
|
||||
uint8_t incoming[] = {0, 2, 3, 0, 1, 0, 3, 0, 0, 0x11, 0x22, 0x33};
|
||||
write_value(ADAPTER "/dev_B", incoming, sizeof(incoming), 0);
|
||||
CHECK(!ble_rx_get(transport, links[1])); struct ll_entry* e = ble_rx_get(transport, links[2]); CHECK(e && e->len == 3);
|
||||
CHECK(!memcmp(e->dgram, incoming + 9, 3)); queue_dgram_free(e); queue_entry_free(e);
|
||||
write_value(ADAPTER "/dev_A", incoming + 2, sizeof(incoming) - 2, 1);
|
||||
CHECK(!ble_rx_get(transport, links[1])); CHECK(!ble_channel_rx_get(transport, links[2], BLE_CHANNEL_INVITE));
|
||||
e = ble_channel_rx_get(transport, links[1], BLE_CHANNEL_INVITE); CHECK(e && e->len == 3);
|
||||
queue_dgram_free(e); queue_entry_free(e);
|
||||
CHECK(!ble_channel_tx_put(transport, links[2], BLE_CHANNEL_INVITE, frame(0xc3))); poll_all();
|
||||
CHECK(recv(invite1, data, sizeof(data), MSG_DONTWAIT) < 0 && errno == EAGAIN);
|
||||
CHECK(recv(fd2, data, sizeof(data), MSG_DONTWAIT) < 0 && errno == EAGAIN);
|
||||
for (int offset = 0; offset < 31; offset += 13) {
|
||||
CHECK(recv(invite2, data, sizeof(data), MSG_DONTWAIT) == (offset == 26 ? 12 : 20));
|
||||
CHECK(data[6] == offset && data[7] == 0xc3);
|
||||
poll_all(); /* INVITE не продвигается без подтверждения; другой клиент свободен. */
|
||||
CHECK(recv(invite2, data, sizeof(data), MSG_DONTWAIT) < 0 && errno == EAGAIN);
|
||||
CHECK(!ble_tx_put(transport, links[1], frame(0xd4))); poll_all();
|
||||
for (int part = 0; part < 31; part += 11) {
|
||||
CHECK(recv(fd1, data, sizeof(data), MSG_DONTWAIT) == (part == 22 ? 18 : 20));
|
||||
CHECK(data[8] == part && data[9] == 0xd4);
|
||||
}
|
||||
CHECK(send(invite2, "\1", 1, MSG_NOSIGNAL) == 1); poll_all();
|
||||
}
|
||||
CHECK(ack_values >= 2);
|
||||
close(ack_fds[0]); close(ack_fds[1]); ack_fds[0] = ack_fds[1] = -1;
|
||||
close(invite1); close(invite2);
|
||||
close(fd1); poll_all(); close(fd2); poll_all();
|
||||
/* Настоящий клиент backend: HELLO/DATA используют command, INVITE использует request. */
|
||||
struct ble_endpoint ep = {REMOTE};
|
||||
CHECK(ble_connect(transport, &ep, 3)); poll_all();
|
||||
/* Кеш UUID доступен сразу, но до ServicesResolved нельзя объявлять GATT готовым с прежним MTU23. */
|
||||
CHECK(ready_links == 2 && commands == 0);
|
||||
remote_resolved = 1;
|
||||
CHECK(sd_bus_emit_properties_changed(bus, REMOTE, "org.bluez.Device1", "ServicesResolved", NULL) >= 0);
|
||||
poll_all(); CHECK(ready_links == 3 && commands == 1);
|
||||
CHECK(!ble_tx_put(transport, links[3], frame(0xe5)));
|
||||
CHECK(!ble_channel_tx_put(transport, links[3], BLE_CHANNEL_INVITE, frame(0xe6)));
|
||||
poll_all(); CHECK(commands == 2 && requests == 1 && ack_requests >= 1); /* MTU517 и ACK Write Request. */
|
||||
ble_destroy(transport); uasync_destroy(ua, 0);
|
||||
for (int i = 0; i < 5; i++) sd_bus_slot_unref(remote[i]);
|
||||
sd_bus_slot_unref(manager); sd_bus_slot_unref(adapter); sd_bus_slot_unref(gatt); sd_bus_slot_unref(adv); sd_bus_close_unref(bus);
|
||||
CHECK(u_get_allocated_count() == before);
|
||||
puts("BlueZ private-bus tests PASS (DATA commands, addressed INVITE/ACK indications, mandatory ACK, two clients, teardown)");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#include "ble_identity.h"
|
||||
#include "debug_config.h"
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
int main(void) {
|
||||
debug_config_init();
|
||||
uint8_t bytes[24]; struct ble_identity peer;
|
||||
int n = ble_identity_encode(bytes, 0x0102030405060708ULL, "АБВГДЕЖ");
|
||||
assert(n == 23 && bytes[0] == 'V' && bytes[1] == 'C' && bytes[2] == BLE_IDENTITY_VERSION && bytes[3] == 1 && bytes[10] == 8);
|
||||
assert(ble_identity_decode(&peer, bytes, n) == 0);
|
||||
assert(peer.node_id == 0x0102030405060708ULL && !strcmp(peer.name, "АБВГДЕ"));
|
||||
assert(ble_identity_encode(bytes, 1, "123456789012😀") == 23);
|
||||
assert(ble_identity_encode(bytes, 1, "1234567890123tail") == 24);
|
||||
assert(ble_identity_decode(&peer, bytes, 24) == 0 && !strcmp(peer.name, "1234567890123"));
|
||||
assert(ble_identity_decode(&peer, bytes, 25) < 0);
|
||||
bytes[2] = 1; assert(ble_identity_decode(&peer, bytes, 24) < 0);
|
||||
bytes[2] = 2; assert(ble_identity_decode(&peer, bytes, 24) < 0);
|
||||
n = ble_identity_encode(bytes, 1, ""); assert(n == 11 && ble_identity_decode(&peer, bytes, n) == 0);
|
||||
bytes[11] = 0xc0; bytes[12] = 0x80; assert(ble_identity_decode(&peer, bytes, 13) < 0);
|
||||
assert(ble_identity_encode(bytes, 0, "name") < 0);
|
||||
assert(ble_identity_encode(bytes, 1, "\xed\xa0\x80") < 0);
|
||||
puts("BLE identity PASS"); return 0;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/* Два/три настоящих common transport с управляемым асинхронным GATT backend: проверяется весь lifecycle. */
|
||||
#include "ble_backend.h"
|
||||
#include "u_async.h"
|
||||
#include "mem.h"
|
||||
#include "debug_config.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define CHECK(v) do { if (!(v)) { fprintf(stderr, "FAIL line=%d: %s\n", __LINE__, #v); exit(1); } } while (0)
|
||||
struct fake_link {
|
||||
ble_link_id id, other; struct ble_backend* peer; uint8_t data[512]; size_t len; int pending;
|
||||
enum ble_wire_channel channel; uint64_t ack_at, complete_at; uint16_t ack_id, ack_delivered; int ack_held, delivered;
|
||||
};
|
||||
struct ble_backend { struct ble_transport* owner; struct UASYNC* ua; void* task; int index, started; struct fake_link links[16]; };
|
||||
static struct ble_backend* backends[3];
|
||||
static unsigned int mtu = 23;
|
||||
static int hold, drop, reject_peer, ready_count, writable_count;
|
||||
static int hold_ack;
|
||||
static unsigned ack_delay_us;
|
||||
static unsigned ack_timeouts;
|
||||
static unsigned closed_count;
|
||||
static uint64_t data_wire_bytes[3];
|
||||
static ble_link_id incoming_ids[3];
|
||||
static void run(void* arg);
|
||||
static void schedule(struct ble_backend* b) { if (!b->task) b->task = uasync_call_soon(b->ua, b, run); CHECK(b->task); }
|
||||
struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* cfg) {
|
||||
struct ble_backend* b = u_calloc(1, sizeof(*b)); CHECK(b); b->owner = owner; b->ua = ua; b->index = (int)cfg->node_id - 1;
|
||||
backends[b->index] = b; schedule(b); return b;
|
||||
}
|
||||
void ble_backend_destroy(struct ble_backend* b) {
|
||||
if (b->task) uasync_call_soon_cancel(b->ua, b->task);
|
||||
backends[b->index] = NULL; u_free(b);
|
||||
}
|
||||
int ble_backend_advertise(struct ble_backend* b, const uint8_t* data, size_t len) { (void)b; (void)data; (void)len; return 0; }
|
||||
static struct fake_link* slot(struct ble_backend* b, ble_link_id id) {
|
||||
for (int i = 0; i < 16; i++) if (b->links[i].id == id) return &b->links[i];
|
||||
return NULL;
|
||||
}
|
||||
int ble_backend_connect(struct ble_backend* b, ble_link_id id, const struct ble_endpoint* ep) {
|
||||
struct ble_backend* p = backends[atoi(ep->id)]; CHECK(p);
|
||||
struct fake_link *a = slot(b, 0), *c = slot(p, 0); CHECK(a && c);
|
||||
struct ble_endpoint remote = {0}; snprintf(remote.id, sizeof(remote.id), "%d", b->index);
|
||||
ble_link_id other = ble_backend_incoming(p->owner, &remote); CHECK(other);
|
||||
*a = (struct fake_link){.id=id, .other=other, .peer=p};
|
||||
*c = (struct fake_link){.id=other, .other=id, .peer=b};
|
||||
incoming_ids[p->index] = other;
|
||||
schedule(b); schedule(p); return 0;
|
||||
}
|
||||
void ble_backend_close(struct ble_backend* b, ble_link_id id) {
|
||||
struct fake_link* f = slot(b, id); if (f) memset(f, 0, sizeof(*f));
|
||||
}
|
||||
int ble_backend_send(struct ble_backend* b, ble_link_id id, enum ble_wire_channel channel, const uint8_t* data, size_t len) {
|
||||
struct fake_link* f = slot(b, id); CHECK(f && !f->pending && len <= sizeof(f->data));
|
||||
CHECK(len <= mtu - 3);
|
||||
if (channel == BLE_WIRE_DATA) data_wire_bytes[b->index] += len;
|
||||
if (channel == BLE_WIRE_ACK) {
|
||||
CHECK(len == 2);
|
||||
uint64_t now = get_time_us();
|
||||
CHECK(!f->ack_at || now - f->ack_at >= BLE_ACK_INTERVAL_US);
|
||||
f->ack_at = now; f->ack_id = (uint16_t)((data[0] << 8) | data[1]);
|
||||
}
|
||||
f->channel = channel; memcpy(f->data, data, len); f->len = len; f->pending = 1; f->delivered = 0;
|
||||
f->complete_at = channel == BLE_WIRE_ACK ? get_time_us() + ack_delay_us : 0;
|
||||
schedule(b); return 0;
|
||||
}
|
||||
static void run(void* arg) {
|
||||
struct ble_backend* b = arg; b->task = NULL;
|
||||
if (!b->started) { b->started = 1; ble_backend_status(b->owner, 1, "fake ready"); }
|
||||
for (int i = 0; i < 16; i++) {
|
||||
struct fake_link* f = &b->links[i]; if (!f->id) continue;
|
||||
if (!hold_ack && f->ack_held) {
|
||||
uint8_t ack[2] = {f->ack_id >> 8, f->ack_id}; f->ack_held = 0; f->ack_delivered = f->ack_id;
|
||||
if (f->peer && slot(f->peer, f->other)) ble_backend_received(f->peer->owner, f->other, BLE_WIRE_ACK, ack, 2);
|
||||
}
|
||||
if (!f->len) { f->len = 1; ble_backend_opened(b->owner, f->id, mtu); }
|
||||
if (!f->pending || hold) continue;
|
||||
struct fake_link* target = f->peer ? slot(f->peer, f->other) : NULL;
|
||||
if (target && !target->len) { schedule(b); continue; } /* Удалённая подписка должна завершиться до доставки HELLO. */
|
||||
ble_link_id id = f->id, other = f->other;
|
||||
struct ble_backend* peer = f->peer;
|
||||
if (!f->delivered) {
|
||||
uint8_t data[512]; size_t len = f->len; memcpy(data, f->data, len); f->delivered = 1;
|
||||
int dropped = f->channel != BLE_WIRE_ACK && data[f->channel == BLE_WIRE_DATA ? 2 : 0] == 3 && drop && --drop == 0;
|
||||
if (hold_ack && f->channel == BLE_WIRE_ACK) f->ack_held = 1;
|
||||
else if (!dropped && peer && slot(peer, other)) {
|
||||
if (f->channel == BLE_WIRE_ACK) f->ack_delivered = f->ack_id;
|
||||
ble_backend_received(peer->owner, other, f->channel, data, len);
|
||||
}
|
||||
}
|
||||
if (slot(b, id)) {
|
||||
if (get_time_us() < f->complete_at) { schedule(b); continue; }
|
||||
f->pending = 0; ble_backend_sent(b->owner, id, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
static int validate(void* arg, ble_link_id id, uint64_t node) { (void)arg; (void)id; (void)node; return !reject_peer; }
|
||||
static void connection(void* arg, ble_link_id id, uint64_t node, enum ble_state s, const char* reason) {
|
||||
(void)arg; (void)id; (void)node; if (s == BLE_READY) ready_count++;
|
||||
if (s == BLE_CLOSED) closed_count++;
|
||||
if (s == BLE_CLOSED && !strcmp(reason, "ACK progress timeout")) ack_timeouts++;
|
||||
}
|
||||
static void writable(void* arg, ble_link_id id) { (void)arg; (void)id; writable_count++; }
|
||||
static void drain(struct UASYNC* ua) {
|
||||
uint64_t end = get_time_us() + 2 * BLE_ACK_INTERVAL_US;
|
||||
do { uasync_poll(ua, 10); } while (get_time_us() < end);
|
||||
}
|
||||
static struct ll_entry* frame(unsigned int size, uint8_t value) {
|
||||
struct ll_entry* e = ll_alloc_lldgram(size); CHECK(e); e->len = size; memset(e->dgram, value, size); return e;
|
||||
}
|
||||
static void release(struct ll_entry* e) { queue_dgram_free(e); queue_entry_free(e); }
|
||||
/* Непрерывный RX при ACK completion >15ms не должен вытеснять встречный DATA. */
|
||||
static void exercise_ack_fairness(void) {
|
||||
mtu = 517; hold = drop = reject_peer = hold_ack = 0; ack_delay_us = 0;
|
||||
struct UASYNC* ua = uasync_create(); CHECK(ua);
|
||||
struct ble_config cfg = {.node_id = 1}; struct ble_transport* a = ble_create(ua, &cfg);
|
||||
cfg.node_id = 2; struct ble_transport* b = ble_create(ua, &cfg); CHECK(a && b && !ble_start(a) && !ble_start(b)); drain(ua);
|
||||
struct ble_endpoint ep = {"1"}; ble_link_id id = ble_connect(a, &ep, 2); CHECK(id); drain(ua);
|
||||
ble_link_id other = incoming_ids[1]; ack_delay_us = 20000;
|
||||
int queued = 0, replied = 0; unsigned received = 0;
|
||||
uint64_t started = get_time_us();
|
||||
while (!replied && get_time_us() - started < 250000) {
|
||||
while (ble_tx_ready(a, id)) CHECK(!ble_tx_put(a, id, frame(500, 0x31)));
|
||||
uasync_poll(ua, 0);
|
||||
struct fake_link* receiver = slot(backends[1], other); CHECK(receiver);
|
||||
if (!queued && receiver->pending && receiver->channel == BLE_WIRE_ACK) {
|
||||
CHECK(!ble_tx_put(b, other, frame(1, 0xa7))); queued = 1;
|
||||
}
|
||||
struct ll_entry* e;
|
||||
while ((e = ble_rx_get(b, other))) { received++; release(e); }
|
||||
while ((e = ble_rx_get(a, id))) { CHECK(e->len == 1 && e->dgram[0] == 0xa7); replied++; release(e); }
|
||||
}
|
||||
CHECK(queued && replied == 1 && received > 1);
|
||||
printf("BLE ACK fairness PASS (completion=20ms, continuous RX, DATA returned in %lluus)\n",
|
||||
(unsigned long long)(get_time_us() - started));
|
||||
ble_destroy(a); ble_destroy(b); uasync_destroy(ua, 0); ack_delay_us = 0;
|
||||
}
|
||||
static void exercise(unsigned int test_mtu) {
|
||||
mtu = test_mtu; hold = drop = reject_peer = ready_count = writable_count = 0;
|
||||
struct UASYNC* ua = uasync_create(); CHECK(ua);
|
||||
struct ble_transport* t[3];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
struct ble_config cfg = {0}; cfg.node_id = i + 1; cfg.company_id = 0xffff;
|
||||
cfg.callbacks.validate = validate; cfg.callbacks.connection = connection; cfg.callbacks.writable = writable;
|
||||
t[i] = ble_create(ua, &cfg); CHECK(t[i] && !ble_start(t[i]));
|
||||
}
|
||||
drain(ua);
|
||||
struct ble_endpoint ep = {"1"};
|
||||
ble_link_id a = ble_connect(t[0], &ep, 2); CHECK(a); drain(ua); CHECK(ready_count == 2);
|
||||
ble_link_id b = incoming_ids[1];
|
||||
CHECK(!ble_tx_put(t[0], a, frame(1500, 0x31))); CHECK(!ble_tx_put(t[1], b, frame(1499, 0x71))); drain(ua);
|
||||
struct ll_entry* e = ble_rx_get(t[1], b); CHECK(e && e->len == 1500);
|
||||
for (int i = 0; i < e->len; i++) CHECK(e->dgram[i] == 0x31);
|
||||
release(e);
|
||||
e = ble_rx_get(t[0], a); CHECK(e && e->len == 1499 && e->dgram[1498] == 0x71); release(e);
|
||||
/* Два независимых канала одновременно, включая отказ TX и потерю фрагмента только INVITE. */
|
||||
CHECK(ble_channel_tx_ready(t[0], a, BLE_CHANNEL_INVITE));
|
||||
CHECK(!ble_tx_put(t[0], a, frame(1500, 0x61)));
|
||||
CHECK(!ble_channel_tx_put(t[0], a, BLE_CHANNEL_INVITE, frame(1100, 0x62)));
|
||||
e = frame(1, 0x63);
|
||||
CHECK(ble_channel_tx_put(t[0], a, BLE_CHANNEL_INVITE, e) == BLE_WOULD_BLOCK); release(e);
|
||||
CHECK(!ble_channel_tx_put(t[1], b, BLE_CHANNEL_INVITE, frame(1100, 0x64)));
|
||||
drain(ua);
|
||||
e = ble_rx_get(t[1], b); CHECK(e && e->len == 1500 && e->dgram[1499] == 0x61); release(e);
|
||||
e = ble_channel_rx_get(t[1], b, BLE_CHANNEL_INVITE); CHECK(e && e->len == 1100 && e->dgram[1099] == 0x62); release(e);
|
||||
e = ble_channel_rx_get(t[0], a, BLE_CHANNEL_INVITE); CHECK(e && e->len == 1100 && e->dgram[1099] == 0x64); release(e);
|
||||
CHECK(!ble_rx_get(t[0], a) && !ble_rx_get(t[1], b));
|
||||
drop = 2; CHECK(!ble_channel_tx_put(t[0], a, BLE_CHANNEL_INVITE, frame(1100, 0x65))); drain(ua);
|
||||
CHECK(!ble_channel_rx_get(t[1], b, BLE_CHANNEL_INVITE));
|
||||
CHECK(!ble_channel_tx_put(t[0], a, BLE_CHANNEL_INVITE, frame(20, 0x66))); drain(ua);
|
||||
e = ble_channel_rx_get(t[1], b, BLE_CHANNEL_INVITE); CHECK(e && e->len == 20); release(e);
|
||||
drop = 2; CHECK(!ble_tx_put(t[0], a, frame(1500, 0x44))); drain(ua); CHECK(!ble_rx_get(t[1], b));
|
||||
CHECK(!ble_tx_put(t[0], a, frame(5, 0x55))); drain(ua); e = ble_rx_get(t[1], b); CHECK(e && e->len == 5); release(e);
|
||||
unsigned before_writable = writable_count;
|
||||
hold = 1;
|
||||
for (int i = 0; i < BLE_QUEUE_MAX; i++) CHECK(!ble_tx_put(t[0], a, frame(1, i)));
|
||||
e = frame(1, 99); CHECK(ble_tx_put(t[0], a, e) == BLE_WOULD_BLOCK); CHECK(!e->owner && e->dgram[0] == 99); release(e);
|
||||
hold = 0; schedule(backends[0]); drain(ua); CHECK(writable_count > before_writable);
|
||||
for (int i = 0; i < BLE_QUEUE_MAX; i++) { e = ble_rx_get(t[1], b); CHECK(e && e->dgram[0] == i); release(e); }
|
||||
CHECK(!ble_rx_get(t[1], b));
|
||||
for (int i = 0; i < BLE_QUEUE_MAX + 1; i++) { CHECK(!ble_tx_put(t[0], a, frame(2, i))); drain(ua); }
|
||||
for (int i = 0; i < BLE_QUEUE_MAX; i++) { e = ble_rx_get(t[1], b); CHECK(e && e->dgram[0] == i); release(e); }
|
||||
CHECK(!ble_rx_get(t[1], b));
|
||||
strcpy(ep.id, "2"); ble_link_id c = ble_connect(t[0], &ep, 3); CHECK(c); drain(ua); CHECK(ready_count == 4);
|
||||
hold = 1; CHECK(!ble_tx_put(t[0], a, frame(1500, 8))); CHECK(!ble_tx_put(t[0], c, frame(1500, 9)));
|
||||
ble_disconnect(t[0], a); hold = 0; schedule(backends[0]); drain(ua);
|
||||
e = ble_rx_get(t[2], incoming_ids[2]); CHECK(e && e->len == 1500 && e->dgram[1499] == 9); release(e);
|
||||
e = frame(1, 0); CHECK(ble_tx_put(t[0], a, e) == BLE_NOT_READY); release(e);
|
||||
/* Отказ политики на третьем независимом направлении не затрагивает существующее соединение. */
|
||||
reject_peer = 1; strcpy(ep.id, "2"); ble_link_id rejected = ble_connect(t[1], &ep, 3); CHECK(rejected); drain(ua);
|
||||
CHECK(ready_count == 4);
|
||||
e = frame(1, 0); CHECK(ble_tx_put(t[1], rejected, e) == BLE_NOT_READY); release(e);
|
||||
ble_set_incoming(t[2], 0);
|
||||
struct ble_endpoint denied = {"policy-test"};
|
||||
CHECK(!ble_backend_incoming(t[2], &denied));
|
||||
CHECK(!ble_rx_get(t[2], incoming_ids[2]));
|
||||
ble_set_incoming(t[2], 1);
|
||||
ble_link_id early = ble_backend_incoming(t[2], &denied); CHECK(early);
|
||||
uint8_t early_hello[19] = {0, 1, 1}; early_hello[10] = 1; early_hello[18] = 3;
|
||||
unsigned before_closed = closed_count;
|
||||
ble_backend_received(t[2], early, BLE_WIRE_DATA, early_hello, sizeof(early_hello));
|
||||
CHECK(closed_count == before_closed + 1 && ready_count == 4);
|
||||
for (int i = 0; i < 3; i++) ble_destroy(t[i]);
|
||||
drain(ua); uasync_destroy(ua, 0);
|
||||
}
|
||||
/* Локальный completion мгновенный, удалённые ACK задержаны: предел относится именно к очереди ниже backend. */
|
||||
static void exercise_flow(void) {
|
||||
mtu = 517; hold = drop = reject_peer = hold_ack = 0;
|
||||
struct UASYNC* ua = uasync_create(); CHECK(ua);
|
||||
struct ble_config cfg = {.node_id = 1}; cfg.callbacks.writable = writable; cfg.callbacks.connection = connection;
|
||||
struct ble_transport* a = ble_create(ua, &cfg); cfg.node_id = 2;
|
||||
struct ble_transport* b = ble_create(ua, &cfg); CHECK(a && b && !ble_start(a) && !ble_start(b)); drain(ua);
|
||||
struct ble_endpoint ep = {"1"}; ble_link_id id = ble_connect(a, &ep, 2); CHECK(id); drain(ua);
|
||||
ble_link_id other = incoming_ids[1];
|
||||
uint64_t base[2] = {data_wire_bytes[0], data_wire_bytes[1]};
|
||||
unsigned received[2] = {0}; hold_ack = 1;
|
||||
for (unsigned i = 0; i < BLE_QUEUE_MAX; i++) {
|
||||
CHECK(!ble_tx_put(a, id, frame(1500, i))); CHECK(!ble_tx_put(b, other, frame(1500, i)));
|
||||
}
|
||||
for (unsigned i = 0; i < 10000; i++) uasync_poll(ua, 0);
|
||||
for (unsigned i = 0; i < 2; i++) {
|
||||
CHECK(data_wire_bytes[i] - base[i] <= BLE_TX_WINDOW_BYTES);
|
||||
CHECK(data_wire_bytes[i] - base[i] >= BLE_TX_WINDOW_BYTES - BLE_VALUE_MAX);
|
||||
}
|
||||
CHECK(!ble_tx_ready(a, id) && !ble_tx_ready(b, other));
|
||||
uint64_t paused = data_wire_bytes[0];
|
||||
struct fake_link* receiver = slot(backends[1], other); CHECK(receiver);
|
||||
uint8_t duplicate[2] = {receiver->ack_delivered >> 8, receiver->ack_delivered};
|
||||
/* Повтор уже доставленного ACK не подтверждает новые данные даже при зависшем ACK в backend. */
|
||||
ble_backend_received(a, id, BLE_WIRE_ACK, duplicate, 2);
|
||||
for (unsigned i = 0; i < 1000; i++) uasync_poll(ua, 0);
|
||||
CHECK(data_wire_bytes[0] == paused && !ble_tx_ready(a, id));
|
||||
hold_ack = 0; schedule(backends[0]); schedule(backends[1]);
|
||||
uint64_t deadline = get_time_us() + 5000000;
|
||||
while ((received[0] < BLE_QUEUE_MAX || received[1] < BLE_QUEUE_MAX) && get_time_us() < deadline) {
|
||||
uasync_poll(ua, 10);
|
||||
for (unsigned i = 0; i < 2; i++) {
|
||||
struct ll_entry* e;
|
||||
while ((e = ble_rx_get(i ? b : a, i ? other : id))) {
|
||||
CHECK(e->len == 1500 && e->dgram[0] == received[i] && e->dgram[1499] == received[i]);
|
||||
received[i]++; release(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
CHECK(received[0] == BLE_QUEUE_MAX && received[1] == BLE_QUEUE_MAX); drain(ua);
|
||||
/* Больше полного оборота wire ID; новые данные действительно приходят, старые ACK не открывают окно. */
|
||||
unsigned sent = 0, delivered = 0; deadline = get_time_us() + 10000000;
|
||||
while (delivered < 70000 && get_time_us() < deadline) {
|
||||
while (sent < 70000 && ble_tx_ready(a, id)) { CHECK(!ble_tx_put(a, id, frame(1, sent))); sent++; }
|
||||
uasync_poll(ua, 0);
|
||||
struct ll_entry* e;
|
||||
while ((e = ble_rx_get(b, other))) { CHECK(e->len == 1 && e->dgram[0] == (uint8_t)delivered); delivered++; release(e); }
|
||||
}
|
||||
CHECK(sent == 70000 && delivered == sent); drain(ua);
|
||||
struct fake_link* sender = slot(backends[0], id); CHECK(sender);
|
||||
uint8_t future[2] = {(uint8_t)((receiver->ack_id + 1) >> 8), (uint8_t)(receiver->ack_id + 1)};
|
||||
ble_backend_received(a, id, BLE_WIRE_ACK, future, 2); CHECK(!ble_tx_ready(a, id));
|
||||
/* Локальный send завершается, но peer исчез: независимый от pending watchdog закрывает линк через 15s. */
|
||||
ack_timeouts = 0;
|
||||
uint64_t started = get_time_us(); CHECK(!ble_tx_put(b, other, frame(1, 0)));
|
||||
while (!ack_timeouts && get_time_us() - started < 17000000) uasync_poll(ua, 1000);
|
||||
CHECK(ack_timeouts == 1 && get_time_us() - started >= 15000000 && !ble_tx_ready(b, other));
|
||||
ble_destroy(a); ble_destroy(b); uasync_destroy(ua, 0);
|
||||
puts("BLE flow PASS (12500-byte duplex bound, ACK >=15ms, duplicate ACK, resume, 70000 IDs, invalid ACK, timeout, teardown)");
|
||||
}
|
||||
int main(void) {
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
|
||||
size_t before = u_get_allocated_count();
|
||||
exercise_ack_fairness(); CHECK(u_get_allocated_count() == before);
|
||||
exercise(23); exercise(247); exercise(517);
|
||||
exercise_flow();
|
||||
CHECK(u_get_allocated_count() == before); puts("BLE transport tests PASS (MTU 23/247/517, duplex, loss, backpressure, peers, teardown)"); return 0;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* Настоящий WinRT mailbox без радио: исключения, изоляция link и отмена поздних callbacks.
|
||||
* Включаем backend для доступа к внутренним событиям; общий transport заменён наблюдателем. */
|
||||
#include "../ble_winrt.cpp"
|
||||
#include <cstdlib>
|
||||
|
||||
#define CHECK(value) do { if (!(value)) { DEBUG_ERROR(CAT, "CHECK failed line=%d: %s", __LINE__, #value); std::abort(); } } while (0)
|
||||
|
||||
struct ble_transport {
|
||||
WinState* state = nullptr;
|
||||
unsigned radio_failures = 0, link_failures = 0;
|
||||
ble_link_id closed = 0;
|
||||
std::string reason;
|
||||
};
|
||||
extern "C" void ble_backend_closed(ble_transport* t, ble_link_id id, const char* reason) {
|
||||
++t->link_failures; t->closed = id; t->reason = reason; t->state->close(id);
|
||||
}
|
||||
extern "C" void ble_backend_status(ble_transport* t, int ready, const char* reason) {
|
||||
CHECK(!ready); ++t->radio_failures; t->reason = reason;
|
||||
while (!t->state->links.empty()) t->state->close(t->state->links.begin()->first);
|
||||
}
|
||||
/* Эти события не должны возникать в проверке mailbox. */
|
||||
extern "C" ble_link_id ble_backend_incoming(ble_transport*, const ble_endpoint*) { CHECK(false); return 0; }
|
||||
extern "C" void ble_backend_advertising(ble_transport*, ble_state, const char*) { CHECK(false); }
|
||||
extern "C" void ble_backend_broadcast(ble_transport*, const ble_endpoint*, const uint8_t*, size_t, int) { CHECK(false); }
|
||||
extern "C" void ble_backend_opened(ble_transport*, ble_link_id, unsigned) { CHECK(false); }
|
||||
extern "C" void ble_backend_received(ble_transport*, ble_link_id, ble_wire_channel, const uint8_t*, size_t) { CHECK(false); }
|
||||
extern "C" void ble_backend_sent(ble_transport*, ble_link_id, int) { CHECK(false); }
|
||||
|
||||
static std::shared_ptr<WinState> make_state(UASYNC* ua, ble_transport& owner) {
|
||||
auto s = std::make_shared<WinState>(); s->ua = ua; s->owner = &owner; owner.state = s.get();
|
||||
for (ble_link_id id : {7, 9}) {
|
||||
auto l = std::make_unique<WinLink>(); l->id = id; s->links.emplace(id, std::move(l));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Ошибка GATT одного пира не переводит radio в failed и не теряет событие другого пира. */
|
||||
static void link_exception(UASYNC* ua) {
|
||||
ble_transport owner;
|
||||
auto s = make_state(ua, owner);
|
||||
bool other_processed = false;
|
||||
s->post("test.ValueChanged", 7, [](WinState& state) {
|
||||
state.step("test.CharacteristicValue", 7);
|
||||
throw hresult_error(E_NOINTERFACE, L"test interface absent");
|
||||
});
|
||||
s->post("test.other_peer", 9, [&other_processed](WinState&) { other_processed = true; });
|
||||
uasync_poll(ua, 0);
|
||||
CHECK(owner.link_failures == 1 && owner.closed == 7 && owner.radio_failures == 0);
|
||||
CHECK(!s->failed && !s->link(7) && s->link(9) && other_processed);
|
||||
CHECK(owner.reason.find("phase=test.CharacteristicValue link=7 HRESULT=0x80004002") != std::string::npos);
|
||||
s->shutdown();
|
||||
DEBUG_INFO(CAT, "WinRT test PASS: link exception preserves other peer and exact HRESULT context");
|
||||
}
|
||||
|
||||
/* Общая ошибка сохраняет точную операцию даже при выключенном TRACE и останавливает batch. */
|
||||
static void radio_exception(UASYNC* ua) {
|
||||
ble_transport owner;
|
||||
auto s = make_state(ua, owner);
|
||||
bool processed = false;
|
||||
s->post("test.Received", 0, [](WinState& state) {
|
||||
state.step("test.BluetoothAddressType");
|
||||
throw hresult_error(E_NOINTERFACE, L"test interface absent");
|
||||
});
|
||||
s->post("test.after_failure", 9, [&processed](WinState&) { processed = true; });
|
||||
uasync_poll(ua, 0);
|
||||
CHECK(owner.radio_failures == 1 && owner.link_failures == 0 && s->failed);
|
||||
CHECK(s->links.empty() && !processed);
|
||||
CHECK(owner.reason.find("phase=test.BluetoothAddressType link=0 HRESULT=0x80004002") != std::string::npos);
|
||||
s->shutdown();
|
||||
DEBUG_INFO(CAT, "WinRT test PASS: radio exception stops queued delivery with TRACE disabled");
|
||||
}
|
||||
|
||||
/* shutdown отменяет posted_task, освобождает captures и отвергает позднее событие Windows. */
|
||||
static void pending_shutdown(UASYNC* ua) {
|
||||
ble_transport owner;
|
||||
auto s = make_state(ua, owner);
|
||||
auto payload = std::make_shared<int>(0);
|
||||
std::weak_ptr<int> weak_payload = payload;
|
||||
bool processed = false;
|
||||
s->post("test.pending", 9, [payload, &processed](WinState&) { processed = true; });
|
||||
payload.reset(); CHECK(!weak_payload.expired());
|
||||
s->shutdown(); CHECK(weak_payload.expired());
|
||||
s->post("test.late", 9, [&processed](WinState&) { processed = true; });
|
||||
CHECK(s->events.empty() && !s->posted);
|
||||
std::weak_ptr<WinState> weak = s; s.reset();
|
||||
uasync_poll(ua, 0);
|
||||
CHECK(weak.expired() && !processed && owner.radio_failures == 0 && owner.link_failures == 0);
|
||||
DEBUG_INFO(CAT, "WinRT test PASS: shutdown cancels queued work and rejects late callbacks");
|
||||
}
|
||||
|
||||
int main() {
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO);
|
||||
init_apartment(apartment_type::multi_threaded);
|
||||
UASYNC* ua = uasync_create(); CHECK(ua);
|
||||
link_exception(ua); radio_exception(ua); pending_shutdown(ua);
|
||||
uasync_destroy(ua, 0); uninit_apartment();
|
||||
return 0;
|
||||
}
|
||||
+204
-21
@@ -12,6 +12,10 @@
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
#include <sys/stat.h>
|
||||
#ifndef _WIN32
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
|
||||
void log_dump(int level, int category, const char* prefix, const uint8_t* data, size_t len) {
|
||||
if (!debug_should_output(level, category)) return;
|
||||
@@ -219,8 +223,151 @@ const char* debug_get_category_name(debug_category_t category_idx) {
|
||||
debug_config_t g_debug_config;
|
||||
static debug_log_hook_fn g_log_hook = NULL;
|
||||
|
||||
/* Запись/смена FILE и ротация сериализованы: fclose не пересекается с чужим fwrite.
|
||||
* Файловый код logger-а не вызывает file_io/DEBUG_* под lock: они сами логируют ошибки. */
|
||||
#ifdef _WIN32
|
||||
static SRWLOCK log_mutex = SRWLOCK_INIT;
|
||||
#define LOG_LOCK() AcquireSRWLockExclusive(&log_mutex)
|
||||
#define LOG_UNLOCK() ReleaseSRWLockExclusive(&log_mutex)
|
||||
#else
|
||||
static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
#define LOG_LOCK() pthread_mutex_lock(&log_mutex)
|
||||
#define LOG_UNLOCK() pthread_mutex_unlock(&log_mutex)
|
||||
#endif
|
||||
#define LOG_PATH_MAX 4096
|
||||
static uint64_t log_bytes, log_limit, log_rotate_at;
|
||||
static unsigned log_files;
|
||||
|
||||
#ifdef _WIN32
|
||||
static int log_wide_path(const char* path, wchar_t wide[LOG_PATH_MAX]) {
|
||||
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, wide, LOG_PATH_MAX)) return 0;
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
static FILE* log_open(const char* path, int truncate, uint64_t* bytes) {
|
||||
if (!path || !*path || strlen(path) >= LOG_PATH_MAX - 4) { errno = ENAMETOOLONG; return NULL; }
|
||||
#ifdef _WIN32
|
||||
wchar_t wide[LOG_PATH_MAX];
|
||||
if (log_wide_path(path, wide)) return NULL;
|
||||
FILE* file = _wfopen(wide, truncate ? L"wb" : L"ab");
|
||||
struct _stat64 info;
|
||||
int result = file ? _fstat64(_fileno(file), &info) : -1;
|
||||
#else
|
||||
FILE* file = fopen(path, truncate ? "wb" : "ab");
|
||||
struct stat info;
|
||||
int result = file ? fstat(fileno(file), &info) : -1;
|
||||
#endif
|
||||
if (!file) return NULL;
|
||||
if (result) { int error = errno; fclose(file); errno = error; return NULL; }
|
||||
*bytes = info.st_size > 0 ? (uint64_t)info.st_size : 0;
|
||||
return file;
|
||||
}
|
||||
|
||||
static int log_remove(const char* path) {
|
||||
#ifdef _WIN32
|
||||
wchar_t wide[LOG_PATH_MAX];
|
||||
if (log_wide_path(path, wide)) return -1;
|
||||
return _wremove(wide);
|
||||
#else
|
||||
return remove(path);
|
||||
#endif
|
||||
}
|
||||
|
||||
static int log_rename(const char* from, const char* to) {
|
||||
#ifdef _WIN32
|
||||
wchar_t source[LOG_PATH_MAX], destination[LOG_PATH_MAX];
|
||||
if (log_wide_path(from, source) || log_wide_path(to, destination)) return -1;
|
||||
return _wrename(source, destination);
|
||||
#else
|
||||
return rename(from, to);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Ошибку logger-а пишем напрямую, без рекурсии, в stderr и доступный текущий лог. */
|
||||
static void log_io_error(const char* phase, const char* path, int error) {
|
||||
char message[1024];
|
||||
snprintf(message, sizeof(message), "[ERROR] [sys] log %s failed: path=%s errno=%d (%s)\n", phase, path, error, strerror(error));
|
||||
fputs(message, stderr); fflush(stderr);
|
||||
if (g_debug_config.file_output) {
|
||||
log_bytes += fwrite(message, 1, strlen(message), g_debug_config.file_output);
|
||||
fflush(g_debug_config.file_output);
|
||||
}
|
||||
}
|
||||
|
||||
/* Под lock, без проверок размера на диске. Пустые файлы не занимают поколения. */
|
||||
static int log_rotate(const char* trigger) {
|
||||
if (!log_bytes) return 0;
|
||||
uint64_t previous_size = log_bytes;
|
||||
const char* path = g_debug_config.output_file;
|
||||
char from[LOG_PATH_MAX], to[LOG_PATH_MAX];
|
||||
const char* phase = "rotate close";
|
||||
int error = 0;
|
||||
FILE* old = g_debug_config.file_output;
|
||||
g_debug_config.file_output = NULL;
|
||||
if (fclose(old)) { error = errno; goto reopen; }
|
||||
if (log_files > 1) {
|
||||
snprintf(to, sizeof(to), "%s.%u", path, log_files - 1);
|
||||
phase = "rotate remove oldest";
|
||||
if (log_remove(to) && errno != ENOENT) { error = errno; goto reopen; }
|
||||
for (unsigned i = log_files - 1; i > 1; --i) {
|
||||
snprintf(from, sizeof(from), "%s.%u", path, i - 1);
|
||||
snprintf(to, sizeof(to), "%s.%u", path, i);
|
||||
phase = "rotate rename generation";
|
||||
if (log_rename(from, to) && errno != ENOENT) { error = errno; goto reopen; }
|
||||
}
|
||||
snprintf(to, sizeof(to), "%s.1", path);
|
||||
phase = "rotate rename current";
|
||||
if (log_rename(path, to)) { error = errno; goto reopen; }
|
||||
}
|
||||
reopen:
|
||||
g_debug_config.file_output = log_open(path, !error && log_files == 1, &log_bytes);
|
||||
if (!g_debug_config.file_output) {
|
||||
log_io_error("rotate reopen", path, errno);
|
||||
g_debug_config.console_enabled = 1;
|
||||
return -1;
|
||||
}
|
||||
if (error) log_io_error(phase, path, error);
|
||||
else if (debug_should_output(DEBUG_LEVEL_INFO, DEBUG_CATEGORY_SYS)) {
|
||||
char message[192];
|
||||
int size = snprintf(message, sizeof(message), "[INFO] [sys] Log rotated: trigger=%s previous_bytes=%llu files=%u\n",
|
||||
trigger, (unsigned long long)previous_size, log_files);
|
||||
log_bytes += fwrite(message, 1, (size_t)size, g_debug_config.file_output);
|
||||
if (fflush(g_debug_config.file_output)) log_io_error("flush rotation notice", path, errno);
|
||||
}
|
||||
/* При ошибке не повторяем файловые операции на каждой строке: ещё limit байтов до попытки. */
|
||||
log_rotate_at = error ? log_bytes + log_limit : log_limit;
|
||||
return error ? -1 : 0;
|
||||
}
|
||||
|
||||
int debug_set_file_rotation(uint64_t max_bytes, unsigned file_count) {
|
||||
if (max_bytes && (max_bytes < 4096 || max_bytes > UINT64_MAX / 2 || !file_count || file_count > 99)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid log rotation: bytes=%llu files=%u", (unsigned long long)max_bytes, file_count);
|
||||
return -1;
|
||||
}
|
||||
LOG_LOCK();
|
||||
log_limit = max_bytes; log_files = file_count; log_rotate_at = max_bytes;
|
||||
LOG_UNLOCK();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int debug_rotate_log(void) {
|
||||
LOG_LOCK();
|
||||
if (!log_limit || !g_debug_config.file_output) {
|
||||
LOG_UNLOCK();
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot rotate log: file output or rotation is disabled");
|
||||
return -1;
|
||||
}
|
||||
int result = log_rotate("startup/manual");
|
||||
LOG_UNLOCK();
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Initialize debug system with default settings */
|
||||
void debug_config_init(void) {
|
||||
LOG_LOCK();
|
||||
log_bytes = log_limit = log_rotate_at = 0; log_files = 0;
|
||||
g_debug_config.level = DEBUG_LEVEL_ERROR; // Global level: errors only by default
|
||||
for (int i = 0; i < DEBUG_CATEGORY_COUNT; i++) {
|
||||
g_debug_config.category_levels[i] = DEBUG_LEVEL_NONE; // 0 = disabled per category
|
||||
@@ -234,6 +381,7 @@ void debug_config_init(void) {
|
||||
g_debug_config.file_output = NULL; // No file by default
|
||||
g_debug_config.console_enabled = 1; // Console enabled
|
||||
g_debug_config.thread_marker = 0; // No thread marker by default
|
||||
LOG_UNLOCK();
|
||||
}
|
||||
|
||||
/* Set debug level */
|
||||
@@ -285,12 +433,18 @@ void debug_enable_file_line(int enable) {
|
||||
}
|
||||
|
||||
void debug_set_output_file(const char* file_path) {
|
||||
if (g_debug_config.file_output) return;// уже установлен, кто первый того и тапки
|
||||
FILE* new_file = fopen(file_path, "a");
|
||||
if (!file_path) return;
|
||||
LOG_LOCK();
|
||||
if (g_debug_config.file_output) { LOG_UNLOCK(); return; }
|
||||
FILE* new_file = log_open(file_path, 0, &log_bytes);
|
||||
if (new_file) {
|
||||
g_debug_config.file_output = new_file;
|
||||
g_debug_config.output_file = file_path;
|
||||
log_rotate_at = log_limit;
|
||||
}
|
||||
int error = errno;
|
||||
LOG_UNLOCK();
|
||||
if (!new_file) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot open log '%s': %s", file_path, strerror(error));
|
||||
}
|
||||
|
||||
/* Set console log level */
|
||||
@@ -301,50 +455,61 @@ void debug_enable_console(int enable) {
|
||||
/* Enable file output with optional truncation */
|
||||
void debug_enable_file_output(const char* file_path, int truncate) {
|
||||
if (!file_path) return;
|
||||
|
||||
// Close existing file if open
|
||||
if (g_debug_config.file_output) {
|
||||
fclose(g_debug_config.file_output);
|
||||
g_debug_config.file_output = NULL;
|
||||
}
|
||||
|
||||
// Open new file
|
||||
const char* mode = truncate ? "w" : "a";
|
||||
FILE* new_file = fopen(file_path, mode);
|
||||
LOG_LOCK();
|
||||
uint64_t bytes;
|
||||
FILE* new_file = log_open(file_path, truncate, &bytes);
|
||||
int error = errno;
|
||||
if (new_file) {
|
||||
FILE* old = g_debug_config.file_output;
|
||||
g_debug_config.file_output = new_file;
|
||||
g_debug_config.output_file = file_path;
|
||||
g_debug_config.console_enabled = 0;
|
||||
log_bytes = bytes; log_rotate_at = log_limit;
|
||||
if (old && fclose(old)) log_io_error("close previous", file_path, errno);
|
||||
}
|
||||
LOG_UNLOCK();
|
||||
if (!new_file) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot open log '%s': %s", file_path, strerror(error));
|
||||
}
|
||||
|
||||
/* Disable file output */
|
||||
void debug_disable_file_output(void) {
|
||||
LOG_LOCK();
|
||||
if (g_debug_config.file_output) {
|
||||
fclose(g_debug_config.file_output);
|
||||
FILE* old = g_debug_config.file_output;
|
||||
g_debug_config.file_output = NULL;
|
||||
g_debug_config.output_file = NULL;
|
||||
if (fclose(old)) log_io_error("close", g_debug_config.output_file, errno);
|
||||
}
|
||||
g_debug_config.output_file = NULL; log_bytes = 0;
|
||||
LOG_UNLOCK();
|
||||
}
|
||||
|
||||
/* Переоткрыть лог в append-режиме; при ошибке открытия сохранить прежний поток. */
|
||||
int debug_reopen_log(void) {
|
||||
LOG_LOCK();
|
||||
if (!g_debug_config.output_file) {
|
||||
LOG_UNLOCK();
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "No log path for reopen");
|
||||
return -1;
|
||||
}
|
||||
const char* path = g_debug_config.output_file;
|
||||
FILE* new_file = fopen(path, "a");
|
||||
uint64_t bytes;
|
||||
FILE* new_file = log_open(path, 0, &bytes);
|
||||
if (!new_file) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot reopen log '%s': %s", path, strerror(errno));
|
||||
int error = errno;
|
||||
LOG_UNLOCK();
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot reopen log '%s': %s", path, strerror(error));
|
||||
return -1;
|
||||
}
|
||||
FILE* old_file = g_debug_config.file_output;
|
||||
g_debug_config.file_output = new_file;
|
||||
log_bytes = bytes; log_rotate_at = log_limit;
|
||||
if (old_file && fclose(old_file) != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot close previous log '%s': %s", path, strerror(errno));
|
||||
int error = errno;
|
||||
LOG_UNLOCK();
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot close previous log '%s': %s", path, strerror(error));
|
||||
return -1;
|
||||
}
|
||||
LOG_UNLOCK();
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Log reopened: %s", path);
|
||||
return 0;
|
||||
}
|
||||
@@ -408,9 +573,14 @@ void debug_output(debug_level_t level, debug_category_t category_idx,
|
||||
struct timeval tv;
|
||||
utun_gettimeofday(&tv, NULL);
|
||||
time_t tv_sec = (time_t)tv.tv_sec;
|
||||
struct tm* tm_info = localtime(&tv_sec);
|
||||
struct tm tm_info;
|
||||
#ifdef _WIN32
|
||||
localtime_s(&tm_info, &tv_sec);
|
||||
#else
|
||||
localtime_r(&tv_sec, &tm_info);
|
||||
#endif
|
||||
char time_str[32];
|
||||
strftime(time_str, sizeof(time_str), "%H:%M:%S", tm_info);
|
||||
strftime(time_str, sizeof(time_str), "%H:%M:%S", &tm_info);
|
||||
BUF_ADVANCE(snprintf(buffer + offset, remaining, "[%s-%03ld.%03ld] ", time_str, tv.tv_usec / 1000, tv.tv_usec % 1000));
|
||||
}
|
||||
|
||||
@@ -461,13 +631,26 @@ void debug_output(debug_level_t level, debug_category_t category_idx,
|
||||
}
|
||||
|
||||
/* Output to console if enabled and level is sufficient */
|
||||
LOG_LOCK();
|
||||
if (g_debug_config.file_output && log_limit && log_bytes >= log_rotate_at) log_rotate("size");
|
||||
if (g_debug_config.file_output) {
|
||||
fprintf(g_debug_config.file_output, "%s", buffer);
|
||||
fflush(g_debug_config.file_output);
|
||||
size_t size = strlen(buffer);
|
||||
size_t written = fwrite(buffer, 1, size, g_debug_config.file_output);
|
||||
log_bytes += written;
|
||||
if (fflush(g_debug_config.file_output) || written != size) {
|
||||
/* Не пытаемся снова писать в неисправный FILE и не теряем диагностику следующей строки. */
|
||||
int error = errno;
|
||||
FILE* broken = g_debug_config.file_output; g_debug_config.file_output = NULL;
|
||||
log_io_error("write", g_debug_config.output_file, error);
|
||||
if (fclose(broken)) log_io_error("close after write error", g_debug_config.output_file, errno);
|
||||
g_debug_config.console_enabled = 1;
|
||||
fputs(buffer, stderr); fflush(stderr);
|
||||
}
|
||||
} else if (g_debug_config.console_enabled) {
|
||||
fprintf(stdout, "%s", buffer);
|
||||
fflush(stdout);
|
||||
}
|
||||
LOG_UNLOCK();
|
||||
#undef BUFFER_SIZE
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -6,7 +6,7 @@
|
||||
* Пример: debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_parse_config("etcp=trace,config=info").
|
||||
* DEBUG_* фильтруют до форматирования; прямой debug_output фильтр не проверяет.
|
||||
* Строка с префиксами ограничена буфером 4096 байт и обрезается; каждый вывод fflush.
|
||||
* Модуль и смена глобальных настроек/файлов не синхронизированы; параллельный доступ согласует владелец.
|
||||
* Запись/смена файла и ротация синхронизированы; уровни/настройки форматирования согласует владелец.
|
||||
*/
|
||||
|
||||
#ifndef DEBUG_CONFIG_H
|
||||
@@ -132,8 +132,19 @@ void debug_enable_file_line(int enable);
|
||||
void debug_set_output_file(const char* file_path);
|
||||
|
||||
/* Заменить файл: truncate != 0 → обрезать, иначе append. NULL ничего не меняет.
|
||||
* Путь заимствован; успешное открытие также выключает console_enabled. */
|
||||
* Путь UTF-8 заимствован; успешное открытие выключает console_enabled; при отказе прежний FILE сохраняется. */
|
||||
void debug_enable_file_output(const char* file_path, int truncate);
|
||||
/* Ротация выключена после init. max_bytes=0 выключает; иначе >=4096, file_count=1..99
|
||||
* включая текущий файл. После примерно max_bytes: path → path.1 → ...; последний удаляется.
|
||||
* Только rename/remove, без сжатия. Строки не разрываются: превышение до одной строки.
|
||||
* Размер читается только при открытии; далее счётчик записанных байтов. Один процесс владеет путём.
|
||||
* 0/-1; повторная настройка и reopen сами по себе не ротируют. */
|
||||
int debug_set_file_rotation(uint64_t max_bytes, unsigned file_count);
|
||||
/* Начать новый лог немедленно (например, при запуске приложения); 0/-1.
|
||||
* Нужны открытый файл и включённая ротация; пустой файл не создаёт пустое поколение.
|
||||
* Ошибка ротации логируется; по возможности продолжается append, повтор через max_bytes. */
|
||||
int debug_rotate_log(void);
|
||||
|
||||
/* Закрыть файл; прежнее значение console_enabled не восстанавливается. */
|
||||
void debug_disable_file_output(void);
|
||||
/* Переоткрыть текущий путь в append-режиме: 0 — успех, -1 — ошибка с диагностикой.
|
||||
|
||||
@@ -6,14 +6,54 @@
|
||||
#define SKIP_WS(p, end) while ((p) < (end) && (*(p) == ' ' || *(p) == '\t' || *(p) == '\n' || *(p) == '\r')) (p)++
|
||||
#define CHECK(p, end) if ((p) >= (end)) return -1
|
||||
|
||||
static int json_unicode_unit(const char** text, const char* end, uint32_t* value) {
|
||||
if (end - *text < 4) return -1;
|
||||
uint32_t result = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
unsigned char c = (unsigned char)(*text)[i]; int digit;
|
||||
if (c >= '0' && c <= '9') digit = c - '0';
|
||||
else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10;
|
||||
else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10;
|
||||
else return -1;
|
||||
result = (result << 4) | (uint32_t)digit;
|
||||
}
|
||||
*text += 4; *value = result; return 0;
|
||||
}
|
||||
|
||||
static int json_read_string(const char** pp, const char* end, char* out, size_t out_sz) {
|
||||
const char* p = *pp;
|
||||
CHECK(p, end);
|
||||
if (*p != '"') return -1; p++;
|
||||
size_t n = 0;
|
||||
while (p < end && *p != '"') {
|
||||
if ((unsigned char)*p < 32) return -1;
|
||||
char ch = *p;
|
||||
if (*p == '\\') { p++; CHECK(p, end);
|
||||
if (*p == 'u') {
|
||||
uint32_t code; p++; if (json_unicode_unit(&p, end, &code) < 0 || !code) return -1;
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
if (end - p < 2 || p[0] != '\\' || p[1] != 'u') return -1;
|
||||
p += 2; uint32_t low;
|
||||
if (json_unicode_unit(&p, end, &low) < 0 || low < 0xdc00 || low > 0xdfff) return -1;
|
||||
code = 0x10000 + ((code - 0xd800) << 10) + low - 0xdc00;
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) return -1;
|
||||
uint8_t utf8[4]; size_t bytes;
|
||||
if (code < 0x80) { utf8[0] = (uint8_t)code; bytes = 1; }
|
||||
else if (code < 0x800) {
|
||||
utf8[0] = (uint8_t)(0xc0 | (code >> 6)); utf8[1] = (uint8_t)(0x80 | (code & 63)); bytes = 2;
|
||||
} else if (code < 0x10000) {
|
||||
utf8[0] = (uint8_t)(0xe0 | (code >> 12)); utf8[1] = (uint8_t)(0x80 | ((code >> 6) & 63));
|
||||
utf8[2] = (uint8_t)(0x80 | (code & 63)); bytes = 3;
|
||||
} else {
|
||||
utf8[0] = (uint8_t)(0xf0 | (code >> 18)); utf8[1] = (uint8_t)(0x80 | ((code >> 12) & 63));
|
||||
utf8[2] = (uint8_t)(0x80 | ((code >> 6) & 63)); utf8[3] = (uint8_t)(0x80 | (code & 63)); bytes = 4;
|
||||
}
|
||||
if (out) {
|
||||
if (n + bytes >= out_sz) return -1;
|
||||
memcpy(out + n, utf8, bytes); n += bytes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
switch (*p) {
|
||||
case '"': ch = '"'; break;
|
||||
case '\\': ch = '\\'; break;
|
||||
@@ -83,6 +123,7 @@ int json_flat_parse(const char* json, json_flat_cb cb, void* arg) {
|
||||
if (*p == '}') break;
|
||||
}
|
||||
if (scratch != local) u_free(scratch);
|
||||
if (result < 0) DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "json_flat: invalid object/string bytes=%zu", capacity - 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
* @file json_flat.h
|
||||
* @brief Парсер плоского JSON без вложенности: {"key":"value",...}
|
||||
*
|
||||
* Только строковые значения и обычные escapes; \uXXXX не поддерживается.
|
||||
* Только строковые значения,обычные escapes и \uXXXX (surrogate pairs→UTF-8).
|
||||
* Нулевой Unicode-символ,непарные суррогаты,неверный hex и сырые ASCII-control отклоняются.
|
||||
* Callback получает полные key/value, действительные только до его возврата.
|
||||
* parse возвращает 0 при успехе/остановке callback, -1 при ошибке; остановка не проверяет остаток ввода.
|
||||
* Синтаксис проверяется нестрого: допускаются пропущенные и завершающие запятые;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#include "packet_link_backend.h"
|
||||
#include "u_async.h"
|
||||
#include "ll_queue.h"
|
||||
#include "mem.h"
|
||||
#include "debug_config.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
struct packet_link {
|
||||
struct UASYNC* ua;
|
||||
struct packet_link_driver driver;
|
||||
char* name;
|
||||
struct packet_link_callbacks callbacks;
|
||||
struct packet_link_state state;
|
||||
void *arg, *notification;
|
||||
unsigned active;
|
||||
int closing, rx_enabled, sending;
|
||||
};
|
||||
|
||||
static void destroy_deferred(void* arg) {
|
||||
struct packet_link* link = arg;
|
||||
link->driver.destroy(link->driver.ctx);
|
||||
u_free(link->name);
|
||||
u_free(link);
|
||||
}
|
||||
|
||||
static void release(struct packet_link* link) {
|
||||
if (--link->active || !link->closing) return;
|
||||
/* Backend мог вызвать receive из своего I/O callback: его стек тоже должен завершиться. */
|
||||
if (!uasync_call_soon(link->ua, link, destroy_deferred)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: cannot defer destruction"); abort();
|
||||
}
|
||||
}
|
||||
|
||||
static void notify(void* arg) {
|
||||
struct packet_link* link = arg;
|
||||
link->notification = NULL;
|
||||
link->active++;
|
||||
if (link->callbacks.state_changed) link->callbacks.state_changed(link, link->arg);
|
||||
release(link);
|
||||
}
|
||||
|
||||
static int schedule(struct packet_link* link) {
|
||||
if (link->notification || link->closing) return 0;
|
||||
link->notification = uasync_call_soon(link->ua, link, notify);
|
||||
if (link->notification) return 0;
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: cannot schedule state notification");
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int valid_state(const struct packet_link_state* s) {
|
||||
return s && s->phase >= PACKET_LINK_CONNECTING && s->phase <= PACKET_LINK_TERMINATED &&
|
||||
s->max_packet && s->max_packet <= UINT16_MAX && (!s->tx_ready || s->phase == PACKET_LINK_OPEN);
|
||||
}
|
||||
|
||||
static void driver_changed(void* arg);
|
||||
static void driver_received(void* arg, struct ll_entry* packet);
|
||||
|
||||
struct packet_link* packet_link_bind(const struct packet_link_driver* driver,
|
||||
const struct packet_link_callbacks* callbacks, void* arg) {
|
||||
if (!driver || !driver->ua || !driver->name || !driver->name[0] || !driver->bind || !driver->get_state ||
|
||||
!driver->send || !driver->stop || !driver->destroy || !driver->set_rx_enabled || !callbacks || !callbacks->received ||
|
||||
driver->direction < PACKET_LINK_DIRECTION_UNKNOWN || driver->direction > PACKET_LINK_INCOMING) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: invalid driver API"); return NULL;
|
||||
}
|
||||
struct packet_link_state initial = driver->get_state(driver->ctx);
|
||||
if (!valid_state(&initial) || initial.phase == PACKET_LINK_TERMINATED) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link %s: invalid initial state", driver->name); return NULL;
|
||||
}
|
||||
struct packet_link* link = u_calloc(1, sizeof(*link));
|
||||
if (!link) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: allocation failed"); return NULL; }
|
||||
link->ua = driver->ua; link->driver = *driver; link->state = initial; link->callbacks = *callbacks; link->arg = arg;
|
||||
link->name = u_strdup(driver->name);
|
||||
if (!link->name || schedule(link)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: cannot prepare binding"); u_free(link->name); u_free(link); return NULL;
|
||||
}
|
||||
link->driver.name = link->name;
|
||||
const struct packet_link_events events = {driver_changed, driver_received};
|
||||
if (driver->bind(driver->ctx, &events, link)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link %s: driver binding rejected", link->name);
|
||||
uasync_call_soon_cancel(link->ua, link->notification); u_free(link->name); u_free(link); return NULL;
|
||||
}
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "packet link=%p driver=%s direction=%d phase=%d max=%zu ready=%d",
|
||||
(void*)link, link->name, link->driver.direction, initial.phase, initial.max_packet, initial.tx_ready);
|
||||
return link;
|
||||
}
|
||||
|
||||
const char* packet_link_name(const struct packet_link* link) { return link ? link->name : "unbound"; }
|
||||
|
||||
enum packet_link_direction packet_link_get_direction(const struct packet_link* link) {
|
||||
return link ? link->driver.direction : PACKET_LINK_DIRECTION_UNKNOWN;
|
||||
}
|
||||
|
||||
struct packet_link_state packet_link_get_state(const struct packet_link* link) {
|
||||
if (!link || link->closing) return (struct packet_link_state){PACKET_LINK_TERMINATED, 0, 0, PACKET_LINK_CLOSED};
|
||||
return link->state;
|
||||
}
|
||||
|
||||
static void driver_changed(void* arg) {
|
||||
struct packet_link* link = arg;
|
||||
if (!link || link->closing) return;
|
||||
struct packet_link_state snapshot = link->driver.get_state(link->driver.ctx);
|
||||
const struct packet_link_state* state = &snapshot;
|
||||
if (!valid_state(state) || state->max_packet != link->state.max_packet || (link->state.phase == PACKET_LINK_TERMINATED && state->phase != PACKET_LINK_TERMINATED)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: invalid state transition"); return;
|
||||
}
|
||||
struct packet_link_state old = link->state;
|
||||
if (old.phase == state->phase && old.max_packet == state->max_packet && old.tx_ready == state->tx_ready && old.error == state->error) return;
|
||||
link->state = *state;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "packet link=%p phase=%d ready=%d max=%zu error=%d",
|
||||
(void*)link, state->phase, state->tx_ready, state->max_packet, state->error);
|
||||
/* Потерять обязательное событие нельзя; синхронный callback нарушил бы контракт send. */
|
||||
if (schedule(link)) abort();
|
||||
}
|
||||
|
||||
int packet_link_send(struct packet_link* link, struct ll_entry* packet) {
|
||||
if (!link || link->closing || link->state.phase == PACKET_LINK_TERMINATED) return PACKET_LINK_CLOSED;
|
||||
if (!packet || packet->owner || packet->next || packet->prev || !packet->dgram || !packet->len || packet->len > packet->memlen) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: invalid TX packet"); return PACKET_LINK_INVALID;
|
||||
}
|
||||
if (packet->len > link->state.max_packet) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: oversized TX size=%u max=%zu", packet->len, link->state.max_packet);
|
||||
return PACKET_LINK_TOO_LARGE;
|
||||
}
|
||||
if (!link->state.tx_ready || link->sending) return PACKET_LINK_WOULD_BLOCK;
|
||||
link->active++; link->sending = 1;
|
||||
int result = link->driver.send(link->driver.ctx, packet);
|
||||
link->sending = 0;
|
||||
release(link);
|
||||
return result;
|
||||
}
|
||||
|
||||
void packet_link_set_rx_enabled(struct packet_link* link, int enabled) {
|
||||
if (!link || link->closing || link->rx_enabled == !!enabled) return;
|
||||
link->active++; link->rx_enabled = !!enabled;
|
||||
link->driver.set_rx_enabled(link->driver.ctx, !!enabled);
|
||||
release(link);
|
||||
}
|
||||
|
||||
static void driver_received(void* arg, struct ll_entry* packet) {
|
||||
struct packet_link* link = arg;
|
||||
if (!packet || packet->owner || packet->next || packet->prev || !packet->dgram || !packet->len ||
|
||||
packet->len > packet->memlen || packet->len > link->state.max_packet ||
|
||||
link->closing || !link->rx_enabled || link->sending || link->state.phase != PACKET_LINK_OPEN) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link %s: invalid RX bytes=%u capacity=%u queued=%d phase=%d enabled=%d sending=%d closing=%d",
|
||||
link->name, packet ? packet->len : 0, packet ? packet->memlen : 0, packet && packet->owner != NULL,
|
||||
link->state.phase, link->rx_enabled, link->sending, link->closing);
|
||||
if (packet) { queue_dgram_free(packet); queue_entry_free(packet); }
|
||||
return;
|
||||
}
|
||||
link->active++;
|
||||
link->callbacks.received(link, packet, link->arg);
|
||||
release(link);
|
||||
}
|
||||
|
||||
void packet_link_close(struct packet_link* link) {
|
||||
if (!link || link->closing) return;
|
||||
link->active++; link->closing = 1;
|
||||
if (link->notification) { uasync_call_soon_cancel(link->ua, link->notification); link->notification = NULL; }
|
||||
link->driver.stop(link->driver.ctx);
|
||||
release(link);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Пакетный канал одного uasync-потока: state → send/received → close.
|
||||
* Каждый модуль сообщает готовность только собственных ресурсов. Формат байтов задаёт пользователь.
|
||||
* send не вызывает верхние callbacks; received передаёт владение entry и dgram получателю.
|
||||
* close допустим из callback, отменяет следующие события; после close handle недействителен. */
|
||||
#ifndef PACKET_LINK_H
|
||||
#define PACKET_LINK_H
|
||||
#include <stddef.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
struct packet_link;
|
||||
struct ll_entry;
|
||||
enum packet_link_result {
|
||||
PACKET_LINK_OK = 0, PACKET_LINK_WOULD_BLOCK = -1, PACKET_LINK_CLOSED = -2,
|
||||
PACKET_LINK_TOO_LARGE = -3, PACKET_LINK_INVALID = -4, PACKET_LINK_ERROR = -5
|
||||
};
|
||||
enum packet_link_phase { PACKET_LINK_CONNECTING, PACKET_LINK_OPEN, PACKET_LINK_TERMINATED };
|
||||
/* Направление физического подключения; не роль протокола, передаваемого по каналу. */
|
||||
enum packet_link_direction { PACKET_LINK_DIRECTION_UNKNOWN, PACKET_LINK_OUTGOING, PACKET_LINK_INCOMING };
|
||||
struct packet_link_state {
|
||||
enum packet_link_phase phase; /* OPEN — готов транспорт, не ETCP-сессия. */
|
||||
size_t max_packet; /* Максимальный пакет, включая байты формата пользователя. */
|
||||
int tx_ready; /* Есть место для одного пакета до max_packet. */
|
||||
int error; /* 0 — штатно; иначе packet_link_result. Подробности — в логе реализации. */
|
||||
};
|
||||
struct packet_link_callbacks {
|
||||
void (*state_changed)(struct packet_link* link, void* arg); /* События объединяются; читать актуальный get_state. */
|
||||
void (*received)(struct packet_link* link, struct ll_entry* packet, void* arg); /* Получатель владеет пакетом. */
|
||||
};
|
||||
/* Снимок без callbacks/изменения очередей; NULL возвращает TERMINATED/CLOSED. */
|
||||
struct packet_link_state packet_link_get_state(const struct packet_link* link);
|
||||
/* Неизменное направление из скопированного описания драйвера; NULL возвращает UNKNOWN. */
|
||||
enum packet_link_direction packet_link_get_direction(const struct packet_link* link);
|
||||
/* Целый извлечённый из очереди entry: OK передаёт владение, любой отказ оставляет его вызывающему. */
|
||||
int packet_link_send(struct packet_link* link, struct ll_entry* packet);
|
||||
/* Включить/приостановить received; нижний модуль сам ограничивает накопление. Изначально RX выключен. */
|
||||
void packet_link_set_rx_enabled(struct packet_link* link, int enabled);
|
||||
/* Отменить события и остановить реализацию; освобождение ждёт активные обработчики. NULL допустим. */
|
||||
void packet_link_close(struct packet_link* link);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
/* Экземпляр пакетного драйвера: создать → передать packet_link_bind → stop/destroy.
|
||||
* Один ctx — один канал к пиру; общий сокет/радио принадлежит платформенному владельцу.
|
||||
* Все API и события выполняются в ua. До bind RX выключен. */
|
||||
#ifndef PACKET_LINK_BACKEND_H
|
||||
#define PACKET_LINK_BACKEND_H
|
||||
#include "packet_link.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
struct UASYNC;
|
||||
struct packet_link_events {
|
||||
void (*state_changed)(void* arg); /* Читать актуальный get_state; событие обязательно при изменении готовности. */
|
||||
void (*received)(void* arg, struct ll_entry* packet); /* Передаёт владение entry+dgram получателю. */
|
||||
};
|
||||
struct packet_link_driver {
|
||||
void* ctx; /* Управляющая структура одного канала. */
|
||||
struct UASYNC* ua; /* Тот же event loop, что у потребителя. */
|
||||
const char* name; /* Непустое диагностическое имя; оболочка копирует строку. */
|
||||
/* Копирует events, заимствует arg. 0 — успех, отказ оставляет ctx непривязанным. Без синхронных событий. */
|
||||
int (*bind)(void* ctx, const struct packet_link_events* events, void* arg);
|
||||
struct packet_link_state (*get_state)(void* ctx); /* Снимок без побочных действий. */
|
||||
int (*send)(void* ctx, struct ll_entry* packet); /* OK передаёт владение; любой отказ сохраняет его. Без RX callbacks. */
|
||||
void (*set_rx_enabled)(void* ctx, int enabled); /* Разрешить/приостановить RX; накоплением управляет драйвер. */
|
||||
void (*stop)(void* ctx); /* Сразу запретить события, остановить канал; не освобождать ctx. */
|
||||
void (*destroy)(void* ctx); /* Освободить остановленный ctx после выхода обработчиков. */
|
||||
enum packet_link_direction direction; /* Диагностика подключения; копируется при bind, 0 — неизвестно. */
|
||||
};
|
||||
/* Копирует API/name/callbacks. Успех передаёт ctx оболочке; отказ сохраняет его вызывающему.
|
||||
* arg живёт до close. Первое state_changed отложено. TERMINATED необратим, max_packet не меняется. */
|
||||
struct packet_link* packet_link_bind(const struct packet_link_driver* driver,
|
||||
const struct packet_link_callbacks* callbacks, void* arg);
|
||||
/* Заимствованное диагностическое имя до close. */
|
||||
const char* packet_link_name(const struct packet_link* link);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
+9
-1
@@ -11,6 +11,8 @@
|
||||
#define STRBUF_MIN_CAP 16
|
||||
|
||||
void strbuf_init(struct strbuf *sb, size_t initial_cap) {
|
||||
*sb = strbuf_new();
|
||||
if (initial_cap > UINT32_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: capacity exceeds allocator range=%zu", initial_cap); return; }
|
||||
if (initial_cap < STRBUF_MIN_CAP) initial_cap = STRBUF_MIN_CAP;
|
||||
sb->buf = u_malloc(initial_cap);
|
||||
if (sb->buf) { sb->buf[0] = '\0'; sb->cap = initial_cap; }
|
||||
@@ -35,8 +37,9 @@ void strbuf_reset(struct strbuf *sb) {
|
||||
}
|
||||
|
||||
static int strbuf_grow(struct strbuf *sb, size_t need) {
|
||||
if (need > UINT32_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: size exceeds allocator range=%zu", need); return -1; }
|
||||
size_t nc = sb->cap ? sb->cap : STRBUF_MIN_CAP;
|
||||
while (nc < need) { nc *= 2; if (nc < STRBUF_MIN_CAP) return -1; }
|
||||
while (nc < need) { if (nc > UINT32_MAX / 2) { nc = need; break; } nc *= 2; }
|
||||
char *n = u_realloc(sb->buf, nc);
|
||||
if (!n) return -1;
|
||||
sb->buf = n; sb->cap = nc;
|
||||
@@ -51,6 +54,9 @@ int strbuf_addf(struct strbuf *sb, const char *fmt, ...) {
|
||||
int need = vsnprintf(NULL, 0, fmt, ap);
|
||||
va_end(ap);
|
||||
if (need < 0) { return -1; }
|
||||
if ((size_t)need > UINT32_MAX - 1 - sb->len) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: formatted text exceeds allocator range"); return -1;
|
||||
}
|
||||
|
||||
/* grow */
|
||||
size_t want = sb->len + (size_t)need + 1; /* +1 for '\0' */
|
||||
@@ -65,6 +71,7 @@ int strbuf_addf(struct strbuf *sb, const char *fmt, ...) {
|
||||
}
|
||||
|
||||
void strbuf_addc(struct strbuf *sb, char c) {
|
||||
if (sb->len > UINT32_MAX - 2) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: character exceeds allocator range"); return; }
|
||||
if (sb->len + 2 > sb->cap && strbuf_grow(sb, sb->len + 2) < 0) return;
|
||||
sb->buf[sb->len++] = c;
|
||||
sb->buf[sb->len] = '\0';
|
||||
@@ -96,6 +103,7 @@ failed:
|
||||
void strbuf_adds(struct strbuf *sb, const char *s) {
|
||||
if (!s) return;
|
||||
size_t slen = strlen(s);
|
||||
if (slen > UINT32_MAX - 1 - sb->len) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: string exceeds allocator range"); return; }
|
||||
if (sb->len + slen + 1 > sb->cap && strbuf_grow(sb, sb->len + slen + 1) < 0) return;
|
||||
memcpy(sb->buf + sb->len, s, slen);
|
||||
sb->len += slen;
|
||||
|
||||
@@ -25,6 +25,9 @@ struct strbuf {
|
||||
size_t len; /* current length (excluding '\0') */
|
||||
};
|
||||
|
||||
/* Размер ограничен UINT32_MAX allocator-ом mem.h;превышение отклоняется до преобразования size_t.
|
||||
* init предназначен для нового буфера;повторная инициализация живого буфера требует предварительного free. */
|
||||
|
||||
/* zero-init on stack: struct strbuf sb = {0}; */
|
||||
#define strbuf_new() ((struct strbuf){NULL, 0, 0})
|
||||
|
||||
|
||||
+30
-12
@@ -403,19 +403,23 @@ static void drain_wakeup_pipe(struct UASYNC* ua) {
|
||||
while (read(ua->wakeup_pipe[0], &val, sizeof(val)) > 0) {}
|
||||
}
|
||||
|
||||
// Process posted tasks (lock-u_free during execution)
|
||||
/* Стек пачек сохраняет доступ к ожидающим задачам при отмене из вложенного poll. */
|
||||
struct uasync_post_batch {
|
||||
struct uasync_post_batch* previous;
|
||||
struct posted_task* head;
|
||||
};
|
||||
|
||||
// Исполняем снимок очереди без блокировки; новые post остаются для следующей пачки.
|
||||
static void process_posted_tasks(struct UASYNC* ua) {
|
||||
if (!ua) return;
|
||||
|
||||
struct posted_task* list = NULL;
|
||||
|
||||
|
||||
struct uasync_post_batch batch = { .previous = ua->posted_batches };
|
||||
#ifdef _WIN32
|
||||
EnterCriticalSection(&ua->posted_lock);
|
||||
#else
|
||||
pthread_mutex_lock(&ua->posted_lock);
|
||||
#endif
|
||||
list = ua->posted_tasks_head;
|
||||
batch.head = ua->posted_tasks_head;
|
||||
ua->posted_tasks_head = ua->posted_tasks_tail = NULL;
|
||||
#ifdef _WIN32
|
||||
LeaveCriticalSection(&ua->posted_lock);
|
||||
@@ -423,16 +427,18 @@ static void process_posted_tasks(struct UASYNC* ua) {
|
||||
pthread_mutex_unlock(&ua->posted_lock);
|
||||
#endif
|
||||
|
||||
while (list) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_TUN, "POSTed task get");
|
||||
struct posted_task* t = list;
|
||||
list = list->next;
|
||||
ua->posted_batches = &batch;
|
||||
while (batch.head) {
|
||||
struct posted_task* t = batch.head;
|
||||
batch.head = t->next; // Исполняемый callback уже не подлежит отмене.
|
||||
DEBUG_TRACE(DEBUG_CATEGORY_SYS, "uasync: executing post task=%p arg=%p", (void*)t, t->arg);
|
||||
|
||||
if (t->callback) {
|
||||
t->callback(t->arg);
|
||||
}
|
||||
u_free(t);
|
||||
}
|
||||
ua->posted_batches = batch.previous;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -1846,7 +1852,7 @@ void uasync_post_reserved(struct UASYNC* ua, struct posted_task* task) {
|
||||
uasync_wakeup(ua); // будим mainloop
|
||||
}
|
||||
|
||||
/* Владелец отменяет pending completion после join производителя вне callbacks. */
|
||||
/* В потоке loop удаляем ожидающую задачу из общей очереди или ещё не исполненной части любой пачки. */
|
||||
err_t uasync_cancel_post(struct UASYNC* ua, struct posted_task* task) {
|
||||
#ifdef _WIN32
|
||||
EnterCriticalSection(&ua->posted_lock);
|
||||
@@ -1866,8 +1872,20 @@ err_t uasync_cancel_post(struct UASYNC* ua, struct posted_task* task) {
|
||||
#else
|
||||
pthread_mutex_unlock(&ua->posted_lock);
|
||||
#endif
|
||||
if (found) u_free(task);
|
||||
else DEBUG_ERROR(DEBUG_CATEGORY_SYS, "uasync_cancel_post: completion is not pending");
|
||||
const char* source = "queue";
|
||||
for (struct uasync_post_batch* batch = ua->posted_batches; !found && batch; batch = batch->previous) {
|
||||
p = &batch->head;
|
||||
while (*p && *p != task) p = &(*p)->next;
|
||||
if (*p) {
|
||||
*p = task->next;
|
||||
found = 1;
|
||||
source = "batch";
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "uasync_cancel_post: cancelled task=%p source=%s", (void*)task, source);
|
||||
u_free(task);
|
||||
} else DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "uasync_cancel_post: task=%p running or absent", (void*)task);
|
||||
return found ? ERR_OK : ERR_FAIL;
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -39,6 +39,7 @@ typedef void (*uasync_post_callback_t)(void* user_arg);
|
||||
#include "memory_pool.h"
|
||||
|
||||
struct timeout_node; // Forward declaration
|
||||
struct uasync_post_batch;
|
||||
|
||||
/* CPU одного потока: 100% = одно ядро. При работе цикла/запросе снимок обновляется раз в 100 мс, без пробуждений.
|
||||
* До первого замера valid=0; window_us растёт до секунды. При задержке замера
|
||||
@@ -87,6 +88,7 @@ struct UASYNC {
|
||||
int use_epoll; // 1 = epoll; 0 = poll на POSIX или select на Windows
|
||||
struct posted_task* posted_tasks_head;
|
||||
struct posted_task* posted_tasks_tail;
|
||||
struct uasync_post_batch* posted_batches; // Ожидающие задачи вложенных dispatch; только поток loop
|
||||
|
||||
#ifdef _WIN32
|
||||
CRITICAL_SECTION posted_lock;
|
||||
@@ -216,8 +218,9 @@ void uasync_post(struct UASYNC* ua, uasync_post_callback_t callback, void* user_
|
||||
* uasync освобождает task после callback, успешной отмены или при destroy без callback; arg не освобождает.
|
||||
* Публикация не проверяет running/stop. Ошибка wakeup не удаляет уже поставленную задачу и не возвращается. */
|
||||
void uasync_post_reserved(struct UASYNC* ua, struct posted_task* task);
|
||||
// Отмена в потоке владельца вне callbacks после завершения производителя; ua/task не NULL.
|
||||
// ERR_OK — task найден в очереди и освобождён; ERR_FAIL — не найден (в том числе уже взят на исполнение).
|
||||
// Отмена в потоке loop после завершения производителя; допустима из callback, ua/task не NULL.
|
||||
// ERR_OK — ожидающий task удалён и освобождён, в том числе из исполняемой пачки; arg остаётся владельцу.
|
||||
// ERR_FAIL — task отсутствует или его callback уже начат; исполняющийся task отменить нельзя.
|
||||
err_t uasync_cancel_post(struct UASYNC* ua, struct posted_task* task);
|
||||
|
||||
// Lock/unlock posted_lock у живого ua; не ждёт исполнения задач. post уже публикует данные под этим мьютексом.
|
||||
|
||||
+7
-4
@@ -106,9 +106,12 @@ void thread_func(UTUN_INSTANCE* u) {
|
||||
Задачу освобождает uasync после исполнения, успешной отмены или при уничтожении без вызова callback; `arg` не освобождается.
|
||||
Ошибка wakeup оставляет уже опубликованную задачу в очереди; обе функции публикации не возвращают эту ошибку.
|
||||
|
||||
Отмена через `uasync_cancel_post` выполняется в потоке владельца вне callbacks после завершения производителя.
|
||||
`ERR_OK` означает, что задача найдена в ожидающей очереди и освобождена; `ERR_FAIL` — не найдена.
|
||||
Порция, уже взятая на исполнение, в этой очереди не находится. `uasync_memsync` лишь захватывает и отпускает
|
||||
Отмена через `uasync_cancel_post` выполняется в потоке loop после завершения публикации производителем;
|
||||
вызов из другого callback допустим. `ERR_OK` означает, что ожидающий task удалён и освобождён:
|
||||
поиск охватывает общую очередь и ещё не исполненную часть всех активных batch, включая вложенные poll.
|
||||
После успешной отмены task недействителен; arg остаётся владельцу, его можно освободить.
|
||||
`ERR_FAIL` означает отсутствие task либо уже начавшийся callback; текущий callback не отменяется.
|
||||
Отдельное обнуление callback/arg для ожидающих batch не требуется. `uasync_memsync` лишь захватывает и отпускает
|
||||
`posted_lock`; он не ждёт исполнения задач и не заменяет согласование времени жизни контекста.
|
||||
|
||||
### Получение времени
|
||||
@@ -183,7 +186,7 @@ Handle кодирует индекс без поколения и сохраня
|
||||
|---------|----------|
|
||||
| `uasync_post(ua, cb, arg)` | **Потокобезопасно.** Выделить и опубликовать задачу для потока экземпляра, попытаться разбудить его. При OOM задача не публикуется, ошибка логируется; статуса возврата нет. |
|
||||
| `uasync_post_reserved(ua, task)` | **Потокобезопасно.** Передать заранее выделенный через `u_malloc` task с заполненными callback/arg без новых аллокаций. `ua` и `task` обязательны. Владение task переходит uasync. |
|
||||
| `uasync_cancel_post(ua, task)` | В потоке владельца вне callbacks после завершения производителя: `ERR_OK` при удалении и освобождении ожидающего task, `ERR_FAIL`, если он не найден. `ua` и `task` обязательны. |
|
||||
| `uasync_cancel_post(ua, task)` | В потоке loop после публикации, допустим из другого callback. `ERR_OK` удаляет и освобождает ожидающий task из очереди или активного batch; `ERR_FAIL` — task отсутствует либо callback уже начат. Arg остаётся владельцу; `ua` и `task` обязательны. |
|
||||
| `uasync_memsync(ua)` | Lock/unlock `posted_lock` живого экземпляра. Не ждёт исполнения задач; публикация через post уже использует этот мьютекс. |
|
||||
| `uasync_wakeup(ua)` | 0 при успешной записи, -1 при ошибке/неинициализированном wakeup. POSIX: запись `uint64_t(1)` в eventfd/pipe; Windows: send одного байта в UDP-сокет. Экземпляр должен оставаться живым. |
|
||||
| `uasync_get_wakeup_fd(ua)` | Заимствованный endpoint записи, либо -1 при `NULL`/неинициализированном wakeup. Linux: eventfd, тот же fd для чтения и записи (писать `uint64_t`); прочие POSIX: pipe; Windows: UDP-сокет. Самостоятельно не закрывать. |
|
||||
|
||||
+13
-2
@@ -2,7 +2,7 @@ bin_PROGRAMS = utun
|
||||
noinst_LIBRARIES = libutun.a
|
||||
|
||||
EXTRA_DIST = lwip_tcp/COPYING dnsmasq/COPYING dnsmasq/COPYING-v3 routing_layer/address_profile.h routing_layer/route_ping_doc.md \
|
||||
stream/stream_playback_audio.h stream/stream_playback_audio.cpp config_ini.h
|
||||
stream/stream_playback_audio.h stream/stream_playback_audio.cpp config_ini.h config_error.h
|
||||
|
||||
stream_core_files = \
|
||||
stream/stream.h \
|
||||
@@ -44,6 +44,7 @@ utun_CORE_SOURCES = \
|
||||
utun.c \
|
||||
utun_instance.c \
|
||||
config_parser.c \
|
||||
config_error.c \
|
||||
config_updater.c \
|
||||
routing_layer/route_lib.c \
|
||||
routing_layer/route6_lib.c \
|
||||
@@ -82,6 +83,7 @@ utun_CORE_SOURCES = \
|
||||
transport_layer/etcp_tx.c \
|
||||
transport_layer/etcp_tx.h \
|
||||
transport_layer/etcp_loadbalancer.c \
|
||||
transport_layer/etcp_link_io.c \
|
||||
transport_layer/etcp_debug.c \
|
||||
transport_layer/etcp_dump.c \
|
||||
transport_layer/secure_channel.c \
|
||||
@@ -185,6 +187,8 @@ utun_CORE_SOURCES = \
|
||||
chat/chat_sync.c \
|
||||
chat/chat_admin.c \
|
||||
chat/chat_join.c \
|
||||
chat/chat_join_port.c \
|
||||
chat/chat_join_port.h \
|
||||
chat/member_sync.c \
|
||||
chat/merkle_sync.c \
|
||||
chat/merkle_tree.c \
|
||||
@@ -192,6 +196,7 @@ utun_CORE_SOURCES = \
|
||||
chat/invite_link.c \
|
||||
chat/invite_build.c \
|
||||
chat/chat_headless_control.c \
|
||||
chat/chat_whisper.h \
|
||||
dm/dm_core.c \
|
||||
dm/dm_history.c \
|
||||
dm/dm_history.h \
|
||||
@@ -234,6 +239,7 @@ libutun_a_SOURCES = \
|
||||
$(stream_core_files) \
|
||||
utun_instance.c \
|
||||
config_parser.c \
|
||||
config_error.c \
|
||||
config_updater.c \
|
||||
routing_layer/route_lib.c \
|
||||
routing_layer/route6_lib.c \
|
||||
@@ -270,6 +276,7 @@ libutun_a_SOURCES = \
|
||||
transport_layer/etcp_tx.c \
|
||||
transport_layer/etcp_tx.h \
|
||||
transport_layer/etcp_loadbalancer.c \
|
||||
transport_layer/etcp_link_io.c \
|
||||
transport_layer/etcp_debug.c \
|
||||
transport_layer/etcp_dump.c \
|
||||
transport_layer/secure_channel.c \
|
||||
@@ -373,6 +380,8 @@ libutun_a_SOURCES = \
|
||||
chat/chat_sync.c \
|
||||
chat/chat_admin.c \
|
||||
chat/chat_join.c \
|
||||
chat/chat_join_port.c \
|
||||
chat/chat_join_port.h \
|
||||
chat/member_sync.c \
|
||||
chat/merkle_sync.c \
|
||||
chat/invite_link.c \
|
||||
@@ -380,6 +389,8 @@ libutun_a_SOURCES = \
|
||||
chat/merkle_tree.h \
|
||||
chat/invite_build.c \
|
||||
chat/chat_headless_control.c \
|
||||
chat/chat_whisper.c \
|
||||
chat/chat_whisper.h \
|
||||
dm/dm_core.c \
|
||||
dm/dm_history.c \
|
||||
dm/dm_history.h \
|
||||
@@ -414,7 +425,7 @@ libutun_a_CFLAGS = $(utun_CFLAGS)
|
||||
# Platform-specific TUN libs (Windows only)
|
||||
utun_TUN_LIBS = @TUN_LIBS@
|
||||
|
||||
utun_SOURCES = utun.c chat/chat_whisper.c
|
||||
utun_SOURCES = utun.c
|
||||
|
||||
# Include paths
|
||||
utun_CFLAGS = \
|
||||
|
||||
+32
-11
@@ -503,8 +503,8 @@ static void call_post_stats(struct call_session* s) {
|
||||
int buffer_ms = 0, tempo_x100 = 100;
|
||||
struct call_jitter_range range = {0, 0, 60};
|
||||
uint32_t dropped = 0, underruns = 0;
|
||||
if (ctx->audio_ops && ctx->audio_ops->get_stats)
|
||||
ctx->audio_ops->get_stats(s->call_id, &buffer_ms, &tempo_x100, &dropped, &underruns, &range);
|
||||
const struct call_audio_ops* audio = ctx->inst->call_audio_ops;
|
||||
if (audio && audio->get_stats) audio->get_stats(s->call_id, &buffer_ms, &tempo_x100, &dropped, &underruns, &range);
|
||||
|
||||
uint16_t rtt_tb = 0;
|
||||
struct ETCP_ROUTER_CONN* rconn =
|
||||
@@ -783,9 +783,9 @@ static void call_handle_media(struct call_ctx* ctx, uint64_t from, const uint8_t
|
||||
memcpy(&ts_ms, b + 12, 4);
|
||||
|
||||
/* аудио-движок (декод + джиттер-буфер) — если зарегистрирован */
|
||||
if (ctx->audio_ops && ctx->audio_ops->on_media)
|
||||
ctx->audio_ops->on_media(ctx->inst, call_id, seq, ts_ms,
|
||||
b + CALL_MEDIA_HDR_SIZE, (int)(n - CALL_MEDIA_HDR_SIZE), &timing);
|
||||
const struct call_audio_ops* audio = ctx->inst->call_audio_ops;
|
||||
if (audio && audio->on_media)
|
||||
audio->on_media(ctx->inst, call_id, seq, ts_ms, b + CALL_MEDIA_HDR_SIZE, (int)(n - CALL_MEDIA_HDR_SIZE), &timing);
|
||||
|
||||
/* релей Opus-кадра потребителю (headless-аудио-сокет) */
|
||||
if (ctx->media_recv_fn)
|
||||
@@ -964,7 +964,8 @@ int call_busy(struct UTUN_INSTANCE* inst) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id) {
|
||||
/* Только uasync: проверка доступности перед созданием сессии; очереди групп не межпоточные. */
|
||||
static int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id) {
|
||||
if (!inst || !ch_id || !ch_id[0] || !inst->topo_groups) return 0;
|
||||
if (peer_node_id == inst->node_id) return 1;
|
||||
uint64_t group_id = strtoull(ch_id, NULL, 10);
|
||||
@@ -981,9 +982,11 @@ void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, v
|
||||
}
|
||||
|
||||
void call_set_audio_ops(struct UTUN_INSTANCE* inst, const struct call_audio_ops* ops) {
|
||||
struct call_ctx* ctx = call_of(inst);
|
||||
if (!ctx) return;
|
||||
ctx->audio_ops = ops;
|
||||
if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call audio registration without instance"); return; }
|
||||
if (inst->call_audio_ops == ops) return;
|
||||
inst->call_audio_ops = ops;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call audio frontend %s inst=%p chat=%d",
|
||||
ops ? "attached" : "detached", (void*)inst, inst->chat_started);
|
||||
}
|
||||
|
||||
/* ─────────────────── TX медиа: tx_q + backpressure ─────────────────── */
|
||||
@@ -1075,14 +1078,32 @@ int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t*
|
||||
|
||||
void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id) {
|
||||
struct call_ctx* ctx = call_of(inst);
|
||||
if (!ctx || !ctx->initialized || !ch_id || !ch_id[0]) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: call_start bad args (ctx=%p ch_id=%s)", CALL_ID, (void*)ctx, ch_id ? ch_id : "(null)");
|
||||
if (!ctx || !ctx->initialized || !ch_id || !ch_id[0] || !peer_node_id || !out_call_id) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: call_start bad args ctx=%p ch=%s peer=%016llx id=%016llx", CALL_ID,
|
||||
(void*)ctx, ch_id ? ch_id : "(null)", (unsigned long long)peer_node_id, (unsigned long long)out_call_id);
|
||||
return;
|
||||
}
|
||||
struct call_session* existing = call_session_find(ctx, out_call_id);
|
||||
if (existing) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call start ID already registered id=%016llx state=%s peer=%016llx requested_peer=%016llx ch=%s",
|
||||
(unsigned long long)out_call_id, call_state_name(existing->state), (unsigned long long)existing->peer_node_id,
|
||||
(unsigned long long)peer_node_id, ch_id);
|
||||
return;
|
||||
}
|
||||
if (peer_node_id == inst->node_id) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: call_start self-call rejected", CALL_ID);
|
||||
return;
|
||||
}
|
||||
/* Как для входящего INVITE, ENDED остаётся только для подтверждения завершения и не занимает линию. */
|
||||
for (struct ll_entry* e = ctx->sessions->head; e; e = e->next) {
|
||||
struct call_session* busy = (struct call_session*)e->data;
|
||||
if (busy->state == CALL_ST_ENDED) continue;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call start rejected id=%016llx ch=%s peer=%016llx busy_id=%016llx state=%s busy_peer=%016llx",
|
||||
(unsigned long long)out_call_id, ch_id, (unsigned long long)peer_node_id,
|
||||
(unsigned long long)busy->call_id, call_state_name(busy->state), (unsigned long long)busy->peer_node_id);
|
||||
call_event_id(ctx, CHAT_EVT_CALL_ENDED, out_call_id, CALL_REASON_BUSY);
|
||||
return;
|
||||
}
|
||||
if (radio_call_busy(inst)) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call start rejected: pending radio invitation");
|
||||
call_event_id(ctx, CHAT_EVT_CALL_ENDED, out_call_id, CALL_REASON_BUSY);
|
||||
|
||||
+43
-26
@@ -25,27 +25,37 @@ extern "C" {
|
||||
|
||||
// ── жизненный цикл ──
|
||||
|
||||
int call_init(struct UTUN_INSTANCE* inst); /* из utun_instance.c после dm_core_init */
|
||||
/* Зарегистрировать сигналинг/медиа в роутере и создать реестр сессий после dm_core_init.
|
||||
* inst/ua/роутер должны существовать. 0 = успех (повтор идемпотентен), -1 = ошибка с откатом. */
|
||||
int call_init(struct UTUN_INSTANCE* inst);
|
||||
|
||||
void call_destroy(struct UTUN_INSTANCE* inst); /* перед dm_core_destroy */
|
||||
/* Остановить сессии, отменить таймеры/CM/waiters и снять сервисы роутера перед dm_core_destroy.
|
||||
* Сначала закрыть call_headless. Без отправки завершающих команд и событий ENDED; NULL/повтор — no-op.
|
||||
* Регистрация call_audio_ops у ядра сохраняется, DSP и устройства frontend здесь не освобождаются. */
|
||||
void call_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// ── сигналинг (uasync-поток) ──
|
||||
|
||||
/* Caller: ch_id — группа либо "dm:<conv_id>" (BGP-группа выбирается при запуске).
|
||||
* out_call_id — случайный uint64, генерит вызывающий. */
|
||||
* out_call_id — ненулевой уникальный uint64, генерит вызывающий.
|
||||
* При любом незавершённом обычном звонке/приглашении рации новый ID получает ENDED/BUSY без создания сессии.
|
||||
* Уже зарегистрированный ID, включая ENDED до очистки, отклоняется без событий и изменения текущего звонка.
|
||||
* После ENDED новый уникальный ID разрешён; результат запуска приходит через CHAT_EVT_CALL_*. */
|
||||
void call_start (struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id);
|
||||
|
||||
// Callee: ответить/отклонить. hangup — любая сторона в любой момент.
|
||||
// Callee: принять только RINGING; неизвестный ID или неподходящее состояние — no-op.
|
||||
void call_accept (struct UTUN_INSTANCE* inst, uint64_t call_id);
|
||||
|
||||
// Callee: отклонить существующую незавершённую сессию, события DECLINED и ENDED с причиной DECLINE.
|
||||
void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id);
|
||||
|
||||
// Любая сторона: завершить существующую сессию; неизвестный ID/ENDED — no-op.
|
||||
void call_hangup (struct UTUN_INSTANCE* inst, uint64_t call_id);
|
||||
|
||||
// Проверка занятости обычным звонком; только uasync, включая входящий/исходящий.
|
||||
// 1 = есть обычная сессия не в ENDED, включая WAIT_DECISION; 0 = свободен/модуль не запущен. Только uasync.
|
||||
int call_busy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// Продолжить исходящий вызов после предупреждения о телефонной занятости. Только uasync.
|
||||
// Caller в WAIT_DECISION: подтвердить force и повторить INVITE с прежним ID/deadline. Только uasync.
|
||||
void call_continue(struct UTUN_INSTANCE* inst, uint64_t call_id);
|
||||
|
||||
// Независимые настройки входящих вызовов; запрет выбранного типа приоритетнее проверки занятости.
|
||||
@@ -61,13 +71,12 @@ void call_set_phone_state(struct UTUN_INSTANCE* inst, int state, unsigned policy
|
||||
* Desktop без звонка свободен; у mobile состояние ОС может быть неизвестно. Только uasync. */
|
||||
uint8_t call_phone_status(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// Read-only онлайн-проверка (GUI может звать из любого потока).
|
||||
int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id);
|
||||
|
||||
// ── медиа (релей Opus-кадров, uasync-поток) ──
|
||||
|
||||
/* Отправить закодированный Opus-кадр пиру (оборачивает в CALL_MEDIA).
|
||||
* Только когда звонок ACTIVE. seq/ts выставляются автоматически. */
|
||||
* Только ACTIVE: len=1..988, opus/timing копируются до возврата; timing=NULL — без capture PTS.
|
||||
* seq/ts выставляются автоматически. 0 = принят в локальную FIFO, -1 = неверные данные/нет ACTIVE/ошибка памяти.
|
||||
* Результат не подтверждает доставку: FIFO750 удаляет старейший кадр при заполнении, роутер может отказать при drain. */
|
||||
int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len,
|
||||
const struct call_capture_time* timing);
|
||||
|
||||
@@ -76,6 +85,8 @@ int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t*
|
||||
typedef void (*call_media_recv_fn)(struct UTUN_INSTANCE* inst, uint64_t call_id,
|
||||
const uint8_t* opus, int len, void* arg);
|
||||
|
||||
/* Один callback в текущем call_ctx, fn=NULL снимает; arg заимствован до замены/destroy.
|
||||
* Требует call_init; stop/start чата сбрасывает этот callback. Независим от сохраняемого у ядра audio_ops. */
|
||||
void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, void* arg);
|
||||
|
||||
/* ── аудио-движок (опционально, GUI-сборки) ──
|
||||
@@ -84,38 +95,44 @@ void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, v
|
||||
* и get_stats для события CALL_STATS. Демон без аудио (headless) — ops не ставит. */
|
||||
|
||||
struct call_audio_ops {
|
||||
// uasync-поток: принятый Opus-кадр пира (декод + jitter-буфер).
|
||||
// uasync-поток: принятый Opus-кадр; opus/timing заимствованы до возврата. Декодирование определяет frontend.
|
||||
void (*on_media)(struct UTUN_INSTANCE* inst, uint64_t call_id,
|
||||
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len, const struct call_capture_time* timing);
|
||||
/* uasync-поток: текущие метрики джиттер-буфера (buffer_ms, tempo_x100,
|
||||
* dropped/underruns — опционально, NULL допустим). */
|
||||
/* uasync-поток: метрики (buffer_ms, tempo_x100=100 для 1x, накопительные dropped/underruns, range).
|
||||
* Выходы необязательны. 0 = снимок получен, -1 = нет активного DSP; реализация может потреблять события диагностики. */
|
||||
int (*get_stats)(uint64_t call_id, int* buffer_ms, int* tempo_x100,
|
||||
uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range);
|
||||
};
|
||||
/* Только uasync: сохранить заимствованный ops у ядра (NULL снимает регистрацию), допустимо до запуска чата.
|
||||
* Объект живёт до снятия/уничтожения ядра. Stop/start чата сохраняет регистрацию, состояние звонка — нет. */
|
||||
void call_set_audio_ops(struct UTUN_INSTANCE* inst, const struct call_audio_ops* ops);
|
||||
|
||||
// Отправить Opus-кадр из ДРУГОГО потока (GUI-аудио): post в uasync через trampoline.
|
||||
/* Аргумент call_send_media_trampoline: heap-объект из u_malloc/u_calloc, callback освобождает его через u_free.
|
||||
* Постить в inst->ua; inst должен жить до выполнения. Не заменяет эпохи/лимит TX в call_audio.h.
|
||||
* len=1..256; timing передаётся в call_send_media как есть (без PTS задать timing.pts_us=INT64_MIN). */
|
||||
struct call_media_arg {
|
||||
struct UTUN_INSTANCE* inst;
|
||||
uint64_t call_id;
|
||||
uint16_t len;
|
||||
struct call_capture_time timing;
|
||||
uint8_t opus[256];
|
||||
struct UTUN_INSTANCE* inst; // Заимствованное ядро, живёт до callback.
|
||||
uint64_t call_id; // ID ACTIVE-сессии на момент выполнения callback.
|
||||
uint16_t len; // Число заполненных байтов opus, 1..256.
|
||||
struct call_capture_time timing; // Capture PTS/epoch/codec delay, копия метаданных кадра.
|
||||
uint8_t opus[256]; // Встроенная копия закодированного кадра.
|
||||
};
|
||||
void call_send_media_trampoline(void* arg);
|
||||
|
||||
// ── trampoline-структуры для gui_bridge_post_uasync_fn / uasync_post ──
|
||||
/* Trampoline выполняются в uasync, вызывают одноимённый API и освобождают arg через u_free.
|
||||
* arg — соответствующий ненулевой heap-объект, inst живёт до callback. Постановка не удерживает ядро.
|
||||
* call_start_arg.ch_id должен содержать завершающий NUL; остальные действия используют call_id_arg. */
|
||||
|
||||
struct call_start_arg {
|
||||
struct UTUN_INSTANCE* inst;
|
||||
char ch_id[64];
|
||||
uint64_t peer_node_id;
|
||||
uint64_t call_id;
|
||||
struct UTUN_INSTANCE* inst; // Заимствованное ядро, живёт до callback.
|
||||
char ch_id[64]; // Десятичный ID группы либо dm:<conv_id>, с завершающим NUL.
|
||||
uint64_t peer_node_id; // Ненулевой ID собеседника, не собственного узла.
|
||||
uint64_t call_id; // Новый уникальный ненулевой ID, генерирует вызывающий.
|
||||
};
|
||||
|
||||
struct call_id_arg {
|
||||
struct UTUN_INSTANCE* inst;
|
||||
uint64_t call_id;
|
||||
struct UTUN_INSTANCE* inst; // Заимствованное ядро, живёт до callback.
|
||||
uint64_t call_id; // ID сессии для accept/decline/hangup/continue.
|
||||
};
|
||||
|
||||
void call_start_trampoline (void* arg);
|
||||
|
||||
+45
-16
@@ -48,6 +48,7 @@ static int g_prepared_capture = 0;
|
||||
static int g_aec_enabled = 1; /* по умолчанию: настройка aec_enabled */
|
||||
static struct audio_compressor* g_compressor = NULL;
|
||||
static uint64_t g_tx_generation;
|
||||
static uint64_t g_instance_generation; /* Эпоха привязки; смена звонка её не меняет. */
|
||||
static uint64_t g_capture_epoch;
|
||||
static int64_t g_capture_end = INT64_MIN;
|
||||
static int g_codec_delay;
|
||||
@@ -69,6 +70,7 @@ struct call_audio_tx {
|
||||
struct posted_task task;
|
||||
struct UTUN_INSTANCE* inst;
|
||||
uint64_t call_id, generation, created_tb;
|
||||
uint64_t instance_generation;
|
||||
int len;
|
||||
struct call_capture_time timing;
|
||||
uint8_t opus[256];
|
||||
@@ -77,9 +79,20 @@ struct call_audio_tx {
|
||||
static void call_audio_send(void* arg) {
|
||||
struct call_audio_tx* tx = arg;
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
int current = tx->generation == g_tx_generation;
|
||||
int owner = tx->instance_generation == g_instance_generation && g_inst == tx->inst;
|
||||
int current = owner && tx->generation == g_tx_generation;
|
||||
if (current) call_audio_duration_add(&g_post_time, (get_time_tb() - tx->created_tb) * 100);
|
||||
if (g_tx_pending) --g_tx_pending;
|
||||
/* Старый звонок той же привязки всё ещё занимает свой слот очереди. */
|
||||
if (owner) {
|
||||
if (g_tx_pending) --g_tx_pending;
|
||||
else DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: TX pending underflow call=%llu instance_epoch=%llu", CALL_AUDIO_ID,
|
||||
(unsigned long long)tx->call_id, (unsigned long long)tx->instance_generation);
|
||||
}
|
||||
if (!current) DEBUG_TRACE(DEBUG_CATEGORY_CALL,
|
||||
"%s: stale TX call=%llu owner=%d instance_epoch=%llu/%llu call_epoch=%llu/%llu pending=%u", CALL_AUDIO_ID,
|
||||
(unsigned long long)tx->call_id, owner, (unsigned long long)tx->instance_generation,
|
||||
(unsigned long long)g_instance_generation, (unsigned long long)tx->generation,
|
||||
(unsigned long long)g_tx_generation, g_tx_pending);
|
||||
int send = current && g_active && !g_ending && g_inst == tx->inst && g_call_id == tx->call_id;
|
||||
if (get_time_tb() - tx->created_tb > 2000) {
|
||||
send = 0;
|
||||
@@ -104,33 +117,41 @@ static int call_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t*
|
||||
}
|
||||
|
||||
int call_audio_init(struct UTUN_INSTANCE* inst) {
|
||||
if (!inst) return -1;
|
||||
if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: init missing instance", CALL_AUDIO_ID); return -1; }
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
if (g_inst == inst) { pthread_mutex_unlock(&g_mtx); return 0; }
|
||||
g_inst = inst; g_tx_pending = 0;
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
if (g_inst != inst) {
|
||||
unsigned int previous_pending = g_tx_pending;
|
||||
g_inst = inst; g_tx_pending = 0;
|
||||
++g_instance_generation;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: initialized inst=%p instance_epoch=%llu previous_pending=%u", CALL_AUDIO_ID,
|
||||
(void*)inst, (unsigned long long)g_instance_generation, previous_pending);
|
||||
}
|
||||
call_set_audio_ops(inst, &g_ops);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: initialized", CALL_AUDIO_ID);
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void call_audio_destroy(struct UTUN_INSTANCE* inst) {
|
||||
if (!inst || g_inst != inst) return;
|
||||
call_set_audio_ops(inst, NULL);
|
||||
if (!inst) return;
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
g_inst = NULL;
|
||||
if (inst->call_audio_ops == &g_ops) call_set_audio_ops(inst, NULL);
|
||||
if (g_inst == inst) {
|
||||
g_inst = NULL;
|
||||
++g_instance_generation;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: destroyed inst=%p instance_epoch=%llu pending=%u", CALL_AUDIO_ID,
|
||||
(void*)inst, (unsigned long long)g_instance_generation, g_tx_pending);
|
||||
}
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: destroyed", CALL_AUDIO_ID);
|
||||
}
|
||||
|
||||
/* ── uasync-поток: приём медиа (кладём кодированный кадр в jitter-буфер) ── */
|
||||
|
||||
void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id,
|
||||
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len, const struct call_capture_time* timing) {
|
||||
(void)inst; (void)seq;
|
||||
(void)seq;
|
||||
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
if (g_active && call_id == g_call_id && g_vj && opus && len > 0) {
|
||||
if (inst == g_inst && g_active && call_id == g_call_id && g_vj && opus && len > 0) {
|
||||
call_jitter_push(g_vj, opus, len, ts_ms, (int64_t)(get_time_tb() / 10), timing);
|
||||
}
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
@@ -145,7 +166,7 @@ static int call_audio_start_impl(uint64_t call_id, int prepared) {
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
if (g_active && g_call_id == call_id) {
|
||||
if (g_inst && g_active && g_call_id == call_id) {
|
||||
int same_mode = g_prepared_capture == prepared;
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
if (!same_mode) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: capture policy cannot change within active call", CALL_AUDIO_ID);
|
||||
@@ -156,6 +177,12 @@ static int call_audio_start_impl(uint64_t call_id, int prepared) {
|
||||
call_audio_release(g_call_id);
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
}
|
||||
if (!g_inst) {
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: start rejected without attached core call=%llu", CALL_AUDIO_ID,
|
||||
(unsigned long long)call_id);
|
||||
return -1;
|
||||
}
|
||||
|
||||
opus_codec_encoder_t* enc = opus_codec_encoder_create(CALL_AUDIO_SAMPLE_RATE, 1);
|
||||
if (!enc) {
|
||||
@@ -212,6 +239,7 @@ static int call_audio_start_impl(uint64_t call_id, int prepared) {
|
||||
|
||||
/* компрессор микрофона (AGC) — как в рации: выравнивает тихий проводной микрофон
|
||||
* (HAL не применяет AGC на проводном входе). lookahead=0 — без задержки. */
|
||||
int compressor_gain = chat_setting_get_int(g_inst, "call_compressor_max_gain_db", 15);
|
||||
struct audio_compressor* ac = audio_compressor_create();
|
||||
if (ac) {
|
||||
audio_compressor_config_t accfg = {0};
|
||||
@@ -220,7 +248,7 @@ static int call_audio_start_impl(uint64_t call_id, int prepared) {
|
||||
accfg.block_duration_ms = CALL_AUDIO_FRAME_MS;
|
||||
accfg.lookback_ms = 200;
|
||||
accfg.lookahead_ms = 0;
|
||||
accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "call_compressor_max_gain_db", 15);
|
||||
accfg.max_gain_db = (float)compressor_gain;
|
||||
accfg.rise_rate_per_sec = (float)chat_setting_get_int(g_inst, "compressor_rise_rate", 2);
|
||||
accfg.target_level = 0.25f; /* -12 dBFS — как у голосовых сообщений */
|
||||
if (audio_compressor_configure(ac, &accfg) != 0) { audio_compressor_destroy(ac); ac = NULL; }
|
||||
@@ -252,7 +280,7 @@ static int call_audio_start_impl(uint64_t call_id, int prepared) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx aec=%d delay=%d compressor=%s gain=%d", CALL_AUDIO_ID,
|
||||
(unsigned long long)call_id, g_aec ? 1 : 0, g_aec_delay_frames,
|
||||
ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable",
|
||||
chat_setting_get_int(g_inst, "call_compressor_max_gain_db", 15));
|
||||
compressor_gain);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -452,6 +480,7 @@ static int call_audio_feed_impl(uint64_t call_id, const int16_t* pcm, int count,
|
||||
}
|
||||
task->inst = inst; task->call_id = call_id;
|
||||
task->generation = g_tx_generation; task->created_tb = get_time_tb();
|
||||
task->instance_generation = g_instance_generation;
|
||||
task->timing = (struct call_capture_time){capture_pts_us, g_capture_epoch, (uint32_t)g_codec_delay};
|
||||
task->len = len;
|
||||
memcpy(task->opus, opus, (size_t)len);
|
||||
|
||||
+38
-16
@@ -1,7 +1,8 @@
|
||||
#ifndef CALL_AUDIO_H
|
||||
#define CALL_AUDIO_H
|
||||
|
||||
// call_audio.h — сервисный аудио-движок P2P-звонка (единый для desktop/Android/headless).
|
||||
// call_audio.h — общий аудио-движок P2P-звонка для frontend с захватом/воспроизведением (desktop/Android).
|
||||
// TCP-сервис готового PCM имеет собственные кодеки и описан отдельно в call_headless.h.
|
||||
//
|
||||
// Владеет Opus codec, адаптивным джиттер-буфером (call_jitter: кодированные
|
||||
// кадры + SoundTouch time-stretch на pull) и тонами (глитч при сталле / завершение).
|
||||
@@ -11,12 +12,13 @@
|
||||
// call_audio_feed_pcm.
|
||||
//
|
||||
// Потоки:
|
||||
// - call_audio_init / destroy — uasync-поток, lifecycle ядра;
|
||||
// - call_audio_start / begin_end / release — GUI-поток (по ACCEPTED/ENDED);
|
||||
// - call_audio_feed_pcm / pull_pcm — по одному аудио-потоку на направление;
|
||||
// - call_audio_on_media / get_stats — uasync-поток (из call.c).
|
||||
// Общие указатели (encoder/decoder/vj/tones) защищены мьютексом; encoder и
|
||||
// decoder — только аудио-поток (decoder дёргается из call_jitter_pull через коллбэк),
|
||||
// call_jitter_push — только uasync-поток.
|
||||
// Mutex защищает привязку, TX и снимки общих указателей. Encoder используется capture worker,
|
||||
// decoder — playback worker (из call_jitter_pull через callback), call_jitter_push — uasync.
|
||||
// Pull использует снимок указателей вне mutex: перед release/сменой ID владелец останавливает оба I/O-направления.
|
||||
|
||||
#include <stdint.h>
|
||||
#include "call_jitter.h"
|
||||
@@ -31,17 +33,28 @@ extern "C" {
|
||||
#define CALL_AUDIO_FRAME_MS 20
|
||||
#define CALL_AUDIO_FRAME_SAMPLES (CALL_AUDIO_SAMPLE_RATE * CALL_AUDIO_FRAME_MS / 1000) /* 960 */
|
||||
|
||||
/* Зарегистрировать аудио-движок в call.c (идемпотентно). */
|
||||
/* uasync-поток: привязать общий для процесса движок к inst, допустимо до запуска чата.
|
||||
* Регистрация принадлежит ядру и сохраняется при stop/start чата: повторный init не требуется.
|
||||
* Повтор для того же inst идемпотентен: не меняет эпоху владельца и занятые TX-слоты.
|
||||
* Перед освобождением inst/ua остановить I/O, снять привязку через destroy в uasync.
|
||||
* Лимит8 TX-задач относится к текущей привязке, возраст до отправки в call.c ограничен200мс.
|
||||
* Задачи прежних привязок не отправляют медиа и не меняют
|
||||
* её счётчик/позднюю статистику; uasync освобождает их после callback либо при destroy. 0 = успех, -1 = нет inst. */
|
||||
int call_audio_init(struct UTUN_INSTANCE* inst);
|
||||
/* uasync-поток: снять регистрацию inst; текущий владелец также инвалидирует TX-привязку.
|
||||
* Поздний detach прежнего inst не затрагивает нового владельца. Codec/jitter освобождаются отдельно через stop/release. */
|
||||
void call_audio_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* GUI-поток: создать codec + jitter + tones. Старый API принимает raw PCM и использует core AEC.
|
||||
* Для desktop нужен start_prepared: AEC и stereo reference принадлежат VoiceAudioIo. 0 = успех. */
|
||||
* Нужен привязанный через init живой inst; без него -1. Для desktop нужен start_prepared:
|
||||
* AEC и stereo reference принадлежат VoiceAudioIo. Повтор активного ID сохраняет DSP;
|
||||
* смена raw/prepared для него отклоняется. До смены ID остановить оба I/O-направления. 0 = успех, -1 = ошибка. */
|
||||
int call_audio_start(uint64_t call_id);
|
||||
/* AEC принадлежит платформенному захвату; pull больше не подаёт собственный референс. */
|
||||
/* Контракт start, но AEC принадлежит платформенному захвату; pull не подаёт собственный референс. */
|
||||
int call_audio_start_prepared(uint64_t call_id);
|
||||
|
||||
/* GUI-поток: по CALL_ENDED — начать тон завершения (pull_pcm выдаст 450мс тона). */
|
||||
/* GUI-поток: по CALL_ENDED прекратить TX, pull_pcm выдаст450мс тона, затем тишину.
|
||||
* Сам движок не освобождается: frontend отдельно останавливает I/O и вызывает release. */
|
||||
void call_audio_begin_end(uint64_t call_id);
|
||||
|
||||
/* GUI-поток (после остановки аудиоустройства): освободить все объекты. */
|
||||
@@ -50,19 +63,24 @@ void call_audio_release(uint64_t call_id);
|
||||
/* GUI-поток: остановить текущий активный звонок (удобная обёртка release). */
|
||||
void call_audio_stop(void);
|
||||
|
||||
/* аудио-поток: закодировать PCM-кадр (ровно CALL_AUDIO_FRAME_SAMPLES) и отправить пиру. */
|
||||
/* Capture worker: ровно960 raw-сэмплов; capture_pts_us — монотонная метка первого либо INT64_MIN.
|
||||
* Encoder работает синхронно, PCM не сохраняется; отличие известного PTS от ожидаемого более2мс меняет capture_epoch.
|
||||
* 0 = Opus поставлен в uasync, -1 = ошибка/отказ очереди; постановка не подтверждает доставку пиру.
|
||||
* Для mute использовать feed_pcm_muted с настоящим микрофоном, не занулять raw-вход до AEC/AGC. */
|
||||
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count, int64_t capture_pts_us);
|
||||
/* Mute после AEC/AGC: фильтр получает настоящий микрофон, Opus — нулевой PCM. */
|
||||
/* Подготовленный PCM и маска mute (960 элементов); AEC уже выполнен до этого API. */
|
||||
/* Подготовленный PCM и обязательная маска mute (960 элементов); AEC уже выполнен до этого API.
|
||||
* Маска применяется после core AGC, результат/PTS — как у feed_pcm. */
|
||||
int call_audio_feed_prepared_pcm(uint64_t call_id, const int16_t* pcm, const uint8_t* mute_mask, int64_t capture_pts_us);
|
||||
/* Raw capture: AEC/AGC получают настоящий PCM; muted!=0 зануляет только вход Opus после обработки.
|
||||
* Android передаёт исходный кадр и snapshot mute через JNI; результат/PTS — как у feed_pcm. */
|
||||
int call_audio_feed_pcm_muted(uint64_t call_id, const int16_t* pcm, int count, int muted, int64_t capture_pts_us);
|
||||
|
||||
/* аудио-поток: выдать max_samples финального PCM (jitter + stretch + тоны + тишина).
|
||||
* Активный движок всегда выдаёт полный блок; 0 означает отсутствие движка/ошибку аргументов. */
|
||||
int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples);
|
||||
|
||||
/* uasync-поток (из call.c): принятый Opus-кадр — кладём в джиттер-буфер (call_jitter_push,
|
||||
* декодирование произойдёт на pull в аудио-потоке). */
|
||||
/* uasync-поток (из call.c): кадр текущего inst/активного DSP передаётся в call_jitter_push;
|
||||
* данные копируются, декодирование произойдёт на pull в аудио-потоке. Чужой inst/ID или отсутствие DSP — no-op. */
|
||||
void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id,
|
||||
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len, const struct call_capture_time* timing);
|
||||
|
||||
@@ -77,14 +95,18 @@ int call_audio_get_clock(uint64_t call_id, int64_t now_us, struct call_clock_sna
|
||||
int call_audio_try_clock(uint64_t call_id, int64_t now_us, struct call_clock_snapshot* out);
|
||||
/* Playback worker: старый аппаратный якорь недействителен до progress нового выхода. PCM/JB сохраняются. */
|
||||
void call_audio_suspend_device(uint64_t call_id, uint64_t epoch);
|
||||
/* Любой поток: диагностический снимок без потребления событий CALL_STATS. */
|
||||
/* Любой поток: диагностический снимок без потребления событий CALL_STATS.
|
||||
* out обязателен; 0 = снимок получен, -1 = нет DSP/выхода. При отсутствии DSP out обнуляется. */
|
||||
int call_audio_get_diagnostics(uint64_t call_id, struct call_jitter_diagnostics* out);
|
||||
|
||||
/* uasync-поток: метрики джиттер-буфера для CALL_STATS (dropped/underruns — необязательные). */
|
||||
/* uasync-поток: метрики для CALL_STATS; все выходы необязательны, 0 = успех, -1 = нет DSP (выходы не меняются).
|
||||
* dropped/underruns накопительные; диагностические события и интервальные TX-измерения потребляются/логируются.
|
||||
* Для чтения без потребления событий использовать get_diagnostics. */
|
||||
int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100,
|
||||
uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range);
|
||||
|
||||
/* После остановки указанного направления: сбросить AEC, для playback также устаревший jitter/PCM.
|
||||
/* После остановки указанного направления: сбросить AEC; capture также сбрасывает AGC и увеличивает capture_epoch.
|
||||
* Playback сбрасывает устаревший jitter/PCM по call_jitter_reset (последний запас Opus сохраняется) и playback clock epoch.
|
||||
* Capture и playback могут работать в разных потоках, по одному writer на направление.
|
||||
* Release допустим только после остановки обоих. */
|
||||
void call_audio_reset_io(uint64_t call_id, int playback);
|
||||
|
||||
+10
-8
@@ -1,5 +1,6 @@
|
||||
/* Снимок часов входящего аудио. PTS принадлежит шкале захвата удалённой стороны.
|
||||
* epoch — локальная эпоха устройства; source_epoch — поколение удалённого encoder.
|
||||
* epoch — локальная эпоха playback-карты: меняется при reset, а не при каждом переоткрытии устройства.
|
||||
* source_epoch — поколение захвата отправителя в текущем PCM-участке.
|
||||
* valid=0 запрещает продвижение видео по этому снимку. measured отличает аппаратную
|
||||
* отметку от оценки backend. Тишина устройства/тоны удерживают PTS, remote mute — нет. */
|
||||
#ifndef CALL_CLOCK_H
|
||||
@@ -8,15 +9,16 @@
|
||||
/* pts_us — первый захваченный сэмпл; INT64_MIN означает отсутствие метки.
|
||||
* delay_samples — задержка Opus от encoder. epoch меняется при разрыве захвата. */
|
||||
struct call_capture_time {
|
||||
int64_t pts_us;
|
||||
uint64_t epoch;
|
||||
uint32_t delay_samples;
|
||||
int64_t pts_us; // Первый сэмпл PCM до кодирования, монотонные мкс отправителя; INT64_MIN — неизвестно.
|
||||
uint64_t epoch; // Ненулевое поколение захвата внутри звонка; 0 — источник без достоверной шкалы.
|
||||
uint32_t delay_samples; // Задержка encoder в сэмплах 48кГц; вычитается при построении шкалы decoder.
|
||||
};
|
||||
struct call_clock_snapshot {
|
||||
uint64_t epoch, source_epoch, played_samples, written_samples;
|
||||
int64_t pts_us;
|
||||
uint32_t held_back_us;
|
||||
uint64_t epoch, source_epoch, played_samples, written_samples; // Поколения карты/источника и позиции PCM, см. ниже.
|
||||
// played_samples — оценка выведенной позиции; written_samples — весь PCM pull/silence, включая тоны.
|
||||
int64_t pts_us; // PTS удалённого захвата; -1 до первого валидного снимка, при valid=0 может быть прежним.
|
||||
uint32_t held_back_us; // Насколько текущий PTS удержан от регрессии (допускается до 100мс).
|
||||
uint32_t rate_ppm; // Фактический шаг source PTS в текущем PCM-участке; 1000000 = 1x, 0 = удержание.
|
||||
int valid, measured;
|
||||
int valid, measured; // valid разрешает использовать PTS; measured описывает последний принятый аппаратный якорь.
|
||||
};
|
||||
#endif
|
||||
+24
-18
@@ -22,43 +22,49 @@
|
||||
class CallDelay {
|
||||
public:
|
||||
using Input = unsigned (*)(void*, int16_t*, unsigned);
|
||||
enum class Mode { Direct, Stretch, Drain };
|
||||
enum class Mode { Direct, Stretch, Drain }; // Прямая выдача, подача в SoundTouch, выдача готового DSP-хвоста.
|
||||
struct Stats {
|
||||
Mode mode = Mode::Direct;
|
||||
double tempo = 1, pending = 0; // Заданный темп и ещё не выданная длительность исходного PCM в сэмплах.
|
||||
uint64_t cuts = 0, entered = 0, exited = 0;
|
||||
uint64_t cuts = 0, entered = 0, exited = 0; // Удалённые Direct-сэмплы и переходы в/из SoundTouch с последнего reset.
|
||||
unsigned phase = 0; // В Direct: ошибка относительно 20мс выхода; 0 — границы совпадают.
|
||||
};
|
||||
// Оба callback обязательны; контексты заимствованы до уничтожения. После создания проверить valid().
|
||||
CallDelay(Input input, void* input_context, soundtouch::SourceCallback source, void* source_context);
|
||||
CallDelay(const CallDelay&) = delete;
|
||||
CallDelay& operator=(const CallDelay&) = delete;
|
||||
// Ошибка обработки необратима для объекта; reset не восстанавливает valid, нужен новый экземпляр.
|
||||
bool valid() const { return !failed; }
|
||||
// Выдать 0..count PCM-сэмплов, без дополнения тишиной; queued не включает уже принятый этим объектом PCM.
|
||||
// output/count должны быть валидны; нехватка input даёт короткую выдачу, ошибка делает valid()==false.
|
||||
unsigned pull(int16_t* output, unsigned count, uint64_t queued, unsigned reserve);
|
||||
// Удалить PCM/DSP-хвост, счётчики и фазу; внешнюю PTS-карту владелец сбрасывает отдельно.
|
||||
void reset();
|
||||
void silence(unsigned count); // Тишина устройства тоже сдвигает выходную фазу.
|
||||
// Снимок единственного worker; pending — исходные сэмплы, ещё не выданные, не размер готового DSP-выхода.
|
||||
Stats stats() const;
|
||||
|
||||
private:
|
||||
static constexpr unsigned Rate = 48000, Frame = 960, Capacity = 8192, Context = 16;
|
||||
Input input;
|
||||
Input input; // Заимствованный reader исходного PCM.
|
||||
void* inputContext;
|
||||
soundtouch::SourceCallback source;
|
||||
soundtouch::SourceCallback source; // Заимствованный получатель происхождения выходного PCM.
|
||||
void* sourceContext;
|
||||
soundtouch::SoundTouch st;
|
||||
std::array<float, Capacity> raw{};
|
||||
soundtouch::SoundTouch st; // DSP только режима Stretch; готовый выход дочитывается в Drain.
|
||||
std::array<float, Capacity> raw{}; // Кольцо исходного PCM, включая контекст перед границей Direct/Stretch.
|
||||
struct Span { soundtouch::SourceSpan value{soundtouch::SourceKind::Copy}; };
|
||||
std::array<Span, 32> spans{};
|
||||
unsigned spanHead = 0, spanCount = 0;
|
||||
uint64_t written = 0, cursor = 0, generated = 0, consumed = 0;
|
||||
uint64_t stBase = 0, stFed = 0, entry = 0, queuedInput = 0;
|
||||
uint64_t deviceSamples = 0;
|
||||
int64_t phaseGoal = 0;
|
||||
double playedSource = 0, tempo = 1, cutPhase = 0;
|
||||
struct Bucket { uint64_t time = UINT64_MAX; double low = 0, inventory = 0; } buckets[4];
|
||||
unsigned trim = 0;
|
||||
bool failed = false, firstSpan = false, lastCopy = false;
|
||||
Mode mode = Mode::Direct;
|
||||
uint64_t cuts = 0, entered = 0, exited = 0;
|
||||
std::array<Span, 32> spans{}; // Произведение DSP/Direct, ещё не учтённое consume.
|
||||
unsigned spanHead = 0, spanCount = 0; // Начало/число действующих spans.
|
||||
uint64_t written = 0, cursor = 0, generated = 0, consumed = 0; // Принятый вход, позиция raw, произведённый/выданный выход.
|
||||
uint64_t stBase = 0, stFed = 0, entry = 0, queuedInput = 0; // Начало DSP-шкалы, переданный вход, граница входа, внешний FIFO.
|
||||
uint64_t deviceSamples = 0; // Выданный PCM плюс объявленная локальная тишина.
|
||||
int64_t phaseGoal = 0; // Целевая разность cursor-deviceSamples, кратная Frame.
|
||||
double playedSource = 0, tempo = 1, cutPhase = 0; // Позиция источника на выходе, темп, дробный долг удаления Direct.
|
||||
struct Bucket { uint64_t time = UINT64_MAX; double low = 0, inventory = 0; } buckets[4]; // Четыре интервала по 50мс.
|
||||
unsigned trim = 0; // Уже выданный Direct-контекст, который нужно убрать с начала SoundTouch.
|
||||
bool failed = false, firstSpan = false, lastCopy = false; // Необратимая ошибка; ожидаем первый DSP-span; хвост — Copy.
|
||||
Mode mode = Mode::Direct; // Текущий способ выдачи PCM.
|
||||
uint64_t cuts = 0, entered = 0, exited = 0; // Счётчики Direct-удалений и переходов режима с последнего reset.
|
||||
|
||||
bool fill();
|
||||
bool pump();
|
||||
|
||||
+42
-15
@@ -42,6 +42,8 @@ struct call_audio_client {
|
||||
uint8_t send_buf[AUDIO_SEND_BUF_SIZE];
|
||||
int send_len;
|
||||
int send_offset;
|
||||
uint32_t pcm_dropped; /* Целые исходящие PCM-кадры, отброшенные при переполнении. */
|
||||
uint64_t drop_log_tb; /* Ограничение частоты WARN: не чаще раза в секунду. */
|
||||
int closing;
|
||||
struct call_audio* ca;
|
||||
struct call_audio_client* next;
|
||||
@@ -66,6 +68,7 @@ struct call_audio {
|
||||
uint32_t c_dec_frames;
|
||||
uint32_t c_enc_dropped;
|
||||
uint32_t c_dec_dropped;
|
||||
uint32_t c_pcm_dropped;
|
||||
};
|
||||
|
||||
static struct call_audio* ca_of(struct UTUN_INSTANCE* inst) {
|
||||
@@ -76,12 +79,24 @@ static void ca_close_client(struct call_audio_client* cli);
|
||||
|
||||
/* ── отправка фрейма клиенту (с буферизацией как в control-сокете) ── */
|
||||
|
||||
/* Принимает целый ограниченный фрейм; short write сохраняет его остаток перед следующими кадрами. */
|
||||
static void ca_send_raw(struct call_audio_client* cli, const uint8_t* data, size_t len) {
|
||||
struct call_audio* ca = cli->ca;
|
||||
if (!cli || cli->closing || cli->fd == SOCKET_INVALID || !data || len == 0) return;
|
||||
struct call_audio* ca = cli->ca;
|
||||
if (cli->send_len > 0) {
|
||||
size_t space = sizeof(cli->send_buf) - (size_t)cli->send_len;
|
||||
if (len > space) return;
|
||||
if (len > space) {
|
||||
++cli->pcm_dropped; ++ca->c_pcm_dropped;
|
||||
uint64_t now = get_time_tb();
|
||||
if (cli->pcm_dropped == 1 || now - cli->drop_log_tb >= 10000) {
|
||||
cli->drop_log_tb = now;
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL,
|
||||
"%s: PCM frame dropped call=%016llx fd=%d frame=%zu queued=%d offset=%d capacity=%zu dropped=%u total=%u",
|
||||
CH_ID, (unsigned long long)cli->call_id, (int)cli->fd, len, cli->send_len, cli->send_offset,
|
||||
sizeof(cli->send_buf), cli->pcm_dropped, ca->c_pcm_dropped);
|
||||
}
|
||||
return;
|
||||
}
|
||||
memcpy(cli->send_buf + cli->send_len, data, len);
|
||||
cli->send_len += (int)len;
|
||||
return;
|
||||
@@ -103,15 +118,21 @@ static void ca_send_raw(struct call_audio_client* cli, const uint8_t* data, size
|
||||
}
|
||||
}
|
||||
|
||||
/* Собрать header+PCM до обращения к очереди, чтобы overflow не оставил часть фрейма. */
|
||||
static void ca_write_frame(struct call_audio_client* cli, uint8_t type, uint64_t call_id,
|
||||
const uint8_t* data, uint16_t len) {
|
||||
uint8_t hdr[CALL_AUDIO_HDR_SIZE];
|
||||
hdr[0] = type;
|
||||
memcpy(hdr + 1, &call_id, 8);
|
||||
hdr[9] = (uint8_t)(len >> 8);
|
||||
hdr[10] = (uint8_t)(len & 0xFF);
|
||||
ca_send_raw(cli, hdr, sizeof(hdr));
|
||||
if (len) ca_send_raw(cli, data, len);
|
||||
if (len > CA_PCM_FRAME_BYTES || (len && !data)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: invalid output frame call=%016llx type=%u len=%u data=%p",
|
||||
CH_ID, (unsigned long long)call_id, type, len, (const void*)data);
|
||||
return;
|
||||
}
|
||||
uint8_t frame[CALL_AUDIO_HDR_SIZE + CA_PCM_FRAME_BYTES];
|
||||
frame[0] = type;
|
||||
memcpy(frame + 1, &call_id, 8);
|
||||
frame[9] = (uint8_t)(len >> 8);
|
||||
frame[10] = (uint8_t)(len & 0xFF);
|
||||
if (len) memcpy(frame + CALL_AUDIO_HDR_SIZE, data, len);
|
||||
ca_send_raw(cli, frame, CALL_AUDIO_HDR_SIZE + len);
|
||||
}
|
||||
|
||||
/* ── Opus-кодеки (одна активная звонка за раз) ── */
|
||||
@@ -198,8 +219,9 @@ static void ca_parse_frames(struct call_audio_client* cli) {
|
||||
CH_ID, flen, CA_PCM_FRAME_BYTES);
|
||||
} else {
|
||||
opus_codec_encoder_t* enc = ca_encoder(ca, call_id);
|
||||
int16_t pcm[CA_FRAME_SAMPLES]; memcpy(pcm, data, sizeof(pcm)); // wire header 11 байт не выровнен для int16_t
|
||||
uint8_t opus[256];
|
||||
int olen = enc ? opus_codec_encode(enc, (const int16_t*)data, CA_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1;
|
||||
int olen = enc ? opus_codec_encode(enc, pcm, CA_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1;
|
||||
if (olen <= 0) {
|
||||
ca->c_enc_dropped++;
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: encode failed (olen=%d) call_id=%016llx",
|
||||
@@ -248,7 +270,8 @@ static void ca_close_client(struct call_audio_client* cli) {
|
||||
cli->closing = 1;
|
||||
if (ca && ca->ua && cli->socket_id) uasync_remove_socket_t(ca->ua, cli->fd);
|
||||
if (cli->fd != SOCKET_INVALID) { socket_close_wrapper(cli->fd); cli->fd = SOCKET_INVALID; }
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client closed call_id=%016llx", CH_ID, (unsigned long long)cli->call_id);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client closed call_id=%016llx pcm_drop=%u",
|
||||
CH_ID, (unsigned long long)cli->call_id, cli->pcm_dropped);
|
||||
}
|
||||
|
||||
static void ca_read_callback(socket_t fd, void* arg) {
|
||||
@@ -346,7 +369,10 @@ int call_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst,
|
||||
|
||||
call_set_media_recv_cb(inst, ca_media_recv, ca);
|
||||
ca->cleanup_timer = uasync_set_timeout(ua, 5000, ca, ca_cleanup, "ca_cleanup");
|
||||
if (!ca->cleanup_timer) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: cleanup timer scheduling failed", CH_ID);
|
||||
if (!ca->cleanup_timer) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: cleanup timer scheduling failed", CH_ID);
|
||||
call_headless_destroy(inst); return -1;
|
||||
}
|
||||
|
||||
ca->running = 1;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket listening on %s:%u", CH_ID, bind_ip, bound_port);
|
||||
@@ -361,7 +387,7 @@ uint16_t call_headless_get_port(struct UTUN_INSTANCE* inst) {
|
||||
|
||||
void call_headless_destroy(struct UTUN_INSTANCE* inst) {
|
||||
struct call_audio* ca = ca_of(inst);
|
||||
if (!ca || !ca->running) return;
|
||||
if (!ca) return;
|
||||
ca->running = 0;
|
||||
if (ca->cleanup_timer) { uasync_cancel_timeout(ca->ua, ca->cleanup_timer); ca->cleanup_timer = NULL; }
|
||||
call_set_media_recv_cb(inst, NULL, NULL);
|
||||
@@ -377,6 +403,7 @@ void call_headless_destroy(struct UTUN_INSTANCE* inst) {
|
||||
c = n;
|
||||
}
|
||||
ca->clients = NULL; ca->client_count = 0;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket destroyed (enc=%u dec=%u enc_drop=%u dec_drop=%u)",
|
||||
CH_ID, ca->c_enc_frames, ca->c_dec_frames, ca->c_enc_dropped, ca->c_dec_dropped);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket destroyed (enc=%u dec=%u enc_drop=%u dec_drop=%u pcm_drop=%u)",
|
||||
CH_ID, ca->c_enc_frames, ca->c_dec_frames, ca->c_enc_dropped, ca->c_dec_dropped, ca->c_pcm_dropped);
|
||||
inst->call_audio = NULL; u_free(ca);
|
||||
}
|
||||
@@ -11,6 +11,16 @@
|
||||
// type = 0x02 FRAME (PCM int16 mono 48k, 960 сэмплов = 1920 байт)
|
||||
// клиент → ядро = PCM захвата (кодируется и шлётся пиру);
|
||||
// ядро → клиент = PCM воспроизведения (декодированный Opus пира)
|
||||
// call_id хранится в порядке байтов хоста, len — старший байт первым; PCM — native int16_t.
|
||||
// Это отдельный сервис с кодеками instance, без DSP/jitter движка call_audio.h.
|
||||
// HELLO выбирает call_id для приёма; передаваемый FRAME содержит call_id своего звонка.
|
||||
// До16 TCP-клиентов; если несколько выбрали один звонок, RX получает первый в списке (последний подключённый).
|
||||
// Encoder/decoder создаются лениво и заменяются при смене call_id. Отправка требует ACTIVE в call.c.
|
||||
// Все вызовы и callbacks выполняются в потоке ua; клиент обеспечивает темп и воспроизведение.
|
||||
// Исходящая очередь принимает header+PCM целиком; при переполнении отбрасывается целый кадр с WARN/счётчиком.
|
||||
// Частичная отправка продолжается перед следующим кадром. Буферы ввода/вывода по64КиБ на клиента;
|
||||
// parser ждёт полный фрейм, неизвестный type/len>2048 закрывает соединение, неверный размер PCM отбрасывается.
|
||||
// Закрытые клиенты освобождаются таймером каждые500мс; до очистки занимают слот лимита16.
|
||||
#ifndef CALL_HEADLESS_H
|
||||
#define CALL_HEADLESS_H
|
||||
|
||||
@@ -28,8 +38,13 @@ struct UTUN_INSTANCE;
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Запустить IPv4/TCP listener bind_ip:port и зарегистрировать приём Opus в call.c.
|
||||
* ua — тот же loop, что inst->ua; inst должен иметь call_init. port=0 выбирает ОС.
|
||||
* Один работающий listener на inst; повтор init не меняет адрес. 0 = успех, -1 = ошибка.
|
||||
* При ошибке вызывающий выполняет destroy, включая частично созданный контекст. */
|
||||
int call_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst,
|
||||
const char* bind_ip, int port);
|
||||
/* Снять callback, отменить таймер и освободить listener/клиентов/кодеки до call_destroy и destroy ua. NULL допустим. */
|
||||
void call_headless_destroy(struct UTUN_INSTANCE* inst);
|
||||
// port=0 при init выбирает ОС; 0 для остановленной службы или при ошибке.
|
||||
uint16_t call_headless_get_port(struct UTUN_INSTANCE* inst);
|
||||
|
||||
+34
-16
@@ -11,43 +11,61 @@ extern "C" {
|
||||
/* Звонок: FIFO Opus, запас 20мс..max_reserve_ms по изменению задержки доставки за 10 секунд и догон после паузы устройства.
|
||||
* push — сетевой поток, pull/decode/CallDelay — единственный аудиопоток.
|
||||
* Старт/восстановление ждут запас плюс запрошенный блок; при выдаче PCM запас не резервируется повторно.
|
||||
* destroy — после остановки обоих потребителей. get_stats — любой поток. */
|
||||
* destroy — после остановки всех обращений. get_stats/diagnostics/clock — любой поток,
|
||||
* внутренний mutex защищает их снимки. Все операции playback (включая silence/reset)
|
||||
* сериализует владелец; mutex не делает допустимыми два одновременных pull. */
|
||||
#define CALL_JITTER_FRAME_MS 20
|
||||
#define CALL_JITTER_MIN_RESERVE_MS 20
|
||||
#define CALL_JITTER_MAX_FRAMES 750 /* 10с перебоя + 5с запаса на накопление и восстановление */
|
||||
/* Заимствованный context, encoded/len и выход/capacity (сэмплы, до 960); вернуть 1..capacity или <=0 при ошибке.
|
||||
* Callback вызывается синхронно на pull без сетевого mutex; encoded действителен только до возврата. */
|
||||
typedef int (*call_jitter_decode_fn)(void*, const uint8_t*, int, int16_t*, int);
|
||||
struct call_jitter_range { int min_ms, max_ms, reserve_ms; };
|
||||
struct call_jitter_range { int min_ms, max_ms, reserve_ms; }; // Экстремумы полной глубины и отдельная цель доставки, мс.
|
||||
struct call_jitter;
|
||||
struct call_jitter_diagnostics {
|
||||
int depth_ms, reserve_ms, tempo_x100, error_result, error_len;
|
||||
uint32_t decode_errors, budget_hits;
|
||||
int resumed, catching_up, waiting;
|
||||
int encoded_ms, processing_ms, pcm_samples;
|
||||
int64_t arrival_spread_ms;
|
||||
uint32_t underruns;
|
||||
int delay_mode, tempo_ppm;
|
||||
unsigned phase_samples;
|
||||
uint64_t cut_samples, stretch_entries, stretch_exits;
|
||||
int depth_ms, reserve_ms, tempo_x100, error_result, error_len; // Глубина перед pull, цель, темп 100=1x, последний decode-отказ.
|
||||
uint32_t decode_errors, budget_hits; // События до reset_events: отказы decoder и исчерпание лимита попыток pull.
|
||||
int resumed, catching_up, waiting; // Событие полного восстановления; темп>1.05; последнее ожидание/короткая выдача.
|
||||
int encoded_ms, processing_ms, pcm_samples; // Текущий FIFO, исходный PCM в DSP (мс), сэмплы последнего pull.
|
||||
int64_t arrival_spread_ms; // Размах вариации доставки за окно, без ограничения верхним reserve.
|
||||
uint32_t underruns; // Число коротких pull после накопления; начальное ожидание не учитывается.
|
||||
int delay_mode, tempo_ppm; // CallDelay::Mode и темп 1000000=1x.
|
||||
unsigned phase_samples; // Ошибка Direct-фазы относительно границы 960 сэмплов.
|
||||
uint64_t cut_samples, stretch_entries, stretch_exits; // Счётчики CallDelay с последнего reset.
|
||||
};
|
||||
/* Снимок и сброс событий под mutex; логировать после выхода из mutex голосового стека. */
|
||||
/* j/out обязательны. reset_events!=0 обнуляет только decode_errors, budget_hits и resumed.
|
||||
* Логировать после выхода из mutex голосового стека; dropped/underruns этим чтением не сбрасываются. */
|
||||
void call_jitter_get_diagnostics(struct call_jitter*, struct call_jitter_diagnostics*, int reset_events);
|
||||
/* decode обязателен; arg заимствован до destroy, max_reserve_ms=500..2000. NULL при ошибке. */
|
||||
struct call_jitter* call_jitter_create(call_jitter_decode_fn decode, void* arg, int max_reserve_ms);
|
||||
/* Освободить объект; NULL допустим. */
|
||||
void call_jitter_destroy(struct call_jitter* j);
|
||||
/* sent_ms — timestamp кадра на проводе (uint32 с переполнением), received_ms — локальные монотонные часы.
|
||||
* Абсолютное смещение часов пиров не влияет на оценку джиттера. */
|
||||
* Абсолютное смещение часов пиров не влияет на оценку джиттера. enc (1..512 байт) и timing копируются;
|
||||
* timing=NULL означает отсутствие PTS. FIFO сохраняет порядок push, сортировки/дедупликации нет.
|
||||
* При заполнении удаляется старейший кадр; неверные аргументы/регрессия received_ms отклоняются с WARN. */
|
||||
void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len, uint32_t sent_ms, int64_t received_ms,
|
||||
const struct call_capture_time* timing);
|
||||
/* Только после остановки pull: убрать устаревший PCM, сохранить свежий сетевой запас. */
|
||||
/* Только после остановки playback-операций: удалить PCM/DSP, увеличить локальную epoch,
|
||||
* оставить последние ceil(reserve/20) Opus-кадров. Оценка доставки и суммарные dropped/underruns сохраняются. */
|
||||
void call_jitter_reset(struct call_jitter* j);
|
||||
/* out — max_samples mono int16 @48кГц; вернуть 0..max_samples, без дополнения тишиной.
|
||||
* Старт/восстановление ждут reserve плюс размер запроса. Decode ограничен min(750, 8+max_samples/480) попытками.
|
||||
* NULL/неположительный размер дают 0. Вызывающий учитывает дополненную локальную тишину через silence. */
|
||||
int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples);
|
||||
/* Playback worker: учесть локальную тишину/тон после pull и прогресс устройства. */
|
||||
/* Playback worker: учесть только дополненные после pull локальные сэмплы; их PTS удерживается. */
|
||||
void call_jitter_silence(struct call_jitter* j, unsigned samples);
|
||||
/* Абсолютная PCM-позиция и локальный срок её вывода; epoch из clock, at_us/now_us на одной монотонной шкале.
|
||||
* Условия свежести/ошибок карты — в call_timeline.h. */
|
||||
void call_jitter_progress(struct call_jitter* j, uint64_t epoch, uint64_t position, int64_t at_us, int64_t now_us, int measured);
|
||||
/* Приостановить аппаратный якорь до следующего progress, сохранив PCM и текущую epoch. */
|
||||
void call_jitter_suspend_device(struct call_jitter* j, uint64_t epoch);
|
||||
/* Любой поток: снимок часов, включая epoch при valid=0. */
|
||||
/* Любой поток: снимок часов, включая epoch при valid=0; NULL j/out оставляет out без изменений. */
|
||||
void call_jitter_clock(struct call_jitter* j, int64_t now_us, struct call_clock_snapshot* out);
|
||||
/* Неблокирующее чтение: 1 — снимок получен, 0 — mutex занят. Аргументы должны быть валидны. */
|
||||
int call_jitter_try_clock(struct call_jitter* j, int64_t now_us, struct call_clock_snapshot* out);
|
||||
/* Необязательные выходы: FIFO+processing (мс исходной речи), темп 100=1x, накопительные потери/недоборы и range.
|
||||
* Счётчики чтением не сбрасываются; dropped включает удаление старых FIFO-кадров при overflow/reset. NULL j — no-op. */
|
||||
void call_jitter_get_stats(struct call_jitter* j, int* depth_ms, int* tempo_x100,
|
||||
uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range);
|
||||
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
// Десять секундных интервалов. Отдельные экземпляры для глубины очереди и задержки доставки.
|
||||
// Десять секундных интервалов (текущий и девять предыдущих), без внутренней синхронизации.
|
||||
// Владелец передаёт неубывающие неотрицательные now/received_ms. Отдельные экземпляры
|
||||
// для глубины очереди и задержки доставки: возраст PCM сам по себе не меняет сетевой reserve.
|
||||
struct call_jitter_window {
|
||||
struct bucket { int64_t second = -1, low = 0, high = 0; } buckets[10];
|
||||
int64_t low = 0, high = 0;
|
||||
int reserve = CALL_JITTER_MIN_RESERVE_MS;
|
||||
int max_reserve = 1000;
|
||||
int64_t last_received_ms = -1, transit_ms = 0;
|
||||
uint32_t last_sent_ms = 0;
|
||||
struct bucket { int64_t second = -1, low = 0, high = 0; } buckets[10]; // -1 — интервал ещё не наблюдался.
|
||||
int64_t low = 0, high = 0; // Экстремумы depth/transit в действующих интервалах, мс.
|
||||
int reserve = CALL_JITTER_MIN_RESERVE_MS; // clamp(high-low, 20, max_reserve), мс.
|
||||
int max_reserve = 1000; // Владелец задаёт предел не ниже CALL_JITTER_MIN_RESERVE_MS.
|
||||
int64_t last_received_ms = -1, transit_ms = 0; // Последний приём и накопленная вариация задержки, мс.
|
||||
uint32_t last_sent_ms = 0; // Предыдущий wire timestamp; соседние интервалы должны укладываться в int32.
|
||||
// Добавить depth (мс, допускается отрицательный transit); true — начался другой секундный интервал.
|
||||
bool observe(int64_t now_ms, int64_t depth) {
|
||||
int64_t second = now_ms / 1000;
|
||||
auto& b = buckets[second % 10];
|
||||
@@ -27,6 +30,7 @@ struct call_jitter_window {
|
||||
}
|
||||
|
||||
// Разность интервалов прихода и отправки: смещение часов исключается, uint32 timestamp разворачивается.
|
||||
// Первый приём задаёт transit=0; возврат и обновлённые поля — как у observe().
|
||||
bool observe_arrival(int64_t received_ms, uint32_t sent_ms) {
|
||||
if (last_received_ms >= 0) transit_ms += received_ms - last_received_ms - (int32_t)(sent_ms - last_sent_ms);
|
||||
last_received_ms = received_ms; last_sent_ms = sent_ms;
|
||||
|
||||
+39
-28
@@ -1,43 +1,54 @@
|
||||
/* Внутреннее состояние call.c/call_video_transport.c; доступ только из uasync. */
|
||||
/* Внутренний реестр звонков: call.c владеет ctx/сессиями, video_transport и process
|
||||
* видеопорта обращаются к ним только из uasync. Workers используют публичный port API.
|
||||
* Сессия размещена в data внешнего ll_entry; sessions индексирует call_id, а не пару group/peer.
|
||||
* Find возвращает заимствованный указатель, в том числе на ENDED до удаления записи.
|
||||
* При ENDED аудио-TX/video останавливаются сразу; CM/запись могут ждать END_ACK/таймаут.
|
||||
* Обычное удаление снимает запись из реестра и освобождает entry через call_soon;
|
||||
* call_destroy отменяет callbacks и освобождает оставшиеся в реестре записи синхронно.
|
||||
* Не переносить указатели ctx/session в другой поток или отложенную команду: передавать call_id
|
||||
* и повторять find в uasync. Описание публичных действий/событий — call.h/call_proto.h. */
|
||||
#ifndef CALL_PRIV_H
|
||||
#define CALL_PRIV_H
|
||||
#include "call.h"
|
||||
#include "../../lib/ll_queue.h"
|
||||
|
||||
/* IDLE — только создание; RINGING бывает у обеих ролей, WAIT_DECISION — ожидание force
|
||||
* при телефонной занятости; ENDED в реестре уже не занимает линию и не принимает медиа. */
|
||||
enum call_state { CALL_ST_IDLE, CALL_ST_OUTGOING, CALL_ST_RINGING, CALL_ST_ACTIVE, CALL_ST_ENDED, CALL_ST_WAIT_DECISION };
|
||||
enum call_role { CALL_ROLE_CALLER, CALL_ROLE_CALLEE };
|
||||
struct call_video_transport;
|
||||
struct call_session {
|
||||
struct ll_entry ll;
|
||||
uint64_t call_id, group_id, peer_node_id;
|
||||
uint8_t role, state, reason;
|
||||
struct ll_entry ll; /* Не используется; реальный queue entry находится перед s == entry->data. */
|
||||
uint64_t call_id, group_id, peer_node_id; /* Неизменные ID попытки, группы маршрутизации и собеседника. */
|
||||
uint8_t role, state, reason; /* call_role/call_state; reason — CALL_REASON_* после перехода в ENDED. */
|
||||
uint8_t force; /* вызывающий подтвердил обход телефонной занятости */
|
||||
uint64_t started_tb; /* неизменный deadline попытки, включая выбор продолжения */
|
||||
uint8_t end_subcmd;
|
||||
uint64_t end_started_tb;
|
||||
struct call_ctx *ctx;
|
||||
struct CONN_MGR_HANDLE *cm_handle;
|
||||
void *ring_timer, *invite_timer;
|
||||
uint8_t no_route_strikes;
|
||||
void *traffic_timer, *teardown_timer, *stats_timer;
|
||||
uint8_t connection_reported, connection_type;
|
||||
uint64_t connection_next_hop, last_media_tb;
|
||||
uint16_t tx_media_seq;
|
||||
struct ll_queue *tx_q;
|
||||
struct queue_waiter_handle send_waiter;
|
||||
uint32_t c_tx_frames, c_rx_frames, c_tx_dropped, c_rx_dropped;
|
||||
uint64_t tx_wait_total_ms;
|
||||
uint32_t tx_wait_frames, tx_wait_max_ms;
|
||||
struct call_video_transport *video;
|
||||
uint64_t started_tb; /* Начало попытки get_time_tb (100мкс); 60с отсчитываются отсюда, включая WAIT_DECISION. */
|
||||
uint8_t end_subcmd; /* Финальная команда для повтора; 0 — нет своей команды либо уже принят END_ACK. */
|
||||
uint64_t end_started_tb; /* Начало ENDED в tb; предел повтора своей команды — 20с. */
|
||||
struct call_ctx *ctx; /* Заимствованный владелец, живёт до удаления сессии из реестра. */
|
||||
struct CONN_MGR_HANDLE *cm_handle; /* Собственный запрос оптимизации пути; ACTIVE не требует готового CM. */
|
||||
void *ring_timer, *invite_timer; /* Caller: 60с/повтор INVITE 300мс; callee: ring_timer — watchdog INVITE до 2с. */
|
||||
uint8_t no_route_strikes; /* Последовательные INVITE ticks без маршрута; сбрасывается при наличии маршрута. */
|
||||
void *traffic_timer, *teardown_timer, *stats_timer; /* RX watchdog 20с, завершение/END retry, путь и ACTIVE-статистика 1с. */
|
||||
uint8_t connection_reported, connection_type; /* Было событие CONNECTION / последний router path.type (0..3). */
|
||||
uint64_t connection_next_hop, last_media_tb; /* Кэш next hop и время последнего RX; watchdog использует traffic_timer. */
|
||||
uint16_t tx_media_seq; /* Следующий sequence аудиопакета с wrap uint16; расходуется до выделения TX entry. */
|
||||
struct ll_queue *tx_q; /* Собственная FIFO готовых audio packets [svc][media]; до 750, переполнение удаляет голову. */
|
||||
struct queue_waiter_handle send_waiter; /* Отменяемое одноразовое ожидание места в router audio send_q. */
|
||||
uint32_t c_tx_frames, c_rx_frames, c_tx_dropped, c_rx_dropped; /* TX принят router / валидный RX / TX overflow / RX не ведётся. */
|
||||
uint64_t tx_wait_total_ms; /* Сумма времени audio enqueue->drain, включая отказ router; сброс при отчёте stats. */
|
||||
uint32_t tx_wait_frames, tx_wait_max_ms; /* Число drained кадров/максимальное ожидание за тот же отчётный интервал. */
|
||||
struct call_video_transport *video; /* Собственное ленивое состояние видеотрека; cleanup обнуляет и освобождает его. */
|
||||
};
|
||||
struct call_ctx {
|
||||
struct UTUN_INSTANCE *inst;
|
||||
int initialized;
|
||||
struct ll_queue *sessions;
|
||||
uint32_t c_calls_started, c_calls_ended;
|
||||
call_media_recv_fn media_recv_fn;
|
||||
void *media_recv_arg;
|
||||
const struct call_audio_ops *audio_ops;
|
||||
struct UTUN_INSTANCE *inst; /* Заимствованное ядро, владеющее ctx в inst->call; audio_ops хранится отдельно у ядра. */
|
||||
int initialized; /* Сигналинг привязан; 1 также во время регистрации media services/отката call_init. */
|
||||
struct ll_queue *sessions; /* Собственные queue entries с call_session в data и 8-байтовым индексом call_id. */
|
||||
uint32_t c_calls_started, c_calls_ended; /* Созданные исходящие / все переходы в ENDED; это не парные счётчики. */
|
||||
call_media_recv_fn media_recv_fn; /* Один необязательный headless RX callback, вызываемый в uasync. */
|
||||
void *media_recv_arg; /* Заимствованный arg до замены callback/destroy; пересоздание ctx сбрасывает привязку. */
|
||||
};
|
||||
/* Найти call_id, включая ENDED: заимствованная сессия либо NULL (также при NULL ctx/sessions).
|
||||
* Только uasync; не проверяет state/group/peer. После удаления/destroy указатель использовать нельзя. */
|
||||
struct call_session *call_session_find(struct call_ctx *ctx, uint64_t call_id);
|
||||
#endif
|
||||
+23
-20
@@ -5,6 +5,9 @@
|
||||
// Приём/отправка через etcp_router. На проводе первый байт payload = subcmd.
|
||||
// Сигналинг и медиа требуют ROUTE_CRYPTO_SIGN|ROUTE_CRYPTO_ENCRYPT: шифрование тела
|
||||
// и подпись заголовка/шифртекста между конечными узлами, независимо от маршрута.
|
||||
// Структуры упакованы без padding; целые поля копируются в порядке байтов хоста, без htons/ntohs.
|
||||
// Здесь фиксирован один аудиотрек, согласования аудиоформата нет: отправитель объявляет Opus48k mono/20мс/32кбит/с.
|
||||
// call.c проверяет размер/число треков, но не настраивает decoder по полям descriptor; кодеки создаёт frontend.
|
||||
#ifndef CALL_PROTO_H
|
||||
#define CALL_PROTO_H
|
||||
|
||||
@@ -16,7 +19,7 @@ extern "C" {
|
||||
|
||||
/* ── подкоманды (первый байт payload после svc_id) ── */
|
||||
enum {
|
||||
CALL_SUBCMD_INVITE = 0x01, /* {call_id:8, group_id:8, tracks:[]} */
|
||||
CALL_SUBCMD_INVITE = 0x01, /* call_pkt_invite: call_id, group_id, num_tracks, force, expires_us, один track */
|
||||
CALL_SUBCMD_RINGING = 0x02, /* {call_id:8} */
|
||||
CALL_SUBCMD_ACCEPT = 0x03, /* {call_id:8} */
|
||||
CALL_SUBCMD_DECLINE = 0x04, /* {call_id:8, reason:1} */
|
||||
@@ -28,7 +31,7 @@ enum {
|
||||
CALL_SUBCMD_MEDIA = 0x10, /* call_pkt_media + payload[] */
|
||||
};
|
||||
|
||||
/* ── треки и кодеки (модель расширяема под видео) ── */
|
||||
/* Типы треков; в этом протоколе INVITE/MEDIA отправляют только AUDIO. Видео согласуется отдельно. */
|
||||
enum { CALL_TRACK_AUDIO = 0x01, CALL_TRACK_VIDEO = 0x02 };
|
||||
enum { CALL_CODEC_OPUS = 0x01 };
|
||||
|
||||
@@ -48,21 +51,21 @@ enum {
|
||||
#pragma pack(push, 1)
|
||||
|
||||
struct call_pkt_track {
|
||||
uint8_t type; /* CALL_TRACK_AUDIO / CALL_TRACK_VIDEO */
|
||||
uint8_t codec; /* CALL_CODEC_OPUS */
|
||||
uint32_t sample_rate;
|
||||
uint8_t channels;
|
||||
uint32_t bitrate;
|
||||
uint8_t type; /* Отправитель ставит CALL_TRACK_AUDIO. */
|
||||
uint8_t codec; /* CALL_CODEC_OPUS. */
|
||||
uint32_t sample_rate; /* 48000 Гц. */
|
||||
uint8_t channels; /* 1, mono. */
|
||||
uint32_t bitrate; /* 32000 бит/с; информационное поле, не настройка удалённого encoder. */
|
||||
};
|
||||
|
||||
struct call_pkt_invite {
|
||||
uint8_t subcmd; /* CALL_SUBCMD_INVITE */
|
||||
uint64_t call_id;
|
||||
uint64_t group_id;
|
||||
uint8_t num_tracks;
|
||||
uint8_t num_tracks; /* Ровно1; размер пакета = sizeof(call_pkt_invite), 38 байт. */
|
||||
uint8_t force; /* 1 = явно подтверждённое продолжение при телефонной занятости */
|
||||
int64_t expires_us; /* Unix microseconds; устаревшая кодограмма не начинает вызов */
|
||||
struct call_pkt_track tracks[1]; /* tracks[num_tracks] */
|
||||
int64_t expires_us; /* Unix us с NTP-поправкой: отправка now+2с, приём требует now<expires<=now+4с. */
|
||||
struct call_pkt_track tracks[1]; /* Один descriptor (11 байт), без списка произвольной длины. */
|
||||
};
|
||||
|
||||
struct call_pkt_ringing { uint8_t subcmd; uint64_t call_id; };
|
||||
@@ -75,14 +78,14 @@ struct call_pkt_end_ack { uint8_t subcmd; uint64_t call_id; };
|
||||
|
||||
struct call_pkt_media {
|
||||
uint8_t subcmd; /* CALL_SUBCMD_MEDIA */
|
||||
uint64_t call_id;
|
||||
uint8_t track_id;
|
||||
uint16_t seq;
|
||||
uint32_t ts_ms;
|
||||
int64_t capture_pts_us; /* INT64_MIN — источник не предоставляет capture clock. */
|
||||
uint64_t capture_epoch;
|
||||
uint32_t codec_delay_samples;
|
||||
/* payload[] */
|
||||
uint64_t call_id; /* ID существующего ACTIVE-звонка; peer/group проверяет call.c. */
|
||||
uint8_t track_id; /* CALL_TRACK_AUDIO. */
|
||||
uint16_t seq; /* Счётчик отправителя с переполнением; call_audio FIFO не сортирует по seq. */
|
||||
uint32_t ts_ms; /* Монотонное время постановки в TX FIFO, миллисекунды modulo 2^32. */
|
||||
int64_t capture_pts_us; /* Монотонный PTS первого входного сэмпла; INT64_MIN — часы неизвестны. */
|
||||
uint64_t capture_epoch; /* Ненулевая эпоха capture при известном PTS; смена означает разрыв. */
|
||||
uint32_t codec_delay_samples; /* Opus lookahead при48кГц, не более48000. */
|
||||
/* Далее непустой Opus payload; call_send_media ограничивает общий media-пакет1024 байтами. */
|
||||
};
|
||||
|
||||
#pragma pack(pop)
|
||||
@@ -96,11 +99,11 @@ struct call_pkt_media {
|
||||
|
||||
/* ── таймауты и ёмкости (timebase 0.1ms) ── */
|
||||
#define CALL_RING_TIMEOUT_TB 600000 /* 60с от начала, включая ожидание решения */
|
||||
#define CALL_INVITE_RETRY_TB 3000 /* 300мс — кодограмма INVITE (OUTGOING + RINGING) */
|
||||
#define CALL_INVITE_RETRY_TB 3000 /* 300мс — кодограмма INVITE (OUTGOING/RINGING/WAIT_DECISION) */
|
||||
#define CALL_RING_DEAD_TB 20000 /* 2с — вызываемый: нет кодограммы → вызов сорван */
|
||||
#define CALL_NO_ROUTE_MAX_STRIKES 20 /* 20 × 300мс ≈ 6с без маршрута → fail-fast CONNECT_FAIL */
|
||||
#define CALL_NO_TRAFFIC_TB 200000 /* 20с — нет входящих медиа → HANGUP */
|
||||
#define CALL_TEARDOWN_DELAY_TB 5000 /* 500мс — задержка разрыва conn_mgr после завершения */
|
||||
#define CALL_TEARDOWN_DELAY_TB 5000 /* Не используется; без своей end-команды CM/роль callee удерживают запись2.1с. */
|
||||
#define CALL_END_RETRY_TB 3000 /* 300мс — повтор финальной команды до END_ACK */
|
||||
#define CALL_END_TIMEOUT_TB 200000 /* 20с — предел удержания завершённой сессии без END_ACK */
|
||||
#define CALL_MAX_TX_FRAMES 750 /* 15с: 10с перебоя + 5с запаса */
|
||||
|
||||
+26
-14
@@ -12,27 +12,38 @@
|
||||
|
||||
class CallTimeline {
|
||||
public:
|
||||
// Начать пустую карту локальной эпохи; сбрасывает ошибки, позиции, PTS и аппаратный якорь.
|
||||
void reset(uint64_t epoch);
|
||||
// Зарегистрировать 1..8192 входных сэмплов 48кГц до callback source; INT64_MIN/epoch=0 не дают валидный PTS.
|
||||
void feed(int64_t pts_us, unsigned samples, uint64_t source_epoch);
|
||||
// Callback происхождения DSP/CallDelay: Copy/Mix должны описывать непрерывный выход из зарегистрированного входа.
|
||||
void source(const soundtouch::SourceSpan& span);
|
||||
// Перенести только реально извлечённый PCM на шкалу устройства; повторно учитывать его в silence нельзя.
|
||||
void read(unsigned samples);
|
||||
// Дописать локальную тишину/тоны, удерживая последний PTS. До первого валидного источника PTS остаётся недостоверным.
|
||||
void silence(unsigned samples);
|
||||
// Якорь локальных часов: position — абсолютная PCM-позиция от reset, at_us — срок её вывода,
|
||||
// received_us — время наблюдения. Чужая epoch игнорируется; регрессия позиции/received или position>written
|
||||
// запрещает часы до reset. Будущий at_us учитывает очередь устройства; measured — аппаратная метка или оценка.
|
||||
void progress(uint64_t epoch, uint64_t position, int64_t at_us, int64_t received_us, bool measured);
|
||||
// Приостановить якорь своей эпохи до progress, сохранив PCM/PTS/позиции; прежние ошибки не сбрасывает.
|
||||
void suspend_device(uint64_t epoch);
|
||||
// Снимок с продвижением/очисткой карты. Якорь старше 250мс, suspend, ошибки карты или неизвестный PTS дают valid=0.
|
||||
// PTS не регрессирует: откат до 100мс удерживается, больший откат делает снимок недостоверным.
|
||||
call_clock_snapshot clock(int64_t now_us);
|
||||
private:
|
||||
struct Span {
|
||||
uint64_t first = 0, epoch = 0;
|
||||
unsigned count = 0;
|
||||
double pts = 0, end = 0;
|
||||
bool valid = false;
|
||||
uint64_t first = 0, epoch = 0; // Первый индекс участка в его кольце и поколение удалённого захвата.
|
||||
unsigned count = 0; // Число сэмплов участка.
|
||||
double pts = 0, end = 0; // PTS начала и конца, мкс; равны при удержании локальной тишины.
|
||||
bool valid = false; // Есть достоверная шкала одного источника.
|
||||
Span() = default;
|
||||
Span(uint64_t f, uint64_t e, unsigned n, double p, double q, bool v)
|
||||
: first(f), epoch(e), count(n), pts(p), end(q), valid(v) {}
|
||||
};
|
||||
template<size_t N> struct Ring {
|
||||
std::array<Span, N> data{};
|
||||
size_t head = 0, count = 0;
|
||||
std::array<Span, N> data{}; // Фиксированное кольцо; push при заполнении возвращает false.
|
||||
size_t head = 0, count = 0; // Начало и число действующих участков.
|
||||
Span& front() { return data[head]; }
|
||||
const Span& back() const { return data[(head + count - 1) % N]; }
|
||||
void pop() { head = (head + 1) % N; --count; }
|
||||
@@ -43,14 +54,15 @@ private:
|
||||
}
|
||||
};
|
||||
Ring<256> input; // >5с входа; SoundTouch звонка держит лишь несколько блоков.
|
||||
Ring<1024> ready;
|
||||
Ring<2048> output;
|
||||
uint64_t inputWritten = 0, generated = 0, consumed = 0, written = 0, epoch = 0;
|
||||
uint64_t anchorPosition = 0, minimumPosition = 0;
|
||||
int64_t anchorTime = 0, anchorReceived = 0, lastPts = -1;
|
||||
double endPts = 0;
|
||||
uint64_t endEpoch = 0;
|
||||
int sourceError = 0;
|
||||
Ring<1024> ready; // Произведённый DSP-выход, ещё не учтённый read.
|
||||
Ring<2048> output; // PCM устройства до подтверждённого продвижения; overflow запрещает часы до reset.
|
||||
uint64_t inputWritten = 0, generated = 0, consumed = 0, written = 0, epoch = 0; // Вход/DSP/прочитано/устройство/локальная эпоха.
|
||||
uint64_t anchorPosition = 0, minimumPosition = 0; // Последний device-якорь и монотонная нижняя граница сыгранного PCM.
|
||||
int64_t anchorTime = 0, anchorReceived = 0, lastPts = -1; // Срок/приём якоря и последний валидный PTS; -1 — ещё нет PTS.
|
||||
double endPts = 0; // Конец последнего записанного участка, удерживаемый silence.
|
||||
uint64_t endEpoch = 0; // Поколение источника конца output.
|
||||
int sourceError = 0; // Отложенная ошибка DSP-карты; логируется при read.
|
||||
// Необратимые до reset ошибки output/device; наличие/тип якоря; валидность конца output.
|
||||
bool outputFailed = false, deviceFailed = false, haveAnchor = false, measured = false, endValid = false;
|
||||
static double at(const Span& s, double position);
|
||||
const Span* findInput(uint64_t position) const;
|
||||
|
||||
@@ -12,17 +12,19 @@ extern "C" {
|
||||
|
||||
struct call_tones;
|
||||
|
||||
/* Создать выключенный генератор; NULL при нехватке памяти. Управление и render сериализует владелец. */
|
||||
struct call_tones* call_tones_create(void);
|
||||
void call_tones_destroy(struct call_tones* t);
|
||||
void call_tones_destroy(struct call_tones* t); // Освободить после остановки render; NULL допустим.
|
||||
void call_tones_reset(struct call_tones* t); /* в тишину */
|
||||
|
||||
void call_tones_glitch_start(struct call_tones* t); /* запустить/продолжить цикл глитча */
|
||||
void call_tones_glitch_stop(struct call_tones* t); /* прекратить глитч (тишина) */
|
||||
|
||||
void call_tones_ended_start(struct call_tones* t); /* 3 нисходящих тона, один раз */
|
||||
void call_tones_ended_start(struct call_tones* t); /* 3 нисходящих тона 700/500/400Гц по 150мс; повторный start начинает заново */
|
||||
int call_tones_ended_pending(struct call_tones* t); /* 1 = тон завершения ещё звучит */
|
||||
|
||||
/* Выдать до max_samples сэмплов (тон или тишина). Возвращает записанное число. */
|
||||
/* При валидных аргументах заполняет ровно max_samples (тон/паузы/тишина после завершения).
|
||||
* Возвращает max_samples; 0 при NULL t/out или max_samples<=0. Остальные операции с NULL t безопасны. */
|
||||
int call_tones_render(struct call_tones* t, int16_t* out, int max_samples);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
+36
-18
@@ -1,7 +1,10 @@
|
||||
/* Общий контракт видеотрека. Все операции выполняет владелец в uasync-потоке.
|
||||
/* Общий контракт видеотрека: чистые проверки/сериализация доступны любому потоку,
|
||||
* один receiver принадлежит одному владельцу, без внутренней синхронизации.
|
||||
* Сетевой transport использует receiver из uasync; codec workers используют проверки формата.
|
||||
* Кодеки: H.264 Baseline/Constrained Baseline, H.265 Main (Main/High tier), 8 bit,
|
||||
* 4:2:0, progressive, без B-кадров/reorder. DPB ограничен уровнем потока.
|
||||
* Поколение меняется только при смене формата/камеры; bitrate не меняет поколение.
|
||||
* Поля format неизменны внутри generation; его увеличивают при смене формата/камеры.
|
||||
* Текущий bitrate encoder не входит в format и не меняет generation.
|
||||
* KEY: config_length:4BE + Annex B configuration + Annex B access unit (как uAV2).
|
||||
* Приём хранит один частичный кадр. Готовый view заимствован до следующего push/reset.
|
||||
* Очередью готовых кадров, decoder и показом по audio clock владеет вызывающий. */
|
||||
@@ -31,29 +34,31 @@ extern "C" {
|
||||
* Поля — независимые верхние границы, не обещание поддержки их сочетания:
|
||||
* выбранный режим дополнительно проверяется открытием платформенного codec. */
|
||||
struct call_video_limit {
|
||||
uint16_t width, height, fps;
|
||||
uint32_t pixels_per_second, maximum_bps;
|
||||
uint16_t width, height, fps; // Верхние границы осей (16..4096) и частоты (1..60); нулевой профиль — все поля нулевые.
|
||||
uint32_t pixels_per_second, maximum_bps; // Ограничение width*height*fps и целевого bitrate (300000..4000000 бит/с).
|
||||
uint8_t profiles; // Маска режимов: AVC Baseline/CB; HEVC Main с Main/High tier. Приём каждого проверяется отдельно.
|
||||
uint8_t level;
|
||||
};
|
||||
struct call_video_caps { struct call_video_limit encode[2], decode[2]; };
|
||||
struct call_video_format {
|
||||
uint64_t generation;
|
||||
uint16_t width, height, fps, rotation;
|
||||
uint8_t codec, level;
|
||||
uint64_t generation; // Ненулевой, монотонно возрастающий номер формата в одном направлении звонка.
|
||||
uint16_t width, height, fps, rotation; // Чётные размеры 16..4096, 1..60 fps, поворот отображения 0/90/180/270 градусов.
|
||||
uint8_t codec, level; // CALL_VIDEO_H264/H265 и AVC level_idc / HEVC general_level_idc.
|
||||
uint32_t maximum_bps; // Предел целевого bitrate; не обещание мгновенного размера выхода/CPB.
|
||||
uint8_t profile;
|
||||
uint8_t profile; // Один режим 1/2, не маска: AVC Baseline/CB либо HEVC Main с Main/High tier.
|
||||
};
|
||||
struct call_video_frame {
|
||||
uint64_t call_id, frame_id;
|
||||
int64_t pts_us;
|
||||
uint64_t call_id, frame_id; // Ненулевые ID; frame_id возрастает сквозь поколения, пропуск требует KEY.
|
||||
int64_t pts_us; // Capture MONOTONIC отправителя: 0..INT64_MAX/2, строго возрастает сквозь поколения.
|
||||
struct call_video_format format;
|
||||
uint32_t size, offset, length;
|
||||
uint8_t flags;
|
||||
const uint8_t *data;
|
||||
uint32_t size, offset, length; // Размер всего кадра, смещение и размер данного фрагмента; полный кадр: offset=0, length=size.
|
||||
uint8_t flags; // Только CALL_VIDEO_KEY либо 0.
|
||||
const uint8_t *data; // Начало payload данного фрагмента, уже со смещением offset; владение задаёт вызывающий API.
|
||||
int64_t received_us; // Только локальная RX port метка MONOTONIC; по сети не передаётся.
|
||||
};
|
||||
|
||||
/* 1 — все limits структурно корректны, включая полностью нулевые CAPS; 0 — ошибка/NULL.
|
||||
* Не проверяет реальные codec и совместимость сочетания независимых границ. */
|
||||
int call_video_caps_valid(const struct call_video_caps *caps);
|
||||
/* После успешных платформенных проб width×height и height×width расширяет обе осевые
|
||||
* границы limit до длинной стороны, сохраняя pixel-rate/FPS/bitrate/profile/level.
|
||||
@@ -61,34 +66,47 @@ int call_video_caps_valid(const struct call_video_caps *caps);
|
||||
* 0 — успех, -1 — неверный limit/codec (limit не меняется). */
|
||||
int call_video_limit_orientations(struct call_video_limit *limit, int codec);
|
||||
/* format задаёт generation/размер/FPS/rotation/maximum_bps, level=0, profile=0 для auto.
|
||||
* codec=0: H.265, затем H.264. Успех: codec (1/2), заполняются codec и минимальный level.
|
||||
* codec=0: H.265, затем H.264. Успех: codec (1/2), заполняются codec/profile и минимальный level.
|
||||
* 0 — нет совместимого режима, -1 — неверные аргументы; на отказе format не меняется. */
|
||||
int call_video_choose(const struct call_video_caps *sender, const struct call_video_caps *receiver,
|
||||
struct call_video_format *format);
|
||||
/* Проверка уже выбранного/полученного режима, включая оба направленных CAPS и level. */
|
||||
/* 1 — формат допустим для sender.encode и receiver.decode, 0 — нет/неверный аргумент. */
|
||||
int call_video_format_supported(const struct call_video_caps *sender, const struct call_video_caps *receiver,
|
||||
const struct call_video_format *format);
|
||||
/* 1/0: проверка полей и таблиц level по padded geometry/FPS/bitrate, без открытия codec. */
|
||||
int call_video_format_valid(const struct call_video_format *format);
|
||||
/* Нормативный предел DPB в кадрах по coded geometry и level; 0 при неверном уровне. */
|
||||
unsigned call_video_dpb_limit(int codec, unsigned level, unsigned width, unsigned height);
|
||||
/* 1/0: ненулевые HRD bitrate (бит/с) и cpb (биты) в пределах таблицы уровня.
|
||||
* nal!=0 включает NAL-множитель; для HEVC используется консервативная таблица Main tier. */
|
||||
int call_video_hrd_valid(int codec, unsigned level, int nal, uint64_t bitrate, uint64_t cpb);
|
||||
/* Сравнение всех полей format, включая generation; оба указателя обязательны. */
|
||||
int call_video_same_format(const struct call_video_format *a, const struct call_video_format *b);
|
||||
/* CAPS: четыре 16-байтовых limits (encode AVC/HEVC, decode AVC/HEVC), integers BE.
|
||||
* 0/-1; при ошибке выход не меняется. Parse требует ровно CALL_VIDEO_CAPS_BYTES. */
|
||||
int call_video_caps_encode(const struct call_video_caps *caps, uint8_t out[CALL_VIDEO_CAPS_BYTES]);
|
||||
int call_video_caps_parse(const uint8_t *data, size_t length, struct call_video_caps *out);
|
||||
/* Весь header проверяется до записи/публикации view. Отказ: -1, выход не меняется. */
|
||||
/* Header 56 байт BE + 1..1200 байт payload; size всего кадра <=2 МиБ.
|
||||
* Encode копирует fragment.data[0..length); parse возвращает view внутри data.
|
||||
* Успех 0; весь header проверяется до записи/публикации view. Отказ -1, выход не меняется. */
|
||||
int call_video_fragment_encode(const struct call_video_frame *frame, uint8_t *out, size_t capacity, size_t *length);
|
||||
int call_video_fragment_parse(const uint8_t *data, size_t length, struct call_video_frame *out);
|
||||
|
||||
struct call_video_receiver;
|
||||
/* Создать пустую сборку, изначально требующую KEY; NULL — ошибка выделения. */
|
||||
struct call_video_receiver *call_video_receiver_create(void);
|
||||
/* Destroy/reset допускают NULL. Reset освобождает буфер и забывает ID/PTS/формат. */
|
||||
void call_video_receiver_destroy(struct call_video_receiver *receiver);
|
||||
void call_video_receiver_reset(struct call_video_receiver *receiver);
|
||||
/* 1 — полный кадр, 0 — частичный/пропущенный, -1 — неверный поток; после пропуска нужен KEY.
|
||||
* bytes — строго один пакет, уже проверенный на принадлежность call_id/peer/group владельцем.
|
||||
* now_us — монотонное время для ограничения жизни частичной сборки (2с).
|
||||
* fragment — валидный заголовок и payload одного фрагмента, call_id/peer/group проверяет владелец.
|
||||
* now_us — неубывающее локальное время; возраст частичной сборки >2с проверяется при push.
|
||||
* Фрагменты одного кадра идут без разрывов offset; повтор/перестановка сбрасывают сборку.
|
||||
* KEY здесь проверяется только по envelope; NAL проверяет call_video_bitstream/платформа.
|
||||
* Новый формат принимается только с KEY. Старые готовые кадры вызывающий сохраняет до их PTS. */
|
||||
int call_video_receiver_push(struct call_video_receiver *receiver, const struct call_video_frame *fragment,
|
||||
uint64_t now_us, struct call_video_frame *complete);
|
||||
/* 1 — нужен независимый KEY (также для NULL), 0 — цепочка сборки непрерывна. */
|
||||
int call_video_receiver_needs_key(const struct call_video_receiver *receiver);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* progressive 8-bit 4:2:0, один spatial/temporal layer, без reorder. DPB — по Annex A.
|
||||
* HEVC допускает dependent segments внутри IDR и явный pic_output_flag=1;
|
||||
* первый слайс независимый, адреса остальных — в границах CTB геометрии SPS.
|
||||
* Результат публикуется целиком только после успешной проверки VPS/SPS/PPS и IDR.
|
||||
* Это проверка нужных полей parameter sets и заголовков IDR, без полного разбора entropy payload;
|
||||
* она не заменяет проверку реальным decoder. Результат публикуется только при успехе.
|
||||
* Все функции доступны worker-потокам; состояния и платформенных объектов нет. */
|
||||
#ifndef CALL_VIDEO_BITSTREAM_H
|
||||
#define CALL_VIDEO_BITSTREAM_H
|
||||
@@ -12,9 +13,13 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
struct call_video_bitstream {
|
||||
unsigned width, height, level, profile, dpb;
|
||||
unsigned width, height, level, profile, dpb; // Видимая геометрия после crop, wire level/profile и SPS DPB в кадрах.
|
||||
};
|
||||
/* codec=1/2, data — полный KEY envelope размером 9..CALL_VIDEO_MAX_FRAME.
|
||||
* 0 — заполнен out, -1 — отказ с логом; out на ошибке не меняется, data заимствован на время вызова. */
|
||||
int call_video_inspect_key(int codec, const uint8_t *data, size_t length, struct call_video_bitstream *out);
|
||||
/* Inspect + сравнение геометрии/profile и actual level <= format.level; 0/-1.
|
||||
* AVC CB принимается и в Baseline envelope. Сам format предварительно валидирует владелец. */
|
||||
int call_video_check_key(const struct call_video_format *format, const uint8_t *data, size_t length);
|
||||
/* KEY-проба 1280x720 либо 1920x1080, включая обратную ориентацию, fps=30 либо 60.
|
||||
* Буфер 8192 байт достаточен; возвращает размер или -1 с диагностикой. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Общий таймер показа видео, один владелец — RX worker. Изображениями не владеет.
|
||||
/* Общий таймер показа видео, один владелец — worker показа. Изображениями не владеет.
|
||||
* poll вызывается для первого готового decoded кадра. queued — все ещё не показанные полные кадры.
|
||||
* SHOW/DROP_ORDER/DROP_LATE потребляют decoded кадр, WAIT сохраняет его. Encoded входы не пропускаются.
|
||||
* DROP_ORDER/DROP_LATE освобождают decoded кадр, SHOW удерживает его до presented; WAIT сохраняет голову.
|
||||
* Сам модуль очереди не меняет. Encoded входы не пропускаются.
|
||||
* received_us ставится на входе RX port, до ожидания worker; все локальные времена — MONOTONIC, не BOOTTIME.
|
||||
* SHOW резервирует один кадр до presented(pts, фактическое время); только ACK продвигает часы.
|
||||
* Короткое опустошение сохраняет расписание. Темп плавно зависит от суммарного запаса кадров.
|
||||
@@ -14,7 +15,7 @@ extern "C" {
|
||||
#endif
|
||||
|
||||
struct call_video_playout_frame {
|
||||
int64_t pts_us, received_us;
|
||||
int64_t pts_us, received_us; // Capture PTS отправителя и локальное время полной RX-сборки, мкс.
|
||||
uint32_t interval_us; // 1000000/format.fps; разрыв capture PTS не создаёт повторную паузу.
|
||||
};
|
||||
enum call_video_playout_action {
|
||||
@@ -22,9 +23,9 @@ enum call_video_playout_action {
|
||||
};
|
||||
struct call_video_playout_event;
|
||||
struct call_video_playout {
|
||||
uint64_t call_id;
|
||||
int anchored, audio_valid;
|
||||
int64_t last_pts, last_deadline, deadline_us;
|
||||
uint64_t call_id; // Для диагностики; не фильтрует входные кадры по звонку.
|
||||
int anchored, audio_valid; // Есть фаза подтверждённого показа / последний clock был valid.
|
||||
int64_t last_pts, last_deadline, deadline_us; // Последний выбранный/пропущенный PTS, фаза и срок следующего poll.
|
||||
int64_t report_us, last_show_us, interval_min, interval_max, av_min, av_max, dispatch_max, data_shift;
|
||||
int64_t last_show_pts, last_received, gap_pts, gap_received, gap_dispatch;
|
||||
uint32_t speed_ppm; // Темп аудио + коррекция отставания, максимум 1.6x.
|
||||
@@ -34,7 +35,7 @@ struct call_video_playout {
|
||||
unsigned source_gaps;
|
||||
int64_t source_gap_max; // Большие разрывы capture PTS, уже прошедшие до показа.
|
||||
struct call_video_playout_frame pending; // Единственный выбранный кадр до подтверждения потребителем.
|
||||
int awaiting;
|
||||
int awaiting; // Есть SHOW без соответствующего presented; новые SHOW запрещены.
|
||||
int64_t selected_us, pending_av, head_pts, head_due_us, lead_us, rate_us;
|
||||
int64_t head_show_us; // Плановая презентация головы; GUI ACK корректирует фазу плавно.
|
||||
unsigned queued, reserve; // reserve — будущие кадры сверх головы, включая необходимый pipeline backend.
|
||||
@@ -54,16 +55,20 @@ struct call_video_playout_event {
|
||||
/* Reporter вне timer: форматирует и выводит сохранённое событие через DEBUG категории call. */
|
||||
void call_video_playout_log_event(const struct call_video_playout_event *event);
|
||||
|
||||
/* Локальное MONOTONIC время, мкс (Windows использует get_time_us); не capture PTS удалённого узла. */
|
||||
int64_t call_video_playout_now_us(void);
|
||||
/* Обнулить состояние и logger, задать id. p обязателен; вызывать до первого poll/после остановки владельца. */
|
||||
void call_video_playout_init(struct call_video_playout *p, uint64_t call_id);
|
||||
/* frame==NULL обновляет состояние без снятия временной привязки.
|
||||
* deadline_us — абсолютный срок при WAIT, проверять аудиочасы не реже чем раз в 5мс.
|
||||
* queued учитывает encoded, decoder, decoded и GUI каждый ровно один раз; reserve обычно 1.
|
||||
* p обязателен, now_us — неубывающий MONOTONIC; NULL/invalid audio означает WAIT.
|
||||
* Некорректный PTS/received/interval даёт DROP_ORDER только при valid audio и отсутствии awaiting.
|
||||
* source_epoch не участвует: коррекция аудио не означает разрыв видеокодека. */
|
||||
enum call_video_playout_action call_video_playout_poll(struct call_video_playout *p,
|
||||
const struct call_video_playout_frame *frame, int64_t now_us, const struct call_clock_snapshot *audio,
|
||||
unsigned queued, unsigned reserve);
|
||||
/* Подтверждение выбранного PTS в MONOTONIC. Повторный/чужой ACK не меняет расписание. */
|
||||
/* Подтверждение выбранного PTS в MONOTONIC, не раньше выбора. Повторный/чужой ACK логируется и игнорируется. */
|
||||
void call_video_playout_presented(struct call_video_playout *p, int64_t pts_us, int64_t now_us);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* call_video_port — обмен готовыми видеокадрами между платформенными workers и ядром звонка.
|
||||
* Attach подключает ядро; для активного call_id: open → send/pull/status → close. Перед отправкой настроить encoder через format.
|
||||
* Attach/detach — uasync; остальные API потокобезопасны. До detach остановить пользователей порта.
|
||||
* Attach/detach — uasync; остальные API потокобезопасны. До detach остановить пользователей порта
|
||||
* и снять transport callbacks через close с обработкой команды либо остановку сессии звонка.
|
||||
* Send копирует кадры; pull передаёт буфер вызывающему, освобождение — u_free(). Команды исполняются в uasync.
|
||||
* В процессе один порт: TX до восьми кадров по 2 МиБ, RX до восьми кадров и 4 МиБ суммарно. */
|
||||
#ifndef CALL_VIDEO_PORT_H
|
||||
@@ -24,7 +25,7 @@ struct call_video_port_status {
|
||||
};
|
||||
// ── Привязка к ядру ──
|
||||
|
||||
// Подключить единственное ядро к порту; 0/-1.
|
||||
// Подключить единственное ядро к порту; 0/-1. Повторный attach, даже того же instance, — ошибка.
|
||||
int call_video_port_attach(struct UTUN_INSTANCE *inst);
|
||||
|
||||
// Отменить ожидающий post и освободить очереди; выполнять до уничтожения ядра.
|
||||
@@ -32,7 +33,8 @@ void call_video_port_detach(struct UTUN_INSTANCE *inst);
|
||||
|
||||
// ── Открытие и закрытие видеотрека ──
|
||||
|
||||
// Скопировать локальные capabilities и запланировать настройку видеотрека; 0/-1, не подтверждение готовности пира.
|
||||
// Скопировать CAPS и запланировать настройку; 0/-1. Active-call проверяется асинхронно.
|
||||
// Повтор open того же id сохраняет RX/TX/счётчики/wake; другой id очищает их.
|
||||
int call_video_port_open(uint64_t id, const struct call_video_caps *caps);
|
||||
|
||||
// Закрыть текущий id и очистить его очереди; чужой id игнорируется.
|
||||
@@ -54,7 +56,9 @@ int call_video_port_set_wake(uint64_t id, void (*wake)(void *), void *arg);
|
||||
|
||||
// ── Настройка кодера и исходящие кадры ──
|
||||
|
||||
// Передать фактический формат и битрейт открытого encoder; формат копируется. 0 — команда принята, -1 — отказ.
|
||||
// Передать формат/битрейт открытого encoder; 0 — команда принята, -1 — отказ.
|
||||
// Перед сменой формата владелец при необходимости ждёт tx_idle; сама команда не ждёт drain.
|
||||
// Итог проверять по status.error/configured_generation; 0 не подтверждает настройку транспорта.
|
||||
int call_video_port_format(uint64_t id, const struct call_video_format *format, uint32_t bps);
|
||||
|
||||
// Подтвердить команду status.rate_generation/rate_request: bps — применённый битрейт, 0 — отказ encoder. 0/-1.
|
||||
@@ -62,9 +66,12 @@ int call_video_port_applied(uint64_t id, uint64_t generation, uint64_t request,
|
||||
|
||||
#define CALL_VIDEO_PORT_TX_FRAMES 8u
|
||||
#define CALL_VIDEO_PORT_TX_BYTES (CALL_VIDEO_PORT_TX_FRAMES * CALL_VIDEO_MAX_FRAME)
|
||||
/* Атомарно копирует готовую пачку одного звонка. 1 — принята целиком, 0 — mailbox занят,
|
||||
/* Атомарно копирует готовую пачку одного звонка/формата с возрастающими frame_id/PTS.
|
||||
* 1 — принята целиком, 0 — mailbox занят,
|
||||
* -1 — ошибка; на 0/-1 ни один кадр не принят. count: 1..CALL_VIDEO_PORT_TX_FRAMES.
|
||||
* Входные кадры всегда остаются у вызывающего. При 0 повторить ту же пачку после освобождения порта. */
|
||||
* Входные кадры всегда остаются у вызывающего. При 0 повторить ту же пачку после освобождения порта.
|
||||
* 1 подтверждает копирование в mailbox; transport проверяет формат/цепочку позже.
|
||||
* Transport busy сохраняет непринятый хвост, ошибка освобождает его и увеличивает key_serial. */
|
||||
int call_video_port_send(const struct call_video_frame *frames, unsigned count);
|
||||
|
||||
// ── Входящие кадры ──
|
||||
|
||||
+14
-10
@@ -1,7 +1,9 @@
|
||||
/* Регулятор исходящего видеотрека: чистое состояние без таймеров, транспорта и encoder.
|
||||
* Владелец вызывает step каждые 50мс с согласованными окнами доставки/производства 500мс.
|
||||
* Все размеры — уникальные байты сжатого видео без заголовков. Held A/V не входит в debt.
|
||||
* После смены транспортной эпохи нужен init. Команды encoder сериализует вызывающий. */
|
||||
* После смены транспортной эпохи нужен init. Команды encoder сериализует вызывающий.
|
||||
* Step допускает ненулевые границы/applied до 100 Мбит/с; диапазон звонка
|
||||
* 300..4000 кбит/с проверяют parse_range и transport. */
|
||||
#ifndef CALL_VIDEO_RATE_H
|
||||
#define CALL_VIDEO_RATE_H
|
||||
#include <stdint.h>
|
||||
@@ -18,32 +20,34 @@ enum call_video_rate_reason {
|
||||
};
|
||||
|
||||
struct call_video_rate_sample {
|
||||
uint64_t now_us, window_end_us;
|
||||
uint64_t delivered_bytes, produced_bytes, debt_bytes;
|
||||
uint32_t applied_bps, minimum_bps, maximum_bps;
|
||||
uint32_t baseline_rtt_us, rtt_us, frame_interval_us;
|
||||
uint64_t now_us, window_end_us; // Неубывающее время и граница завершённого 50мс интервала: 0 <= now-end <50мс.
|
||||
uint64_t delivered_bytes, produced_bytes, debt_bytes; // ACK/выход за 500мс и текущий долг; каждый <=UINT32_MAX.
|
||||
uint32_t applied_bps, minimum_bps, maximum_bps; // Фактически применённый bitrate и желаемые границы, бит/с.
|
||||
uint32_t baseline_rtt_us, rtt_us, frame_interval_us; // RTT и период кадра 1..1000000мкс для допуска штатного inflight.
|
||||
uint64_t debt_limit_bytes; /* Жёсткий предел admission, независимо от ACK-оценки. */
|
||||
int valid, ready, no_route, receive_busy, probing, locally_held, command_pending;
|
||||
int valid, ready, no_route, receive_busy, probing, locally_held, command_pending; // Флаги окна, канала и encoder-команды.
|
||||
};
|
||||
|
||||
struct call_video_rate_result {
|
||||
uint32_t target_bps;
|
||||
enum call_video_rate_reason reason;
|
||||
uint32_t target_bps; // Рекомендация; при pending остаётся applied, даже если диапазон изменился.
|
||||
enum call_video_rate_reason reason; // Причина рекомендации либо удержания/блокировки.
|
||||
int admit; /* Уже принятые кадры продолжают drain; новые запрещены при переполнении. */
|
||||
uint64_t delivery_bps, excess_bytes;
|
||||
uint64_t delivery_bps, excess_bytes; // delivered_bytes*16 и долг сверх допуска RTT + два периода кадра.
|
||||
};
|
||||
|
||||
struct call_video_rate {
|
||||
// Внутренняя история; изменяется только init/step у одного владельца.
|
||||
uint64_t last_us, next_change_us, previous_debt, high_since_us, last_log_us;
|
||||
uint32_t applied_bps;
|
||||
int initialized, high;
|
||||
};
|
||||
|
||||
/* Сбросить историю к now_us; NULL диагностируется и игнорируется. */
|
||||
void call_video_rate_init(struct call_video_rate *rate, uint64_t now_us);
|
||||
/* Атомарная настройка min:max в десятичных кбит/с, 300 <= min <= max <= 4000.
|
||||
* На ошибке выходные значения не меняются; результат 0/-1. */
|
||||
int call_video_rate_parse_range(const char *text, uint32_t *minimum_bps, uint32_t *maximum_bps);
|
||||
/* 0 — результат получен, -1 — недопустимый снимок (состояние не меняется).
|
||||
/* 0 — результат получен, -1 — недопустимый снимок (состояние и result не меняются).
|
||||
* target != applied — рекомендация, не подтверждение применения. Pending запрещает новую команду. */
|
||||
int call_video_rate_step(struct call_video_rate *rate, const struct call_video_rate_sample *sample,
|
||||
struct call_video_rate_result *result);
|
||||
|
||||
@@ -11,7 +11,9 @@ struct call_session;
|
||||
extern "C" {
|
||||
#endif
|
||||
struct call_video_ops {
|
||||
// peer/frame заимствованы на время callback; frame — полный собранный encoded кадр.
|
||||
void (*capabilities)(void *arg, const struct call_video_caps *peer);
|
||||
// 1 — есть место для полного кадра bytes, 0 — router удерживает первый фрагмент/ACK до resume.
|
||||
int (*receive_ready)(void *arg, uint32_t bytes);
|
||||
void (*frame)(void *arg, const struct call_video_frame *frame);
|
||||
void (*need_key)(void *arg);
|
||||
@@ -19,8 +21,11 @@ struct call_video_ops {
|
||||
void (*decoded)(void *arg, uint64_t generation, int accepted);
|
||||
void (*writable)(void *arg, int admit); // Разрешение новых входов encoder; также будит retry сохранённой пачки.
|
||||
};
|
||||
/* Настройка активного звонка, включая receive-only. NULL ops снимает платформенные callbacks.
|
||||
* capabilities отражают только реально проверенные профили. 0 — успешно, -1 — ошибка. */
|
||||
/* Настройка активного звонка, включая receive-only. При ops обязательны frame/receive_ready;
|
||||
* NULL ops снимает callbacks, обнуляет локальные CAPS/регулятор и очищает TX/частичную сборку RX.
|
||||
* caps обязательны даже при NULL ops; отражают только реально проверенные профили.
|
||||
* 0 — CAPS поставлены в router, -1 — ошибка; при отказе отправки настройка уже может быть применена.
|
||||
* CAPS повторяются до прикладного CAPS_ACK; успешный configure ещё не разрешает TX. */
|
||||
int call_video_configure(struct UTUN_INSTANCE *inst, uint64_t call_id, const struct call_video_caps *caps,
|
||||
const struct call_video_ops *ops, void *arg);
|
||||
/* Копирует один полный кадр. 1 принят, 0 временная занятость, -1 неверный формат/сессия/цепочка.
|
||||
@@ -29,10 +34,12 @@ int call_video_configure(struct UTUN_INSTANCE *inst, uint64_t call_id, const str
|
||||
* Каждый кадр выходит только после соответствующего аудио; deadline головы 2с.
|
||||
* При истечении deadline сбрасывается вся зависимая цепочка и запрашивается KEY. */
|
||||
int call_video_send_frame(struct UTUN_INSTANCE *inst, const struct call_video_frame *frame);
|
||||
/* Повторить доставку router RX после освобождения места платформой; чужой/закрытый id игнорируется. */
|
||||
void call_video_resume_receive(struct UTUN_INSTANCE *inst, uint64_t call_id);
|
||||
/* Decoder потерял references. Запрос идемпотентный, отправляется не чаще раза в 500мс. */
|
||||
void call_video_request_key(struct UTUN_INSTANCE *inst, uint64_t call_id);
|
||||
/* Результат запуска decoder конкретного поколения, отдельно от потери references. */
|
||||
/* Результат последнего полностью полученного поколения, accepted=0/1, 0/-1.
|
||||
* Отправка control при необходимости повторяется timer, отдельно от потери references. */
|
||||
int call_video_decode_result(struct UTUN_INSTANCE *inst, uint64_t call_id, uint64_t generation, int accepted);
|
||||
/* После открытия encoder; format — согласованный и фактически проверенный режим.
|
||||
* maximum_bps — дополнительный предел регулятора, не выше format.maximum_bps;
|
||||
@@ -44,10 +51,13 @@ int call_video_decode_result(struct UTUN_INSTANCE *inst, uint64_t call_id, uint6
|
||||
* applied=0 в rate_applied означает отказ encoder, выключает регулятор до configure_rate. */
|
||||
int call_video_configure_rate(struct UTUN_INSTANCE *inst, uint64_t call_id, const struct call_video_format *format,
|
||||
uint32_t applied_bps, uint32_t maximum_bps);
|
||||
/* 0 — ненулевой результат принят; -1 — stale/неверный результат либо обработанный отказ applied_bps=0. */
|
||||
int call_video_rate_applied(struct UTUN_INSTANCE *inst, uint64_t call_id, uint64_t generation,
|
||||
uint64_t request, uint32_t applied_bps);
|
||||
|
||||
/* Только call.c: управление общей сессией и граница уже отправленного аудио. */
|
||||
/* Только call.c: bind 0/-1 регистрирует два router-сервиса; unbind снимает их.
|
||||
* Stop отменяет timer/продолжение/send waiter и освобождает transport сессии.
|
||||
* Audio_sent обновляет границу уже отправленного аудио и повторяет flush; end_pts_us<0 игнорируется. */
|
||||
int call_video_transport_bind(struct UTUN_INSTANCE *inst);
|
||||
void call_video_transport_unbind(struct UTUN_INSTANCE *inst);
|
||||
void call_video_transport_stop(struct call_session *session);
|
||||
|
||||
+22
-6
@@ -32,10 +32,11 @@
|
||||
|
||||
static int ed25519_pub_from_priv(const uint8_t priv[32], uint8_t pub[32]) {
|
||||
EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, priv, 32);
|
||||
if (!pkey) return -1;
|
||||
if (!pkey) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: Ed25519 private key initialization failed", CA_ID); return -1; }
|
||||
size_t plen = 32;
|
||||
int ok = EVP_PKEY_get_raw_public_key(pkey, pub, &plen) == 1 && plen == 32;
|
||||
EVP_PKEY_free(pkey);
|
||||
if (!ok) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: Ed25519 public key derivation failed bytes=%zu", CA_ID, plen);
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
@@ -45,12 +46,23 @@ static int ed25519_pub_from_priv(const uint8_t priv[32], uint8_t pub[32]) {
|
||||
static void admin_key_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
if (!entry) return;
|
||||
if (!conn || !entry->dgram || entry->len < ROUTER_SVC_PAYLOAD_OFF + 8 + 32 + 32) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — invalid delivery len=%u", CA_ID, entry->len);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
struct UTUN_INSTANCE* inst = conn->instance;
|
||||
if (!inst) { queue_dgram_free(entry); queue_entry_free(entry); return; }
|
||||
if (!inst) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — no instance", CA_ID);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
struct chat_core_ctx* cc = CC(inst);
|
||||
if (!cc || !cc->initialized || !cc->db) { queue_dgram_free(entry); queue_entry_free(entry); return; }
|
||||
if (!cc || !cc->initialized || !cc->db) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — not initialized", CA_ID);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — import rejected inside external transaction", CA_ID);
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
|
||||
const uint8_t* d = entry->dgram;
|
||||
uint64_t from_node;
|
||||
@@ -84,6 +96,10 @@ static void admin_key_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
if (topo_node_sqlite_channel_set_privs(cc->db, ch_id, x_priv, ed_priv) != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — set_privs FAILED ch=%s from 0x%016llx",
|
||||
CA_ID, ch_id, (unsigned long long)from_node);
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — rollback left transaction open ch=%s; restart required", CA_ID, ch_id);
|
||||
inst->running = 0;
|
||||
}
|
||||
queue_dgram_free(entry); queue_entry_free(entry); return;
|
||||
}
|
||||
|
||||
@@ -120,8 +136,8 @@ void chat_admin_destroy(struct UTUN_INSTANCE* inst) {
|
||||
/* ─── передача прав (вызывается из uasync-потока) ─── */
|
||||
|
||||
void chat_core_transfer_admin(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t target_node_id) {
|
||||
struct chat_core_ctx* cc = CC(inst);
|
||||
if (!cc || !cc->initialized || !inst || !cc->db) {
|
||||
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
|
||||
if (!cc || !cc->initialized || !cc->db) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — not initialized", CA_ID);
|
||||
return;
|
||||
}
|
||||
@@ -151,7 +167,7 @@ void chat_core_transfer_admin(struct UTUN_INSTANCE* inst, const char* ch_id, uin
|
||||
struct ll_entry* entry = queue_entry_new(0);
|
||||
if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — queue_entry_new failed", CA_ID); return; }
|
||||
entry->dgram = u_malloc(1 + sizeof(payload));
|
||||
if (!entry->dgram) { queue_entry_free(entry); return; }
|
||||
if (!entry->dgram) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — payload allocation failed", CA_ID); queue_entry_free(entry); return; }
|
||||
entry->dgram[0] = ETCP_RT_ID_ADMIN_KEY;
|
||||
memcpy(entry->dgram + 1, payload, sizeof(payload));
|
||||
entry->len = 1 + (uint16_t)sizeof(payload);
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
* chat_admin.h — передача канального приватного ключа (прав админа) другому узлу
|
||||
*
|
||||
* Владелец канала (имеющий ed25519_privkey + x25519_privkey) может передать оба ключа
|
||||
* выбранному мемберу. Передача идёт через сервисный пакет etcp_router с
|
||||
* выбранному узлу. Передача идёт через сервисный пакет etcp_router с
|
||||
* ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT: X25519 ECDH + AES-128-CCM и Ed25519-подпись.
|
||||
* Получатель автоматически импортирует ключи и становится совладельцем канала.
|
||||
* Получатель сверяет выведенные публичные ключи с уже известными ключами канала,
|
||||
* сохраняет оба privkey атомарно и после commit синхронно публикует CHAT_EVT_ADMIN_KEY_RECEIVED.
|
||||
* Ошибка SQL/crypto сохраняет прежнюю пару без события успеха; корректный повтор допустим.
|
||||
* Внешняя SQL-транзакция отклоняет входящий импорт; незавершённый rollback требует restart.
|
||||
* Модуль включается/отключается chat_sync; все вызовы — в потоке uasync.
|
||||
*/
|
||||
|
||||
#ifndef CHAT_ADMIN_H
|
||||
@@ -18,8 +22,9 @@ extern "C" {
|
||||
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
/* Зарегистрировать/снять обработчик сервиса ETCP_RT_ID_ADMIN_KEY. */
|
||||
/* Зарегистрировать ETCP_RT_ID_ADMIN_KEY; 0/-1. inst обязателен, повтор заменяет callback. */
|
||||
int chat_admin_init(struct UTUN_INSTANCE* inst);
|
||||
/* Снять обработчик; NULL допустим, отсутствие регистрации логирует router. */
|
||||
void chat_admin_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
+1065
-256
File diff suppressed because it is too large.
Load diff
+47
-15
@@ -2,7 +2,21 @@
|
||||
* send готовит контейнер и публикует сообщение; downloaded/want запускают получение ключа и расшифровку.
|
||||
* Автор отвечает на запрос через decide. Ключи и очередь доставки сохраняются в локальной БД.
|
||||
* Все вызовы, кроме identity, — uasync. init после media_async; quiesce до остановки workers, destroy после них.
|
||||
* Обмен ключами подписан и зашифрован; для доставки нужен живой маршрут обеих сторон. */
|
||||
* Обмен ключами подписан и зашифрован; для доставки нужен живой маршрут обеих сторон.
|
||||
* Ответы REQUEST_ACK/GRANT/DENY/RESULT_ACK атомарно меняют ключ, запрос и outbox собственной транзакцией.
|
||||
* Внешняя транзакция для ответов запрещена. GRANT/DENY подтверждаются только после проверенного commit;
|
||||
* повтор с тем же результатом допустим, конфликтующий ключ/решение отклоняется. Отказ SQL сохраняет прежнее состояние.
|
||||
* Приём REQUEST владеет собственной транзакцией проверки идентичности/прав и сохранения запроса/повтора ответа.
|
||||
* Внешняя транзакция отклоняется без изменений; ACK/событие — после commit. Дубликат сохраняет решение и дату:
|
||||
* state=0 ждёт автора, 2/4 повторяют GRANT, 3 повторяет DENY; чужое направление и другие состояния отклоняются.
|
||||
* Исходящий REQUEST сохраняется собственной транзакцией request+outbox после повторной проверки состояния.
|
||||
* Уведомление/отправка следуют после commit; внешняя транзакция откладывает весь проход файлов и очереди.
|
||||
* Перед сетевой попыткой отдельно фиксируется retry_at; отказ SQL не отправляет команду и сохраняет очередь/дату.
|
||||
* Пустая очередь завершается без commit hook. retry_at отодвигается на5с; tick каждые10с, до16 команд за проход.
|
||||
* Расшифровка стартует после commit decrypting=1. До commit метаданных завершение остаётся у модуля:
|
||||
* таймер повторяет SQL без нового worker; не более 16 активных/ожидающих завершения файлов на instance.
|
||||
* quiesce останавливает повторы, destroy после workers освобождает также отложенные завершения.
|
||||
* Незавершённый rollback отключает модуль и запрашивает остановку ядра владельцем вне callback. */
|
||||
#ifndef CHAT_ATTACHMENT_H
|
||||
#define CHAT_ATTACHMENT_H
|
||||
#include "../media_async/attachment.h"
|
||||
@@ -14,45 +28,61 @@ struct UTUN_INSTANCE;
|
||||
|
||||
// ── Жизненный цикл модуля ──
|
||||
|
||||
// Подключить таблицы, router-сервис и обработку сохранённых задач; 0/-1.
|
||||
// Подключить таблицы, router-сервис и обработку сохранённых задач; 0/-1. Внешняя транзакция запрещена.
|
||||
int chat_attachment_init(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// Запретить новые подготовки и остановить таймер; запущенные workers завершаются отдельно.
|
||||
void chat_attachment_quiesce(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// После завершения workers убрать незавершённые подготовки и отключить router-сервис.
|
||||
// После workers убрать незавершённые подготовки; открытая транзакция откладывает recovery, сохраняя файлы.
|
||||
void chat_attachment_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// ── Отправка и учёт вложений ──
|
||||
|
||||
/* Асинхронно отправить подготовленный файл; channel — десятичный ID, reply — 32 байта ID ответа либо нули.
|
||||
* Аргументы копируются. 0 — задача принята, source передан задаче и будет удалён даже при ошибке подготовки.
|
||||
* -1 — отказ запуска, source остаётся у вызывающего. Ненулевой request возвращается в CHAT_EVT_MSG_SUBMITTED. */
|
||||
* -1 — отказ до принятия, source остаётся у вызывающего, события MSG_SUBMITTED здесь нет.
|
||||
* Ключ/UUID/метаданные фиксируются собственной SQL-транзакцией до передачи source и запуска worker.
|
||||
* Внешняя транзакция запрещена и остаётся нетронутой при отказе. Ошибка rollback с открытой транзакцией
|
||||
* запрещает новые задачи модуля и запрашивает остановку ядра; уничтожение выполняет владелец вне callback.
|
||||
* Ненулевой request возвращается в единственном CHAT_EVT_MSG_SUBMITTED; отказ media_async_submit может
|
||||
* дать его синхронно, до возврата 0. Commit не усиливает установленную ядром политику SQLite synchronous. */
|
||||
/* type проверяется encrypted_file_validate_type до выделения задачи, генерации UUID/ключа и записи в БД. */
|
||||
int chat_attachment_send(struct UTUN_INSTANCE* inst, const char* channel, const char* source,
|
||||
const struct attachment_info* info, const char* type, const uint8_t reply[32], uint64_t request);
|
||||
|
||||
// Прочитать UUID из канонического base64-префикса тела медиа; 0/-1. Подпись проверяет вызывающий.
|
||||
// Прочитать UUID из канонического base64-префикса тела медиа; 0/-1, id меняется только при успехе. Подпись проверяет вызывающий.
|
||||
int chat_attachment_identity(const char* body, size_t length, uint8_t id[16]);
|
||||
|
||||
// Связать авторский ключ с ID сообщения и SHA256 контейнера до публикации; 0/-1. Массивы копируются в БД.
|
||||
/* Собственной транзакцией связать авторский ключ с ID сообщения и SHA256 контейнера до публикации; 0/-1.
|
||||
* Массивы копируются в БД. Повтор той же пары допустим, её изменение запрещено; отказ сохраняет прежние поля.
|
||||
* Внешняя транзакция отклоняется без изменений; все SQL-этапы и commit проверяются. */
|
||||
int chat_attachment_bind(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t id[16],
|
||||
const uint8_t message[32], const uint8_t hash[32]);
|
||||
|
||||
// Учесть проверенное db_sync сообщение и сохранить метаданные контейнера; 0/-1. Обычные сообщения игнорируются.
|
||||
/* Учесть проверенное db_sync сообщение собственной транзакцией, не меняя ключ/состояние существующей идентичности.
|
||||
* 0 — записано/точный дубликат/обычное сообщение, -1 — SQL/OOM-отказ для повтора, -2 — неверный manifest/коллизия.
|
||||
* Внешняя транзакция запрещена. Подпись проверяет вызывающий; JSON заимствован до возврата. */
|
||||
int chat_attachment_observe(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32],
|
||||
uint64_t author, const char* json, size_t length);
|
||||
|
||||
// ── Получение файла и выдача доступа ──
|
||||
|
||||
// После проверенной загрузки: сохранить состояние, запросить ключ либо расшифровать.
|
||||
void chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]);
|
||||
/* После загрузки/наличия сохранённого контейнера: своей транзакцией сохранить downloaded и policy wanted.
|
||||
* 0/-1; запись observe обязательна. Повтор неизменного состояния не даёт события. Криптографию проверит worker.
|
||||
* verified_download!=0 — новая проверенная передача, также сбрасывает прежний error; 0 — найденный cache, error сохраняется.
|
||||
* Внешняя транзакция запрещена, SQL-отказ сохраняет прежние поля, событие — после commit. */
|
||||
int chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], int verified_download);
|
||||
|
||||
// Ручная загрузка на storage-узле также означает запрос пользовательского доступа.
|
||||
void chat_attachment_want(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]);
|
||||
/* Ручная загрузка, включая storage: одной транзакцией установить wanted, сбросить error и ready отсутствующего cache.
|
||||
* 0/-1; запись observe обязательна. Внешняя транзакция запрещена, отказ сохраняет прежние поля. */
|
||||
int chat_attachment_want(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]);
|
||||
|
||||
/* Сохранить решение автора по ожидающему запросу peer: approve!=0 — выдать ключ, 0 — отказать.
|
||||
* 0 — решение и исходящий ответ записаны в БД, -1 — ошибка/нет прав или ожидающего запроса; доставка идёт позже. */
|
||||
* 0 — собственная транзакция решения+outbox завершена, затем послано одно уведомление изменения;
|
||||
* -1 — ошибка/нет прав или ожидающего запроса, прежнее состояние сохранено. Доставка идёт позже.
|
||||
* Внешняя транзакция отклоняется без изменений; проверка прав выполняется внутри собственной.
|
||||
* Открытая после отказа rollback транзакция требует остановки/закрытия БД владельцем после poll. */
|
||||
int chat_attachment_decide(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint64_t peer, int approve);
|
||||
|
||||
// ── Команда интерфейса ──
|
||||
@@ -64,13 +94,15 @@ struct chat_attachment_decision {
|
||||
uint64_t peer;
|
||||
int approve;
|
||||
};
|
||||
// arg — u_malloc/u_calloc chat_attachment_decision, освобождается здесь; результат — CHAT_EVT_ATTACHMENT_KEYS_CHANGED.
|
||||
// arg — u_malloc/u_calloc chat_attachment_decision, освобождается здесь; успех уже уведомлён decide, ошибка — событие с success=0.
|
||||
void chat_attachment_decision_trampoline(void* arg);
|
||||
|
||||
// ── Локальное удаление канала ──
|
||||
|
||||
// Канал удаляется локально: отменить очереди, стереть ключи и частные копии.
|
||||
void chat_attachment_delete_channel(struct UTUN_INSTANCE* inst, const char* channel);
|
||||
/* После commit удаления канала убрать собственные UUID.file; 0/-1, при I/O-ошибке можно повторить.
|
||||
* Ключи/запросы/outbox удаляет SQLite-trigger атомарно с channels; SQL-отказ сохраняет все записи.
|
||||
* Внешняя транзакция или существующий канал запрещают файловую очистку; завершения workers видят удаление через БД. */
|
||||
int chat_attachment_delete_channel(struct UTUN_INSTANCE* inst, const char* channel);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
+203
-163
@@ -32,123 +32,165 @@
|
||||
#include <openssl/sha.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ─── Ed25519 подпись join-сообщения ─── */
|
||||
uint64_t chat_channel_id(const char* text) {
|
||||
if (!text) return 0;
|
||||
size_t length = strnlen(text, 20);
|
||||
if (!length || length == 20 || text[0] == '0' || strspn(text, "0123456789") != length) return 0;
|
||||
uint64_t id = strtoull(text, NULL, 10); return id <= INT64_MAX ? id : 0;
|
||||
}
|
||||
|
||||
static int channel_sign_join(const uint8_t* ch_x25519, const uint8_t* ch_ed25519,
|
||||
uint64_t node_id, const uint8_t* node_pubkey,
|
||||
uint64_t join_ts, const uint8_t* ed25519_privkey,
|
||||
uint8_t sig_out[64])
|
||||
{
|
||||
uint8_t msg[128];
|
||||
int mlen = member_sync_build_join_msg(ch_x25519, ch_ed25519, node_id, node_pubkey, join_ts,
|
||||
msg, (int)sizeof(msg));
|
||||
if (mlen < 0) { memset(sig_out, 0, 64); return -1; }
|
||||
|
||||
EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL,
|
||||
ed25519_privkey, 32);
|
||||
if (!pkey) { memset(sig_out, 0, 64); return -1; }
|
||||
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
|
||||
if (!mdctx) { EVP_PKEY_free(pkey); memset(sig_out, 0, 64); return -1; }
|
||||
int ok = EVP_DigestSignInit(mdctx, NULL, NULL, NULL, pkey) == 1
|
||||
&& EVP_DigestSign(mdctx, sig_out, &(size_t){64}, msg, (size_t)mlen) == 1;
|
||||
EVP_MD_CTX_free(mdctx);
|
||||
EVP_PKEY_free(pkey);
|
||||
if (!ok) memset(sig_out, 0, 64);
|
||||
return ok ? 0 : -1;
|
||||
/* Один формат описания для создания и проверки подписи входящего JOIN/invite. */
|
||||
int chat_channel_description_data(const char* channel, const char* name, uint64_t owner,
|
||||
const uint8_t x25519[32], const uint8_t ed25519[32], uint8_t* out, size_t capacity) {
|
||||
if (!channel || !name || !x25519 || !ed25519 || !out) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel description: missing argument"); return -1;
|
||||
}
|
||||
size_t channel_len = strnlen(channel, 20), name_len = strnlen(name, 256);
|
||||
if (!chat_channel_id(channel) || name_len == 256 || capacity < channel_len + name_len + 74) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel description: invalid channel/name lengths=%zu/%zu capacity=%zu",
|
||||
channel_len, name_len, capacity); return -1;
|
||||
}
|
||||
size_t offset = channel_len + 1; memcpy(out, channel, offset);
|
||||
memcpy(out + offset, name, name_len + 1); offset += name_len + 1;
|
||||
memcpy(out + offset, &owner, 8); offset += 8;
|
||||
memcpy(out + offset, x25519, 32); offset += 32;
|
||||
memcpy(out + offset, ed25519, 32); offset += 32;
|
||||
return (int)offset;
|
||||
}
|
||||
|
||||
/* ─── подготовка инфраструктуры канала (db_sync instance) ─── */
|
||||
|
||||
void chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id) {
|
||||
struct chat_core_ctx* cc = CC(inst);
|
||||
if (!cc || !cc->initialized || !ch_id || !ch_id[0]) return;
|
||||
if (chat_feedback_ensure_channel(inst, ch_id) < 0) return;
|
||||
if (si_find(inst, ch_id)) return;
|
||||
if (topo_node_sqlite_member_local_init(cc->db, ch_id) < 0) return;
|
||||
|
||||
char tbl_msg[80]; msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg));
|
||||
|
||||
int chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id) {
|
||||
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
|
||||
if (!cc || !cc->initialized || !chat_channel_id(ch_id) || !inst->topo_groups) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel preparation without active chat/topology"); return -1;
|
||||
}
|
||||
if (chat_feedback_ensure_channel(inst, ch_id) < 0 || topo_node_sqlite_member_local_init(cc->db, ch_id) < 0) return -1;
|
||||
uint64_t gid = strtoull(ch_id, NULL, 10);
|
||||
if (gid != 0 && inst->topo_groups && !topo_groups_find(inst->topo_groups, gid)) {
|
||||
struct TOPO_GROUP* g = topo_groups_create_group(inst->topo_groups, gid, TOPO_GROUP_TYPE_CHAT, ch_id);
|
||||
if (g) member_sync_subscribe_group(inst, g);
|
||||
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, gid);
|
||||
int created_group = !group;
|
||||
if (!group) group = topo_groups_create_group(inst->topo_groups, gid, TOPO_GROUP_TYPE_CHAT, ch_id);
|
||||
if (!group || member_sync_subscribe_group(inst, group) < 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel group preparation failed ch=%s", ch_id);
|
||||
if (group && created_group) topo_groups_remove_group(inst->topo_groups, gid);
|
||||
return -1;
|
||||
}
|
||||
struct DB_SYNC_INSTANCE* si = db_sync_instance_add(inst, tbl_msg, gid);
|
||||
if (si) {
|
||||
si_register(inst, si, ch_id);
|
||||
db_sync_set_insert_cb(si, on_msg_inserted, inst);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: channel ready ch=%s tbl=%s group=0x%016llx",
|
||||
CC_ID, ch_id, tbl_msg, (unsigned long long)gid);
|
||||
} else {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: db_sync_instance_add failed for ch=%s",
|
||||
CC_ID, ch_id);
|
||||
struct DB_SYNC_INSTANCE* si = si_find(inst, ch_id);
|
||||
int created_sync = !si;
|
||||
char table[80]; msg_table_name(ch_id, table, sizeof(table));
|
||||
if (!si) si = db_sync_instance_add(inst, table, gid);
|
||||
if (!si) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel sync allocation failed ch=%s", ch_id);
|
||||
if (created_group) topo_groups_remove_group(inst->topo_groups, gid);
|
||||
return -1;
|
||||
}
|
||||
if (created_sync) db_sync_set_insert_cb(si, on_msg_inserted, inst);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "channel prepared ch=%s new_group=%d new_sync=%d", ch_id, created_group, created_sync);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ─── создание канала ─── */
|
||||
|
||||
void chat_core_create_channel(struct UTUN_INSTANCE* inst, struct chat_channel_create* req) {
|
||||
struct chat_core_ctx* cc = CC(inst);
|
||||
if (!cc || !cc->initialized) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel NOT INITIALIZED ch=%s",
|
||||
CC_ID, req ? req->channel_id : "(null)");
|
||||
return;
|
||||
int chat_core_create_channel(struct UTUN_INSTANCE* inst, const struct chat_channel_create* req) {
|
||||
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
|
||||
if (!cc || !cc->initialized || !cc->db || !inst->topo_groups || !req || req->owner_node_id != inst->node_id ||
|
||||
strnlen(req->name, sizeof(req->name)) == sizeof(req->name)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: invalid context/owner/name"); return -1;
|
||||
}
|
||||
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel req=NULL", CC_ID); return; }
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: external transaction rejected"); return -1;
|
||||
}
|
||||
uint8_t description[CHAT_CHANNEL_DESCRIPTION_MAX], public_key[32];
|
||||
int bytes = chat_channel_description_data(req->channel_id, req->name, req->owner_node_id,
|
||||
req->x25519_pubkey, req->ed25519_pubkey, description, sizeof(description));
|
||||
if (bytes < 0 || sc_compute_public_key_from_private(req->x25519_privkey, public_key) != SC_OK ||
|
||||
memcmp(public_key, req->x25519_pubkey, 32)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: invalid description or X25519 key pair"); return -1;
|
||||
}
|
||||
EVP_PKEY* ed = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, req->ed25519_privkey, 32);
|
||||
size_t public_size = 32;
|
||||
int valid_pair = ed && EVP_PKEY_get_raw_public_key(ed, public_key, &public_size) == 1 && public_size == 32;
|
||||
EVP_PKEY_free(ed);
|
||||
if (!valid_pair || memcmp(public_key, req->ed25519_pubkey, 32) ||
|
||||
sc_ed25519_verify(req->ed25519_pubkey, description, (size_t)bytes, req->signature) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: invalid Ed25519 key pair/description signature ch=%s", req->channel_id); return -1;
|
||||
}
|
||||
uint64_t group_id = strtoull(req->channel_id, NULL, 10);
|
||||
if (topo_groups_find(inst->topo_groups, group_id)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: group ID already in use ch=%s", req->channel_id); return -1;
|
||||
}
|
||||
time_t now = ntp_time_get_seconds(inst);
|
||||
if (now <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: invalid join time ch=%s", req->channel_id); return -1; }
|
||||
uint64_t timestamp = (uint64_t)now;
|
||||
uint8_t join[64], root[64], update[64], body[MS_USERINFO_MAX + 73]; char profile[MS_USERINFO_MAX + 1];
|
||||
bytes = member_sync_build_join_msg(req->x25519_pubkey, req->ed25519_pubkey, inst->node_id,
|
||||
inst->my_keys.public_key, timestamp, body, sizeof(body));
|
||||
if (bytes < 0 || sc_ed25519_sign(inst->my_ed25519_privkey, body, (size_t)bytes, join) != SC_OK ||
|
||||
member_sync_sign_pubkey(req->ed25519_privkey, inst->my_keys.public_key, root) < 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: root JOIN/invitation signing failed ch=%s", req->channel_id); return -1;
|
||||
}
|
||||
if (chat_core_name_userinfo(inst->name, profile, sizeof(profile))) return -1;
|
||||
bytes = member_sync_build_update_msg(join, timestamp, profile, body, sizeof(body));
|
||||
if (bytes < 0 || sc_ed25519_sign(inst->my_ed25519_privkey, body, (size_t)bytes, update) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: root profile signing failed ch=%s", req->channel_id); return -1;
|
||||
}
|
||||
sqlite3_stmt* st = NULL; const char* stage = "begin";
|
||||
int rc = sqlite3_exec(cc->db, "BEGIN IMMEDIATE /* channel creation */", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: begin ch=%s rc=%d: %s", req->channel_id, rc, sqlite3_errmsg(cc->db)); return -1;
|
||||
}
|
||||
stage = "lookup channel";
|
||||
rc = sqlite3_prepare_v2(cc->db, "SELECT 1 FROM channels WHERE channel_id=?", -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, req->channel_id, -1, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
if (rc == SQLITE_ROW) { stage = "channel already exists"; rc = SQLITE_CONSTRAINT; goto error; }
|
||||
if (rc != SQLITE_DONE) goto error;
|
||||
stage = "lookup finalize"; rc = sqlite3_finalize(st); st = NULL;
|
||||
if (rc != SQLITE_OK) goto error;
|
||||
stage = "store description";
|
||||
if (topo_node_sqlite_channel_put(cc->db, req->channel_id, req->name, req->owner_node_id,
|
||||
req->x25519_pubkey, req->x25519_privkey, req->ed25519_pubkey, req->ed25519_privkey, req->signature)) {
|
||||
rc = SQLITE_ERROR; goto error;
|
||||
}
|
||||
stage = "feedback schema";
|
||||
if (chat_feedback_ensure_channel(inst, req->channel_id)) { rc = SQLITE_ERROR; goto error; }
|
||||
stage = "store root member";
|
||||
int changes = member_sync_put(inst, req->channel_id, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey,
|
||||
join, timestamp, update, timestamp, profile, NULL, NULL, 0, 0, root);
|
||||
if (changes < 0 || !(changes & MS_APPLY_CHANGED)) { rc = SQLITE_ERROR; goto error; }
|
||||
stage = "verify stored root";
|
||||
if (member_sync_author_key(inst, req->channel_id, inst->node_id, public_key) != MS_AUTHOR_ACTIVE ||
|
||||
memcmp(public_key, inst->my_ed25519_pubkey, 32)) { rc = SQLITE_CONSTRAINT; goto error; }
|
||||
stage = "commit"; rc = sqlite3_exec(cc->db, "COMMIT /* channel creation */", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) goto error;
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel BEGIN ch=%s name=%s",
|
||||
CC_ID, req->channel_id, req->name);
|
||||
|
||||
int rc = topo_node_sqlite_channel_put(cc->db,
|
||||
req->channel_id, req->name, req->owner_node_id,
|
||||
req->x25519_pubkey, req->x25519_privkey,
|
||||
req->ed25519_pubkey, req->ed25519_privkey,
|
||||
req->signature);
|
||||
|
||||
if (rc != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: channel_put FAILED ch=%s rc=%d",
|
||||
CC_ID, req->channel_id, rc);
|
||||
} else {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: channel_put OK ch=%s name=%s owner=0x%016llx",
|
||||
CC_ID, req->channel_id, req->name, (unsigned long long)req->owner_node_id);
|
||||
|
||||
chat_core_ensure_channel_ready(inst, req->channel_id);
|
||||
|
||||
uint8_t ch_id_len = (uint8_t)strlen(req->channel_id);
|
||||
uint8_t data[65];
|
||||
data[0] = ch_id_len;
|
||||
memcpy(data + 1, req->channel_id, ch_id_len);
|
||||
|
||||
{
|
||||
uint64_t myid = inst->node_id;
|
||||
uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst);
|
||||
uint8_t join_sig[64];
|
||||
if (channel_sign_join(req->x25519_pubkey, req->ed25519_pubkey, myid, inst->my_keys.public_key,
|
||||
join_ts, inst->my_ed25519_privkey, join_sig) < 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: root join signing failed ch=%s", CC_ID, req->channel_id); return;
|
||||
}
|
||||
|
||||
/* корень дерева приглашений: подписан ключом группы (канальный ed25519) */
|
||||
uint8_t root_sig[64];
|
||||
struct ms_member_rec root = { .node_id = myid, .x25519 = inst->my_keys.public_key, .signature = root_sig };
|
||||
if (member_sync_sign_pubkey(req->ed25519_privkey, inst->my_keys.public_key, root_sig) < 0
|
||||
|| member_sync_verify_signature(cc->db, req->channel_id, &root) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: root invitation signing failed ch=%s", CC_ID, req->channel_id); return;
|
||||
}
|
||||
int mrc = member_sync_put(inst, req->channel_id, myid,
|
||||
inst->my_keys.public_key, inst->my_ed25519_pubkey,
|
||||
join_sig, join_ts, NULL, 0, "{}", NULL, NULL, 0, 0, root_sig);
|
||||
if (mrc < 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: member_sync_put(self) FAILED ch=%s rc=%d",
|
||||
CC_ID, req->channel_id, mrc);
|
||||
}
|
||||
/* адреса + update_ts + подпись + синк — единой функцией */
|
||||
chat_core_update_my_member(inst);
|
||||
int ready = !chat_core_ensure_channel_ready(inst, req->channel_id);
|
||||
if (!ready) DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel create: committed ch=%s, runtime activation deferred; retry preparation", req->channel_id);
|
||||
member_sync_record_committed(inst, req->channel_id, inst->node_id, (unsigned)changes);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "channel created: ch=%s name=%s root=%016llx committed=1 runtime_ready=%d",
|
||||
req->channel_id, req->name, (unsigned long long)inst->node_id, ready);
|
||||
uint8_t event[21]; size_t length = strlen(req->channel_id); event[0] = (uint8_t)length;
|
||||
memcpy(event + 1, req->channel_id, length); chat_event_post(inst, CHAT_EVT_CHANNEL_UPDATED, event, (int)length + 1);
|
||||
return 0;
|
||||
error:
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: ch=%s stage=%s rc=%d (%s) db=%s",
|
||||
req->channel_id, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(cc->db));
|
||||
rc = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: cleanup finalize ch=%s rc=%d", req->channel_id, rc);
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
rc = sqlite3_exec(cc->db, "ROLLBACK", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: rollback ch=%s rc=%d: %s",
|
||||
req->channel_id, rc, sqlite3_errmsg(cc->db));
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: transaction remains open ch=%s; requesting owner stop/DB close", req->channel_id);
|
||||
utun_instance_stop(inst);
|
||||
}
|
||||
|
||||
chat_event_post(inst, CHAT_EVT_CHANNEL_UPDATED, data, 1 + ch_id_len);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void chat_core_create_channel_trampoline(void* arg) {
|
||||
@@ -156,6 +198,7 @@ void chat_core_create_channel_trampoline(void* arg) {
|
||||
struct chat_channel_create* req = (struct chat_channel_create*)arg;
|
||||
if (!req) return;
|
||||
chat_core_create_channel(req->inst, req);
|
||||
OPENSSL_cleanse(req, sizeof(*req));
|
||||
u_free(req);
|
||||
}
|
||||
|
||||
@@ -166,62 +209,51 @@ void chat_core_create_channel_auto_trampoline(void* arg) {
|
||||
u_free(arg);
|
||||
}
|
||||
|
||||
void chat_core_create_channel_auto(struct UTUN_INSTANCE* inst, const char* name) {
|
||||
struct chat_core_ctx* cc = CC(inst);
|
||||
if (!cc || !cc->initialized || !name) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel_auto — not initialized or NULL name", CC_ID);
|
||||
return;
|
||||
/* Генерация двух независимых типов ключей с проверкой всех стадий OpenSSL. */
|
||||
static int channel_generate_keypair(int type, uint8_t public_key[32], uint8_t private_key[32]) {
|
||||
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(type, NULL); EVP_PKEY* key = NULL;
|
||||
const char* stage = "context"; size_t public_size = 32, private_size = 32;
|
||||
if (!ctx) goto error;
|
||||
stage = "initialize"; if (EVP_PKEY_keygen_init(ctx) != 1) goto error;
|
||||
stage = "generate"; if (EVP_PKEY_keygen(ctx, &key) != 1) goto error;
|
||||
stage = "public key";
|
||||
if (EVP_PKEY_get_raw_public_key(key, public_key, &public_size) != 1 || public_size != 32) goto error;
|
||||
stage = "private key";
|
||||
if (EVP_PKEY_get_raw_private_key(key, private_key, &private_size) != 1 || private_size != 32) goto error;
|
||||
EVP_PKEY_free(key); EVP_PKEY_CTX_free(ctx); return 0;
|
||||
error:
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel key generation: type=%d stage=%s public_bytes=%zu private_bytes=%zu",
|
||||
type, stage, public_size, private_size);
|
||||
EVP_PKEY_free(key); EVP_PKEY_CTX_free(ctx); return -1;
|
||||
}
|
||||
|
||||
uint64_t chat_core_create_channel_auto(struct UTUN_INSTANCE* inst, const char* name) {
|
||||
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
|
||||
if (!cc || !cc->initialized || !cc->db || !inst->topo_groups || !name || strnlen(name, 128) == 128) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel auto create: invalid context/name or name exceeds127 bytes"); return 0;
|
||||
}
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel_auto BEGIN name=%s", CC_ID, name);
|
||||
|
||||
uint8_t x25519_pub[32] = {0}, x25519_priv[32] = {0};
|
||||
{ EVP_PKEY* pkey = NULL; EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
|
||||
if (ctx) { EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); }
|
||||
if (pkey) { size_t pkLen = 32, skLen = 32; EVP_PKEY_get_raw_public_key(pkey, x25519_pub, &pkLen); EVP_PKEY_get_raw_private_key(pkey, x25519_priv, &skLen); EVP_PKEY_free(pkey); } }
|
||||
|
||||
uint8_t ed25519_pub[32] = {0}, ed25519_priv[32] = {0};
|
||||
EVP_PKEY* ed_pkey = NULL;
|
||||
{ EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL);
|
||||
if (ctx) { EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &ed_pkey); EVP_PKEY_CTX_free(ctx); }
|
||||
if (ed_pkey) { size_t pkLen = 32, skLen = 32; EVP_PKEY_get_raw_public_key(ed_pkey, ed25519_pub, &pkLen); EVP_PKEY_get_raw_private_key(ed_pkey, ed25519_priv, &skLen); } }
|
||||
|
||||
uint64_t chId = 0; RAND_bytes((uint8_t*)&chId, sizeof(chId));
|
||||
chId &= 0x7FFFFFFFFFFFFFFFULL; /* как node_id — всегда < 2^63 (помещается в signed Long) */
|
||||
if (chId == 0) chId = 1;
|
||||
char channelId[64]; snprintf(channelId, sizeof(channelId), "%llu", (unsigned long long)chId);
|
||||
|
||||
uint8_t sig[64] = {0};
|
||||
if (ed_pkey) {
|
||||
size_t nameLen = strlen(name);
|
||||
size_t chIdLen = strlen(channelId);
|
||||
size_t msgLen = chIdLen + 1 + nameLen + 1 + 8 + 32 + 32;
|
||||
uint8_t* msg = u_malloc(msgLen);
|
||||
if (msg) {
|
||||
uint8_t* p = msg;
|
||||
memcpy(p, channelId, chIdLen); p += chIdLen; *p++ = '\0';
|
||||
memcpy(p, name, nameLen); p += nameLen; *p++ = '\0';
|
||||
memcpy(p, &cc->my_node_id, 8); p += 8;
|
||||
memcpy(p, x25519_pub, 32); p += 32;
|
||||
memcpy(p, ed25519_pub, 32);
|
||||
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
|
||||
if (mdctx) { EVP_DigestSignInit(mdctx, NULL, NULL, NULL, ed_pkey); size_t sl = 64; EVP_DigestSign(mdctx, sig, &sl, msg, msgLen); EVP_MD_CTX_free(mdctx); }
|
||||
u_free(msg);
|
||||
}
|
||||
EVP_PKEY_free(ed_pkey);
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel auto create: external transaction rejected before key generation"); return 0;
|
||||
}
|
||||
|
||||
struct chat_channel_create* req = u_calloc(1, sizeof(*req));
|
||||
if (!req) return;
|
||||
req->inst = inst;
|
||||
snprintf(req->channel_id, sizeof(req->channel_id), "%s", channelId);
|
||||
snprintf(req->name, sizeof(req->name), "%s", name);
|
||||
req->owner_node_id = cc->my_node_id;
|
||||
memcpy(req->x25519_pubkey, x25519_pub, 32); memcpy(req->x25519_privkey, x25519_priv, 32);
|
||||
memcpy(req->ed25519_pubkey, ed25519_pub, 32); memcpy(req->ed25519_privkey, ed25519_priv, 32);
|
||||
memcpy(req->signature, sig, 64);
|
||||
|
||||
chat_core_create_channel(inst, req);
|
||||
u_free(req);
|
||||
struct chat_channel_create req = {.inst = inst, .owner_node_id = inst->node_id};
|
||||
uint8_t description[CHAT_CHANNEL_DESCRIPTION_MAX]; uint64_t group_id = 0; int result = -1;
|
||||
if (channel_generate_keypair(EVP_PKEY_X25519, req.x25519_pubkey, req.x25519_privkey) ||
|
||||
channel_generate_keypair(EVP_PKEY_ED25519, req.ed25519_pubkey, req.ed25519_privkey)) goto done;
|
||||
if (RAND_bytes((uint8_t*)&group_id, sizeof(group_id)) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel auto create: ID generation failed"); goto done;
|
||||
}
|
||||
group_id &= INT64_MAX; if (!group_id) group_id = 1;
|
||||
snprintf(req.channel_id, sizeof(req.channel_id), "%llu", (unsigned long long)group_id);
|
||||
memcpy(req.name, name, strlen(name) + 1);
|
||||
int bytes = chat_channel_description_data(req.channel_id, req.name, req.owner_node_id,
|
||||
req.x25519_pubkey, req.ed25519_pubkey, description, sizeof(description));
|
||||
if (bytes < 0 || sc_ed25519_sign(req.ed25519_privkey, description, (size_t)bytes, req.signature) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel auto create: description signing failed ch=%s", req.channel_id); goto done;
|
||||
}
|
||||
result = chat_core_create_channel(inst, &req);
|
||||
done:
|
||||
OPENSSL_cleanse(&req, sizeof(req));
|
||||
return result == 0 ? group_id : 0;
|
||||
}
|
||||
|
||||
/* ─── управление подключением к каналу (выбор в GUI) ─── */
|
||||
@@ -321,12 +353,15 @@ void chat_core_apply_group_autoconnect(struct UTUN_INSTANCE* inst) {
|
||||
|
||||
|
||||
void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id) {
|
||||
struct chat_core_ctx* cc = CC(inst);
|
||||
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
|
||||
if (!cc || !cc->initialized || !cc->db || !inst) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel — not initialized", CC_ID);
|
||||
return;
|
||||
}
|
||||
if (!ch_id || !ch_id[0]) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel — empty ch_id", CC_ID); return; }
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel rejected inside external transaction ch=%s", CC_ID, ch_id); return;
|
||||
}
|
||||
if (chat_media_defer_channel_delete(inst, ch_id)) return;
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel BEGIN ch=%s", CC_ID, ch_id);
|
||||
@@ -334,7 +369,6 @@ void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id) {
|
||||
|
||||
/* 1. отмена медиа-загрузок/релэев/суперузлов канала (держат conn_mgr handle группы) */
|
||||
media_delivery_cancel_channel(inst, gid);
|
||||
chat_attachment_delete_channel(inst, ch_id);
|
||||
member_files_cancel_group(inst, gid);
|
||||
group_avatar_cancel_group(inst, gid);
|
||||
|
||||
@@ -354,16 +388,22 @@ void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id) {
|
||||
/* 5. немедленно убрать канал из кеша chat_sync */
|
||||
chat_sync_on_channel_deleted(inst, ch_id);
|
||||
|
||||
/* 6. удалить медиафайлы канала на диске + строки media_files */
|
||||
char media_base[MEDIA_BASE_MAX];
|
||||
int media_error = media_index_get_base(cc->db, media_base) || media_index_delete_channel(cc->db, ch_id, media_base);
|
||||
if (media_error) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "channel=%s removed with incomplete media cleanup; retry required", ch_id);
|
||||
|
||||
/* 7. удалить таблицы и метаданные канала */
|
||||
/* 6. удалить таблицы, метаданные и attachment-записи одной SQL-транзакцией до файловой очистки */
|
||||
if (topo_node_sqlite_channel_delete(cc->db, ch_id)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel metadata deletion failed ch=%s", ch_id); return;
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel metadata deletion failed ch=%s; files preserved, retry required", ch_id);
|
||||
if (!sqlite3_get_autocommit(cc->db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel delete left transaction open ch=%s; requesting owner stop", ch_id);
|
||||
chat_attachment_quiesce(inst); utun_instance_stop(inst);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* 7. удалить файлы только после commit; I/O-отказ не восстанавливает удалённый канал */
|
||||
int media_error = chat_attachment_delete_channel(inst, ch_id);
|
||||
char media_base[MEDIA_BASE_MAX];
|
||||
if (media_index_get_base(cc->db, media_base) || media_index_delete_channel(cc->db, ch_id, media_base)) media_error = 1;
|
||||
if (media_error) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "channel=%s removed with incomplete media cleanup; retry required", ch_id);
|
||||
|
||||
/* 8. уведомить GUI */
|
||||
{
|
||||
uint8_t evt[65]; uint8_t cl = (uint8_t)strlen(ch_id);
|
||||
|
||||
+637
-438
File diff suppressed because it is too large.
Load diff
+156
-68
@@ -21,6 +21,10 @@
|
||||
struct sqlite3;
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// ── Жизненный цикл ──
|
||||
|
||||
// Запустить чат поверх core_start; UTUN не требуется. Повтор допустим; 0/-1.
|
||||
@@ -30,24 +34,32 @@ int chat_service_start(struct UTUN_INSTANCE* inst);
|
||||
* Отменяет доставку и ждёт native media workers; длительная задача может задержать stop. */
|
||||
void chat_service_stop(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* Внутренняя часть запуска: открыть/подключить БД и загрузить каналы. 0/-1.
|
||||
/* Внутренняя часть запуска:заимствовать БД работающего ядра,сохранить метаданные и подготовить каналы. 0/-1.
|
||||
* Собственная транзакция включает schema/identity/media_base и установку обязательных подписок;чужая запрещена.
|
||||
* db_path<=511 байт задаёт default media base,отдельная БД не открывается. Готовность публикуется после commit/activation.
|
||||
* Activation error сохраняет committed метаданные;caller откатывает runtime через chat_service_stop и может повторить старт.
|
||||
* Для запуска всего чат-сервиса использовать chat_service_start(). */
|
||||
int chat_core_init(struct UTUN_INSTANCE* inst, const char* db_path);
|
||||
|
||||
// Освободить chat_core после остановки остальных частей чата; общая БД остаётся у ядра.
|
||||
void chat_core_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* Backfill: регистрирует уже скачанные медиафайлы (лежат на диске, но отсутствуют
|
||||
* в media_files) — вызывается после загрузки каналов/сообщений при старте. */
|
||||
/* Startup: восстановить производный attachment-индекс из подписанной истории и зарегистрировать сохранённые медиа.
|
||||
* Вызывать после инициализации attachment и startup_backfill, чтобы SQL-отказ мог поставить повтор. Ключи из истории не восстанавливаются. */
|
||||
void chat_media_backfill(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* Докачка недостающего медиа за последние storage_backfill_days дней. Проходит по
|
||||
* медиасообщениям каналов и запускает загрузку для тех, чей файл отсутствует на диске. */
|
||||
/* Докачка недостающего медиа за последние storage_backfill_days дней и применение квоты.
|
||||
* Также восстанавливает attachment-индекс/доступность сохранённых контейнеров; SQL-отказ ставит повтор.
|
||||
* Перед удалением по квоте/очисткой сирот требуется полный снимок ссылок;ошибка SQL/памяти отменяет удаление.
|
||||
* Один путь учитывается один раз,с самой новой ссылкой;derived plaintext входит в общий кеш. */
|
||||
void chat_media_autodownload_backfill(struct UTUN_INSTANCE* inst);
|
||||
|
||||
/* Регистрирует триггер: после первого SYNC_DONE (+ дебаунс 2с) анонсирует локальные
|
||||
* блоки суперузлам и запускает chat_media_autodownload_backfill. Один раз за запуск чата. */
|
||||
void chat_media_startup_backfill_init(struct UTUN_INSTANCE* inst);
|
||||
/* Подписать завершения Merkle: первый обмен запускает общий scan после 2с тишины; отдельные каналы — batch до16 через2с.
|
||||
* Повтор индекса не отменяет MSG_RECEIVED committed-истории; следующий batch может быть поставлен во время текущего.
|
||||
* При переполнении очереди и для восстановления допускаются последующие общие scan.
|
||||
* init:0 — обе подписки установлены/повтор,-1 — отказ с отменой частичной установки.
|
||||
* Destroy снимает подписки/таймеры;вызывать до освобождения chat_core. */
|
||||
int chat_media_startup_backfill_init(struct UTUN_INSTANCE* inst);
|
||||
|
||||
void chat_media_startup_backfill_destroy(struct UTUN_INSTANCE* inst);
|
||||
|
||||
@@ -74,19 +86,33 @@ struct chat_msg_submit {
|
||||
uint32_t data_len; /* размер data в байтах */
|
||||
uint64_t timestamp; /* успешная отправка возвращает назначенное время */
|
||||
uint8_t message_id[32]; /* успешная обычная отправка возвращает общий ID */
|
||||
uint64_t expected_ver; /* >0: trampoline редактирует message_id вместо новой отправки. */
|
||||
uint8_t reply_to[32]; /* общий ID оригинала в этом канале; нули = не ответ */
|
||||
uint64_t request_id; /* ненулевой ID: trampoline/media отправляет CHAT_EVT_MSG_SUBMITTED */
|
||||
};
|
||||
|
||||
// Подписать, сохранить и разослать сообщение. req/data остаются у вызывающего. 0/-1.
|
||||
/* Подписать,сохранить и разослать:0/-1,req/data остаются у caller.
|
||||
* channel/content_type — NUL-строки в пределах массивов,канал канонический;data<=4000 байт без NUL.
|
||||
* Итоговый экранированный JSON также должен помещаться в4000 байт. SQL JSON/bind/finalize проверяются до подписи/записи. */
|
||||
int chat_core_submit_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req);
|
||||
|
||||
/* Правка собственного text: ожидаемая версия защищает открытый редактор от устаревшего сохранения.
|
||||
* 0 — сохранено/текст прежний, -1 — ошибка, -2 — версия изменилась. ID и порядок сообщения сохраняются. */
|
||||
int chat_core_edit_message(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32],
|
||||
uint64_t expected_ver, const char* text, size_t len);
|
||||
|
||||
/* Подготовить файл в worker, затем сохранить и разослать сообщение; результат — CHAT_EVT_MSG_SUBMITTED при request_id!=0.
|
||||
* req и data копируются до возврата и остаются у вызывающего; файлы должны жить до завершения подготовки.
|
||||
* media_copy копирует src в dest, media_video запускает транскодирование. Ошибка также приходит событием. */
|
||||
void chat_core_submit_media_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req);
|
||||
* media_copy копирует src в dest, media_video запускает транскодирование. Ошибка также приходит событием.
|
||||
* Необязательный complete(arg,success) вызывается ровно один раз после сохранения/отказа и освобождения задачи,
|
||||
* в том числе синхронно при отказе запуска;arg принадлежит caller и живёт до callback. */
|
||||
typedef void (*chat_media_submitted_fn)(void* arg, int success);
|
||||
void chat_core_submit_media_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req,
|
||||
chat_media_submitted_fn complete, void* arg);
|
||||
|
||||
// Ручная загрузка вложения; для зашифрованного файла также запрашивает ключ автора.
|
||||
/* Ручная загрузка по локальному номеру строки; внешняя SQL-транзакция запрещена.
|
||||
* Manifest ограничен общим DB_SYNC_MESSAGE_MAX. Зашифрованный индекс восстанавливается из истории до wanted/загрузки;
|
||||
* отсутствие plaintext сбрасывает ready с сохранением ключа. Отказ SQL логируется, состояние модуля атомарно сохраняется. */
|
||||
void chat_core_attachment_download(struct UTUN_INSTANCE* inst, const char* channel_id, int64_t msg_id);
|
||||
|
||||
// ── DB-операции (оставлены для интроспекции) ──
|
||||
@@ -94,48 +120,57 @@ void chat_core_attachment_download(struct UTUN_INSTANCE* inst, const char* chann
|
||||
// Число локальных сообщений; 0 также при отсутствии БД или ошибке запроса.
|
||||
uint32_t chat_core_count(struct UTUN_INSTANCE* inst, const char* ch_id);
|
||||
|
||||
/* Хеш записи по позиции с нуля в порядке (timestamp, node_id); hash_out — 32 байта.
|
||||
* 0 = запрос выполнен (нет записи — нулевой хеш), -1 = ошибка. */
|
||||
int chat_core_chain_hash_at(struct UTUN_INSTANCE* inst, const char* ch_id, uint32_t pos, uint8_t* hash_out);
|
||||
|
||||
/* Список ID: count:u16, затем [длина:u8, строка]; buf_size >= 2. 0/-1.
|
||||
* Списки ограничены буфером; out_len — записанные байты, числа в порядке байтов хоста. */
|
||||
* Списки ограничены буфером/count<=65535;out_len — записанные байты,числа native.
|
||||
* SQL читается целиком даже после заполнения буфера;отказ сохраняет buf и обнуляет допустимый out_len.
|
||||
* Каналы идут по created_at,channel_id. */
|
||||
int chat_core_list_channels(struct UTUN_INSTANCE* inst, uint8_t* buf, size_t buf_size, size_t* out_len);
|
||||
|
||||
// Список участников: count:u16, затем node_id:u64; buf_size >= 2. 0/-1.
|
||||
// Неудалённые участники:count:u16,node_id:u64;unsigned ID по возрастанию;канал канонический,остальной контракт как у list_channels.
|
||||
int chat_core_list_peers(struct UTUN_INSTANCE* inst, const char* ch_id, uint8_t* buf, size_t buf_size, size_t* out_len);
|
||||
|
||||
/* Ключи X25519/Ed25519 (по 32 байта) и список адресов из БД; buf_size >= 65.
|
||||
* out_len — размер результата; 0 = прочитано, -1 = нет узла или ошибка. */
|
||||
/* Ключи X25519/Ed25519 (по32 байта,NULL в БД→нули),count:u8,затем адреса:
|
||||
* family:u8,protocol:u8,address_len:u8,address[4/16],port:u16,rtt:i16;числа native,RTT насыщается.
|
||||
* buf_size>=65;не более255 целых первых адресов в детерминированном порядке БД.
|
||||
* 0 — прочитано,-1 — нет узла/ошибка,buf сохраняется,out_len=0. Проверяется весь SQL-снимок. */
|
||||
int chat_core_load_nodeinfo(struct UTUN_INSTANCE* inst, uint64_t node_id, uint8_t* buf, size_t buf_size, size_t* out_len);
|
||||
|
||||
// ── Создание канала (GUI → uasync) ──
|
||||
|
||||
struct chat_channel_create {
|
||||
struct UTUN_INSTANCE* inst;
|
||||
char channel_id[64];
|
||||
char name[128];
|
||||
uint64_t owner_node_id;
|
||||
uint8_t x25519_pubkey[32];
|
||||
uint8_t x25519_privkey[32];
|
||||
uint8_t ed25519_pubkey[32];
|
||||
uint8_t ed25519_privkey[32];
|
||||
uint8_t signature[64];
|
||||
struct UTUN_INSTANCE* inst; /* Заимствован trampoline; прямой вызов использует свой аргумент inst. */
|
||||
char channel_id[64]; /* NUL-строка: каноническое десятичное число 1..INT64_MAX. */
|
||||
char name[128]; /* NUL-строка до127 байт, без молчаливого обрезания. */
|
||||
uint64_t owner_node_id; /* node_id создающего instance. */
|
||||
uint8_t x25519_pubkey[32]; /* Канальный ключ шифрования; должен соответствовать privkey. */
|
||||
uint8_t x25519_privkey[32];/* Независимый приватный X25519-ключ канала. */
|
||||
uint8_t ed25519_pubkey[32];/* Канальный ключ подписи; должен соответствовать raw seed. */
|
||||
uint8_t ed25519_privkey[32];/* Raw Ed25519 seed, без преобразования sc_derive_ed25519_pubkey. */
|
||||
uint8_t signature[64]; /* Подпись id\0 || name\0 || owner(native u64) || X25519 || Ed25519. */
|
||||
};
|
||||
|
||||
struct chat_create_auto_arg { struct UTUN_INSTANCE* inst; char name[128]; };
|
||||
|
||||
// Подготовить локальные таблицы, группу и синхронизацию канала. Членства на других узлах не даёт.
|
||||
void chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id);
|
||||
/* Подготовить таблицы, группу и синхронизацию; 0/-1. Ошибка удаляет новые runtime-ресурсы,
|
||||
* проверенные данные БД сохраняются. Членства на других узлах не даёт. */
|
||||
int chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id);
|
||||
|
||||
// Создать канал по готовым ключам и метаданным req; req остаётся у вызывающего.
|
||||
void chat_core_create_channel(struct UTUN_INSTANCE* inst, struct chat_channel_create* req);
|
||||
/* Создать новый канал:проверить пары ключей/подпись,одним commit сохранить описание,root JOIN/профиль/приглашение и Merkle.
|
||||
* 0 — сохранено,-1 — отказ без публикации;req заимствован и не меняется. Владелец — этот instance.
|
||||
* Внешняя транзакция и занятый ID запрещены. CHANNEL_UPDATED — после commit и попытки runtime-подготовки.
|
||||
* Отказ подготовки не отменяет созданный канал:можно повторить ensure_channel_ready. Неудачный rollback
|
||||
* с открытой транзакцией запрашивает stop/закрытие БД владельцем вне callback. */
|
||||
int chat_core_create_channel(struct UTUN_INSTANCE* inst, const struct chat_channel_create* req);
|
||||
|
||||
// Забирает u_malloc-структуру chat_channel_create, очищает ключи и освобождает её после вызова.
|
||||
void chat_core_create_channel_trampoline(void* arg);
|
||||
|
||||
// Сгенерировать ключи и создать канал, добавив себя владельцем.
|
||||
void chat_core_create_channel_auto(struct UTUN_INSTANCE* inst, const char* name);
|
||||
/* Сгенерировать независимые пары X25519/Ed25519 и создать канал. ID>0 — сохранён,0 — отказ.
|
||||
* name — NUL-строка<=127байт,не обрезается. Проверяет RNG/генерацию/извлечение/подписи;
|
||||
* временные приватные ключи очищаются. Внешняя транзакция отклоняется до генерации. */
|
||||
uint64_t chat_core_create_channel_auto(struct UTUN_INSTANCE* inst, const char* name);
|
||||
|
||||
// Забирает и освобождает u_malloc-структуру chat_create_auto_arg; instance должен дожить до выполнения.
|
||||
void chat_core_create_channel_auto_trampoline(void* arg);
|
||||
|
||||
// ── Локальное удаление канала (GUI → uasync) ──
|
||||
@@ -149,19 +184,23 @@ struct chat_delete_channel_req {
|
||||
* (соединения/синк), db_sync инстанса, таблиц msg_ и peers_, медиафайлов и
|
||||
* merkle-хешей. Работает в uasync-потоке. Другие узлы канал не теряют.
|
||||
* Исходящие video/register задачи сначала завершаются; удаление продолжит последний completion.
|
||||
* Новые исходящие медиа этого канала в это время отклоняются. Событие CHANNEL_DELETED приходит после удаления метаданных;
|
||||
* ошибка файловой очистки логируется отдельно, записи неочищенных файлов сохраняются для повтора. */
|
||||
* Новые исходящие медиа этого канала в это время отклоняются. Внешняя SQL-транзакция запрещена до отмены ресурсов.
|
||||
* Ключи/запросы/outbox удаляются триггером атомарно с метаданными; при SQL-ошибке данные и файлы сохраняются,
|
||||
* сетевые ресурсы уже могут быть отменены — требуется повтор. Открытая после ошибки транзакция требует stop владельцем.
|
||||
* CHANNEL_DELETED приходит после commit метаданных и попытки файловой очистки. Её I/O-ошибка логируется отдельно;
|
||||
* остатки media_files сохраняются, собственный каталог plaintext можно очистить повторно после commit. */
|
||||
void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id);
|
||||
|
||||
void chat_core_delete_channel_trampoline(void* arg);
|
||||
|
||||
// ── Утилиты ──
|
||||
|
||||
// Задать локальный ID в контексте чата; ключи и идентичность ядра не меняет.
|
||||
void chat_core_set_my_node_id(struct UTUN_INSTANCE* inst, uint64_t node_id);
|
||||
|
||||
// Сохранить имя узла и обновить собственные member-записи каналов.
|
||||
void chat_core_update_my_name(struct UTUN_INSTANCE* inst, const char* name);
|
||||
/* Имя (NUL,<=127байт,включая пустое):0 — committed/no-op,-1 — отказ без изменения runtime.
|
||||
* Один BEGIN IMMEDIATE сохраняет local_identity,nodes,подписанный NODEINFO и собственные полные member-блоки/Merkle.
|
||||
* Ожидающий JOIN без полного member пропускается. No-op не меняет версии и не вызывает commit hook.
|
||||
* Только после commit обновляются inst/LAN и идут NODEINFO/member уведомления;внешняя транзакция запрещена.
|
||||
* Неудачный rollback с открытой транзакцией запрашивает stop/закрытие БД владельцем. Конфиг не сохраняется. */
|
||||
int chat_core_update_my_name(struct UTUN_INSTANCE* inst, const char* name);
|
||||
|
||||
struct update_my_name_arg { struct UTUN_INSTANCE* inst; char name[128]; };
|
||||
void chat_core_update_my_name_trampoline(void* arg);
|
||||
@@ -173,24 +212,31 @@ void chat_core_sync_my_addresses(struct UTUN_INSTANCE* inst);
|
||||
* Адреса обновляются отдельно через topo_node; подпись member-блока их не содержит. */
|
||||
void chat_core_update_my_member(struct UTUN_INSTANCE* inst);
|
||||
|
||||
// Сохранение key-value в ui_state (вызывается из uasync-потока)
|
||||
void chat_core_save_ui_state(struct UTUN_INSTANCE* inst, const char* key, const char* value);
|
||||
/* Сохранить key/value в uasync:0 — committed/no-op,-1 — отказ;key непустой,value допускает пустую строку.
|
||||
* Собственная транзакция проверяет все SQL-этапы;внешняя запрещена,no-op не вызывает commit hook.
|
||||
* Ошибка сохраняет прежнее значение;unrecovered rollback запрашивает owner stop/DB close. */
|
||||
int chat_core_save_ui_state(struct UTUN_INSTANCE* inst, const char* key, const char* value);
|
||||
|
||||
/* Per-channel «play sound on new message» (ui_state: ch_<id>_sound_on_message, default 1=on).
|
||||
* set — сохраняет и рассылает пирам через chat_sync (троттлинг db_sync на сервере). */
|
||||
* get возвращает0/1,-1 при ошибке;отсутствие записи —1. set возвращает0/-1,
|
||||
* после собственного commit/no-op рассылает желаемое значение через chat_sync best effort.
|
||||
* SQL-отказ не рассылается,идентичный повтор повторяет доставку. ch_id — канонический ID канала. */
|
||||
int chat_core_get_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id);
|
||||
|
||||
void chat_core_set_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id, int on);
|
||||
int chat_core_set_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id, int on);
|
||||
|
||||
struct chat_sound_arg { struct UTUN_INSTANCE* inst; char ch_id[64]; int on; };
|
||||
void chat_core_set_sound_on_message_trampoline(void* arg);
|
||||
|
||||
/* Полный request принадлежит trampoline/u_free;data содержит key+NUL,value+NUL в пределах массива. */
|
||||
struct save_ui_state_arg { struct UTUN_INSTANCE* inst; char data[1024]; };
|
||||
void chat_core_save_ui_state_trampoline(void* arg);
|
||||
|
||||
// Установка chat-настройки (name=value) через chat_setting API (GUI → uasync)
|
||||
void chat_core_set_setting(struct UTUN_INSTANCE* inst, const char* name, const char* value);
|
||||
/* Установить через chat_setting:0 — успех,-1 — неизвестное имя,-2 — валидация,-3 — LAN apply pending.
|
||||
* Только успешный group_autoconnect запускает пересмотр групп;остальные эффекты — в chat_setting.h. */
|
||||
int chat_core_set_setting(struct UTUN_INSTANCE* inst, const char* name, const char* value);
|
||||
|
||||
/* Trampoline владеет полным request/u_free;data — name+NUL,value+NUL,обе строки проверяются в границах. */
|
||||
struct chat_setting_arg { struct UTUN_INSTANCE* inst; char data[256]; };
|
||||
void chat_core_set_setting_trampoline(void* arg);
|
||||
|
||||
@@ -223,12 +269,16 @@ struct attachment_dl_req {
|
||||
};
|
||||
void chat_core_attachment_download_trampoline(void* arg);
|
||||
|
||||
// Обновление local_attrs для записи (вызывается из uasync-потока)
|
||||
/* Заменить local_attrs указанной подписи/timestamp:0 — committed,-1 — отказ/нет записи/не JSON-object.
|
||||
* Канал канонический,timestamp1..INT64_MAX,подпись64 байта. Собственная транзакция,чужая запрещена;
|
||||
* ошибка bind/step/finalize/commit откатывается,незавершённый rollback запрашивает owner stop. */
|
||||
int chat_core_update_local_attrs(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t ts,
|
||||
const uint8_t* author_sig,
|
||||
const char* local_attrs_json);
|
||||
|
||||
// Пометить голосовое сообщение как проигранное (добавляет "pl":1 в local_attrs)
|
||||
/* Добавить/заменить pl=1 через JSON,сохранив остальные local_attrs любой допустимой длины.
|
||||
* NULL/пустые attrs становятся объектом;неверный объект не перезаписывается. Первая строка по id
|
||||
* с timestamp/author;собственная транзакция как у update_local_attrs,результат/ошибки логируются. */
|
||||
void chat_core_mark_voice_played(struct UTUN_INSTANCE* inst, const char* channel_id, uint64_t ts,
|
||||
uint64_t author_node_id);
|
||||
|
||||
@@ -241,51 +291,70 @@ struct voice_played_req {
|
||||
uint64_t author_node_id;
|
||||
};
|
||||
|
||||
// Сбор статуса (NTP + connections) и отправка в GUI. arg = struct UTUN_INSTANCE* (inst)
|
||||
/* Сбор статуса (NTP + connections) в uasync. arg — заимствованный inst.
|
||||
* Ответ CHAT_EVT_STATUS_REFRESH: UTF-8 без завершающего NUL в длине; размер растёт по данным.
|
||||
* Текст заимствован только на время синхронного event callback; отказ памяти/форматирования логируется без события. */
|
||||
void chat_core_collect_status_trampoline(void* arg);
|
||||
|
||||
// Лёгкий запрос нагрузки uasync для GUI. arg = inst, ответ CHAT_EVT_CPU_LOAD.
|
||||
void chat_core_collect_cpu_load_trampoline(void* arg);
|
||||
|
||||
// Сбор списка ETCP-соединений и отправка в GUI (binary packet). arg = inst
|
||||
/* Список до250 ETCP-соединений:arg — заимствованный inst,ответ CONN_LIST [count:u16 native][entry:46]*.
|
||||
* Entry:peer:u64,flags:u8,links:u8,rtt:u16,inflight_KiB:u16,name:32;числа native,links/inflight насыщаются. */
|
||||
void chat_core_collect_conn_list_trampoline(void* arg);
|
||||
|
||||
struct chat_node_arg { struct UTUN_INSTANCE* inst; uint64_t node_id; };
|
||||
|
||||
// Сбор полных метрик одного соединения (arg = u_malloc struct chat_node_arg)
|
||||
/* Полные метрики одного соединения, вызывается в uasync. arg — u_malloc struct chat_node_arg,
|
||||
* trampoline освобождает arg; inst остаётся заимствованным. CHAT_EVT_CONN_METRICS содержит UTF-8
|
||||
* без завершающего NUL в длине; буфер растёт по числу линков, при OOM нет частичного события.
|
||||
* Данные ответа заимствованы только на время синхронного event callback. */
|
||||
void chat_core_collect_conn_metrics_trampoline(void* arg);
|
||||
|
||||
// Сбор live-снапшота для панели деталей участника (arg = u_malloc struct chat_node_arg)
|
||||
void chat_core_collect_member_detail_trampoline(void* arg);
|
||||
/* Диагностика карточки участника: запрос/codec находятся в chat_member.h. */
|
||||
|
||||
// ── Headless CLI API ──
|
||||
|
||||
// Список каналов с метаданными в JSON: [{id,name,owner_id,owner_name,peers,msgs,online}]
|
||||
/* JSON-списки:0 — полный JSON с NUL,-1 — ошибка;out_len без NUL,при отказе0,допустимый buf очищается.
|
||||
* buf/out_len обязательны,buf_size>=3. SQL/память/переполнение не дают успешный префикс;
|
||||
* Строки экранируются целиком;channels/members отклоняют скрытый NUL в канальных/профильных SQLite TEXT.
|
||||
* Каналы:[{id,name,owner_id,owner_name,peers,msgs,online}];отложенная history-таблица означает msgs=0. */
|
||||
int chat_core_get_channels_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len);
|
||||
|
||||
/* Сообщения канала в JSON: [{id,ts,author_id,author_name,content_type,data,local_attrs}].
|
||||
* От новых к старым; count<=0 — до 100 записей, offset — сколько пропустить. */
|
||||
* От новых к старым;count<=0 — до100 записей,offset>=0 — сколько пропустить.
|
||||
* Канал — канонический ID1..INT64_MAX;общий контракт буфера как у channels_json. */
|
||||
int chat_core_get_messages_json(struct UTUN_INSTANCE* inst, const char* ch_id, int count, int offset,
|
||||
char* buf, size_t buf_size, size_t* out_len);
|
||||
|
||||
/* Мемберы в JSON: [{node_id,name,online,connected,adm_tags,x25519,ed25519,addrs}].
|
||||
* connected означает наличие транспорта в реестре, а не его UP или готовность группы. */
|
||||
* connected означает наличие транспорта в реестре,а не его UP/готовность группы;deleted показывается offline.
|
||||
* addrs.proto=UDP/TCP/TCP+REALITY,IPv4/IPv6 текстовые;канал/буфер как у messages/channels_json. */
|
||||
int chat_core_get_members_json(struct UTUN_INSTANCE* inst, const char* ch_id, char* buf, size_t buf_size, size_t* out_len);
|
||||
|
||||
// Имя узла из таблицы nodes
|
||||
/* Имя nodes с NUL,при необходимости обрезанное до sz-1 байт:0 — прочитано/узел отсутствует,-1 — ошибка.
|
||||
* out/sz>0 обязательны;отсутствие узла и ошибка оставляют пустую строку (если буфер допустим). */
|
||||
int chat_core_get_node_name(struct UTUN_INSTANCE* inst, uint64_t node_id, char* out, size_t sz);
|
||||
|
||||
// Ссылка на описание avatars/<sha256>.json внутри дерева своего UID/канала. server=0 удаляет ссылку.
|
||||
/* Ссылка avatars/<sha256>.json внутри дерева своего UID/канала;server=0 удаляет ссылку,file остаётся обязательной NUL-строкой.
|
||||
* 0 — committed/семантически прежнее,-1 — отказ;чужая транзакция запрещена. Storage-роль сервера проверяется под блокировкой. */
|
||||
int chat_core_set_my_avatar(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t server, const char* file);
|
||||
|
||||
/* Построить {name:string},включая Unicode/control escaping;0/-1,при ошибке out не меняется. */
|
||||
int chat_core_name_userinfo(const char* name, char* out, size_t capacity);
|
||||
|
||||
/* Прочитать собственный подписанный профиль:0/-1,при отказе все выходы сохраняются.
|
||||
* Проверяет SQL,размеры,JOIN/update-подписи и целый плоский JSON с уникальными строковыми полями.
|
||||
* out требует capacity>=длина+1,signature64,timestamp — версия update_ts в секундах/монотонных шагах. */
|
||||
int chat_core_get_my_profile(struct UTUN_INSTANCE* inst, const char* channel, char* out, size_t capacity,
|
||||
uint8_t signature[64], uint64_t* timestamp);
|
||||
|
||||
/* Одно подписанное обновление нескольких собственных строковых опций. NULL value удаляет ключ.
|
||||
* Остальные поля сохраняются; имя/аватар меняются своими API. Ошибка не меняет БД/Merkle.
|
||||
* Вызов только в uasync-потоке, вне внешней SQLite-транзакции. */
|
||||
* Остальные подписанные поля сохраняются;неподписанные старые атрибуты не авторизуются новым обновлением.
|
||||
* Имя/аватар меняются своими API. Чтение/проверка/версия/запись/Merkle защищены одним BEGIN IMMEDIATE.
|
||||
* Семантический no-op сохраняет подпись/версию независимо от порядка ключей/escaping;ошибка не меняет БД/Merkle.
|
||||
* Уведомления идут после commit,без SQLite cursor. Вызов в uasync вне чужой транзакции;
|
||||
* неудачный rollback с открытой транзакцией запрашивает stop/закрытие БД владельцем. */
|
||||
struct chat_profile_option { const char* key; const char* value; };
|
||||
int chat_core_set_my_profile_options(struct UTUN_INSTANCE* inst, const char* channel,
|
||||
const struct chat_profile_option* options, size_t count);
|
||||
@@ -304,26 +373,41 @@ void chat_core_set_my_avatar_trampoline(void* arg);
|
||||
|
||||
struct chat_member_tags;
|
||||
|
||||
// Загрузить текущие adm_tags мембера в память. Возвращает NULL если мембера нет.
|
||||
/* Загрузить проверенный owner-снимок полного участника:NULL при отсутствии/placeholder/SQL/подписи/формате.
|
||||
* Объект принадлежит caller до free,используется в uasync и освобождается до остановки chat-сервиса. */
|
||||
struct chat_member_tags* chat_member_tags_load(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id);
|
||||
|
||||
// Установить ключ. val=NULL → удалить ключ. ver — особый, автоинкремент при commit.
|
||||
/* Изменить желаемое поле:0/-1;отказ не меняет editor. val=NULL удаляет поле;admin/moder принимают yes/no.
|
||||
* Пустой ключ/ver запрещены,версию выбирает commit;место для ver резервируется в лимите16 ключей. */
|
||||
int chat_member_tags_set(struct chat_member_tags* t, const char* key, const char* val);
|
||||
|
||||
// Получить значение ключа. Возвращает NULL если ключ не найден.
|
||||
const char* chat_member_tags_get(struct chat_member_tags* t, const char* key);
|
||||
|
||||
// Заменить все теги данными из JSON-строки (плоский, строковые значения).
|
||||
/* Заменить желаемые теги:0/-1,при отказе editor не меняется. Весь объект<=8192 байта,уникальные
|
||||
* непустые ключи,только строки;admin/moder yes/no. Необязательный ver обязан совпадать с исходным снимком. */
|
||||
int chat_member_tags_replace(struct chat_member_tags* t, const char* json);
|
||||
|
||||
/* Сохранить: сериализовать в JSON, инкрементировать ver, подписать канальным Ed25519,
|
||||
* записать через member_sync_put() → синхронизируется всем. */
|
||||
/* Собственный BEGIN IMMEDIATE проверяет исходный owner-снимок и фиксирует новую подпись/версию/Merkle.
|
||||
* 0 — committed/no-op,-2 — конфликт с изменившимся owner-блоком,-1 — прочий отказ;чужая транзакция запрещена.
|
||||
* Отказ сохраняет editor/БД,успех обновляет editor для следующей правки. No-op сохраняет версию/подпись.
|
||||
* Версия1..INT_MAX;admin/moder сохраняют историю,отключение дописывает d<seconds>. Приватный ключ очищается.
|
||||
* Уведомления после commit без cursor;при неудачном rollback запрашивается stop/закрытие БД владельцем. */
|
||||
int chat_member_tags_commit(struct UTUN_INSTANCE* inst, struct chat_member_tags* t);
|
||||
|
||||
/* uasync: атомарно сохранить локальный nickname и частичный owner_json для участника channel/node.
|
||||
* Строки заимствованы на время вызова; nickname="" снимает псевдоним, owner_json="" сохраняет только ник.
|
||||
* Непустой JSON использует правила tags_req ниже, включая проверку ver; нужен канальный приватный ключ для изменения прав.
|
||||
* 0 — commit и MEMBERS_CHANGED, -1 — отказ, -2 — конфликт owner-снимка. Чужая транзакция запрещена.
|
||||
* Ошибка не сохраняет частичный результат; SQL/подпись/rollback логируются, зависший rollback запрашивает stop. */
|
||||
int chat_core_save_member(struct UTUN_INSTANCE* inst, const char* channel, uint64_t node,
|
||||
const char* nickname, const char* owner_json);
|
||||
|
||||
// Освободить без сохранения.
|
||||
void chat_member_tags_free(struct chat_member_tags* t);
|
||||
|
||||
// Трамплин для gui_bridge: arg = struct chat_member_tags_req* (доверенная память).
|
||||
/* Трамплин для gui_bridge:владеет req/u_free. json — частичный объект желаемых полей,неуказанные сохраняются;
|
||||
* необязательный ver проверяет снимок формы. Пустая строка — no-op;ошибки логируются без commit. */
|
||||
struct chat_member_tags_req {
|
||||
struct UTUN_INSTANCE* inst;
|
||||
char ch_id[64];
|
||||
@@ -335,6 +419,7 @@ void chat_member_tags_commit_trampoline(void* arg);
|
||||
// ── Передача прав админа (владелец → другому узлу) ──
|
||||
|
||||
// Передать оба канальных privkey выбранному узлу (encrypted+signed через etcp_router).
|
||||
// uasync-поток; NULL/неинициализированный inst отклоняется с ERROR. Постановка в транспорт не подтверждает импорт.
|
||||
void chat_core_transfer_admin(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t target_node_id);
|
||||
|
||||
void chat_core_transfer_admin_trampoline(void* arg);
|
||||
@@ -345,4 +430,7 @@ struct chat_transfer_admin_req {
|
||||
uint64_t target_node_id;
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif /* CHAT_CORE_H */
|
||||
+64
-26
@@ -2,7 +2,9 @@
|
||||
* chat_core_priv.h — внутренний заголовок для под-модулей chat_core
|
||||
*
|
||||
* Предоставляет per-instance chat_core_ctx через CC(inst), общие SQL/сериализационные хелперы
|
||||
* и внутренние операции частей chat_core/member_sync. Не включать извне chat/.
|
||||
* и внутренние операции частей chat_core/member_sync. Не является frontend API.
|
||||
* Все обращения — в uasync; контекст создаёт/освобождает chat_core. Сначала закрываются
|
||||
* приём команд, callbacks и media workers, затем освобождается контекст; БД остаётся ядру.
|
||||
*/
|
||||
|
||||
#ifndef CHAT_CORE_PRIV_H
|
||||
@@ -19,8 +21,15 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#define CC_ID "chat_core"
|
||||
#define CHAT_MEDIA_BACKFILL_CHANNELS 16
|
||||
#define CHAT_CHANNEL_DESCRIPTION_MAX (20 + 256 + 8 + 32 + 32)
|
||||
|
||||
/* ── Base64 encode/decode (URL-safe, used for msg "d" field prefix) ── */
|
||||
/* Base64url для префикса поля d:заимствованные непересекающиеся буферы.
|
||||
* encode возвращает записанные байты,дописывает '=' для неполной тройки,без NUL;
|
||||
* при нехватке места останавливается перед очередной четверкой.
|
||||
* decode пропускает чужие символы,останавливается на '=',принимает хвост из2/3 символов;
|
||||
* возвращает ограниченный dst_cap префикс,не валидирует каноничность или весь вход.
|
||||
* Строгие потребители отдельно сверяют длину и повторное кодирование (chat_attachment_identity). */
|
||||
|
||||
static const char B64_ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
@@ -80,50 +89,79 @@ struct media_index_result;
|
||||
struct ms_apply_cbk; /* member_sync.c */
|
||||
struct group_avatar;
|
||||
struct media_submit_ctx;
|
||||
struct ak_file;
|
||||
struct chat_whisper;
|
||||
struct member_route_event;
|
||||
|
||||
struct chat_core_ctx {
|
||||
struct UTUN_INSTANCE* inst;
|
||||
sqlite3* db;
|
||||
char db_path[512];
|
||||
uint8_t shared_db;
|
||||
uint64_t my_node_id;
|
||||
uint8_t initialized;
|
||||
/* per-instance sub-module state (was file-scope statics) */
|
||||
struct ms_props_cbk* props_cbks; /* was member_sync.c g_props_cbks */
|
||||
struct ms_apply_cbk* apply_cbks; /* was member_sync.c g_apply_cbks */
|
||||
struct group_avatar* group_avatar;
|
||||
uint64_t media_cache_bytes; /* was chat_msg.c g_media_cache_bytes */
|
||||
struct UTUN_INSTANCE* inst; /* Заимствованный владелец;не уничтожается из callbacks чата. */
|
||||
sqlite3* db; /* Заимствована у ядра;chat_core её не открывает/не закрывает. */
|
||||
char db_path[512]; /* Копия пути init для идентичности запуска/default media base;не отдельное соединение БД. */
|
||||
uint64_t my_node_id; /* node_id владельца,снимок при init. */
|
||||
uint8_t initialized; /* Метаданные committed,подписки установлены;не означает готовность всего chat_service. */
|
||||
struct chat_whisper* whisper; /* Ленивый владелец модели/одной задачи; stop отсоединяет до ожидания workers. */
|
||||
struct member_route_event* member_route_events; /* Отложенные UI-снимки топологии по (канал,узел), без TOPO_* указателей. */
|
||||
struct ms_props_cbk* props_cbks; /* Владение списком подписчиков свойств member_sync. */
|
||||
unsigned props_dispatch_depth; /* снятие props-подписки откладывает free до выхода из вложенных callbacks */
|
||||
struct ms_apply_cbk* apply_cbks; /* Владение списком уведомлений о принятии мембера. */
|
||||
unsigned apply_dispatch_depth; /* снятые apply callbacks освобождаются после выхода из всех вложенных вызовов */
|
||||
struct group_avatar* group_avatar; /* Контекст запросов/передач аватарки группы;освобождает group_avatar_destroy. */
|
||||
uint64_t media_cache_bytes; /* Производный размер уникальных файлов/расшифрованных копий;сверяется полным scan. */
|
||||
struct media_submit_ctx* media_submissions; /* исходящие задачи до последнего callback; удаление канала ждёт их */
|
||||
void* media_backfill_timer;/* was chat_msg.c g_media_startup_backfill_timer */
|
||||
int media_backfill_ran; /* was chat_msg.c g_media_startup_backfill_ran */
|
||||
struct UTUN_INSTANCE* media_backfill_inst; /* was chat_msg.c g_media_startup_backfill_inst */
|
||||
void* media_channel_backfill_timer; /* debounce per-channel докачки */
|
||||
char media_channel_backfill[16][64]; /* pending ch_id */
|
||||
int media_channel_backfill_pending;
|
||||
void* attachment_timer;
|
||||
int attachment_closing;
|
||||
int attachment_initialized;
|
||||
void* media_backfill_timer;/* Общий scan после debounce истории либо повтор при отказе/переполнении batch. */
|
||||
int media_backfill_ran; /* Первый общий scan уже запущен;последующие завершения не переносят его. */
|
||||
struct UTUN_INSTANCE* media_backfill_inst; /* inst при установленных done-подписках,NULL после destroy;заимствован. */
|
||||
void* media_channel_backfill_timer; /* ограниченный batch докачки/восстановления индекса через 2с от первой заявки */
|
||||
char media_channel_backfill[CHAT_MEDIA_BACKFILL_CHANNELS][64]; /* pending ch_id */
|
||||
int media_channel_backfill_pending; /* Число занятых строк batch,0..16. */
|
||||
void* attachment_timer; /* Периодический pump запросов ключей/отложенных completion,1с. */
|
||||
int attachment_closing; /* Quiesce запрещает новые операции и публикацию worker-результатов. */
|
||||
int attachment_initialized; /* Зарегистрированы модели/обработчики attachment;не признак наличия ключей. */
|
||||
struct ak_file* attachment_files; /* workers и завершения, ожидающие SQL commit; владеет chat_attachment */
|
||||
unsigned attachment_file_count; /* максимум MEDIA_ASYNC_MAX_TASKS, включая отложенные завершения */
|
||||
};
|
||||
|
||||
/* Per-instance chat context. inst->chat_core обязателен после chat_core_init. */
|
||||
/* Заимствованный контекст или NULL;inst обязан быть ненулевым,макрос не проверяет lifecycle. */
|
||||
#define CC(inst) ((inst)->chat_core)
|
||||
|
||||
/* ── Хелперы ── */
|
||||
|
||||
/* Внутренние имена таблиц:не валидатор ID. Ненулевые строки/буферы,out_sz>0;
|
||||
* sanitize сохраняет ASCII alnum/_,остальное заменяет '_',обрезает до out_sz-1 и ставит NUL.
|
||||
* table_name предварительно ограничивает очищенный ID63 байтами,добавляет msg_/peers_ через snprintf. */
|
||||
void sanitize_ch_id(const char* ch_id, char* out, size_t out_sz);
|
||||
void msg_table_name(const char* ch_id, char* buf, size_t sz);
|
||||
void peers_table_name(const char* ch_id, char* buf, size_t sz);
|
||||
|
||||
/* Тихая проверка канонического десятичного ID1..INT64_MAX;0 — отказ,NULL также недопустим.
|
||||
* Использующий модуль логирует ошибку с контекстом своей операции. */
|
||||
uint64_t chat_channel_id(const char* text);
|
||||
|
||||
/* Проверить весь JSON-object<=limit:уникальные ключи,только строковые значения,без скрытого NUL.
|
||||
* SQLite заимствована;0/-1 с логом,callback может быть NULL. Callback получает строки до следующего step,
|
||||
* строит только временный результат и возвращает0/-1;при поздней ошибке caller отбрасывает весь результат. */
|
||||
int chat_json_strings(sqlite3* db, const char* text, size_t limit,
|
||||
int (*visit)(const char* key, const char* value, void* arg), void* arg);
|
||||
|
||||
/* Байты подписи описания:канонический ID+NUL,имя<=255байт+NUL,owner native-u64,X25519/Ed25519 public[32].
|
||||
* Возвращает длину/-1;аргументы обязательны,при отказе out не меняется. Общий формат create/JOIN/invite. */
|
||||
int chat_channel_description_data(const char* channel, const char* name, uint64_t owner,
|
||||
const uint8_t x25519[32], const uint8_t ed25519[32], uint8_t* out, size_t capacity);
|
||||
|
||||
/* ── db_sync registry (chat_core.c) ── */
|
||||
|
||||
/* Заимствованный зарегистрированный history instance канонического канала;NULL при отсутствии/неверном ID. */
|
||||
struct DB_SYNC_INSTANCE* si_find(struct UTUN_INSTANCE* inst, const char* ch_id);
|
||||
void si_register(struct UTUN_INSTANCE* inst, struct DB_SYNC_INSTANCE* si, const char* ch_id);
|
||||
|
||||
/* ── db_sync callback (chat_msg.c) ── */
|
||||
|
||||
/* db_sync callback после commit:производный attachment-индекс,feedback/UI,live download и last_msg_at.
|
||||
* data/message_id заимствованы до возврата;updated даёт MSG_UPDATED без звука/unread/повторной загрузки. */
|
||||
void on_msg_inserted(struct DB_SYNC_INSTANCE* si, const uint8_t message_id[32], uint64_t record_ts,
|
||||
const char* data, size_t len, uint64_t author, int initial_sync, void* arg);
|
||||
/* Разобрать подписанный manifest канального медиа; result освобождается media_index_result_free. */
|
||||
const char* data, size_t len, uint64_t author, int initial_sync, int updated, void* arg);
|
||||
/* Проверить весь NUL-terminated manifest<=DB_SYNC_MESSAGE_MAX,hex,разделители и геометрию1..100 блоков.
|
||||
* 0 — result владеет block_ids/block_sigs до media_index_result_free,-1 — формат/аргументы,-2 — память.
|
||||
* При допустимых аргументах result обнуляется до разбора;подписи/содержимое файла здесь не проверяются. */
|
||||
int chat_msg_parse_media_body(const char* body, struct media_index_result* result);
|
||||
/* Учесть также расшифрованные производные файлы и применить общую квоту кеша. */
|
||||
void chat_media_cache_enforce(struct UTUN_INSTANCE* inst);
|
||||
|
||||
+17
-5
@@ -17,7 +17,7 @@
|
||||
#endif
|
||||
|
||||
void chat_event_set_handler(struct UTUN_INSTANCE* inst, chat_event_handler_fn handler) {
|
||||
if (!inst) return;
|
||||
if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "chat_event: handler requires instance"); return; }
|
||||
#ifdef __ANDROID__
|
||||
__android_log_print(ANDROID_LOG_INFO, "utun-evt", "chat_event_set_handler: handler=%p old=%p",
|
||||
(void*)handler, (void*)inst->chat_event_handler);
|
||||
@@ -26,7 +26,11 @@ void chat_event_set_handler(struct UTUN_INSTANCE* inst, chat_event_handler_fn ha
|
||||
}
|
||||
|
||||
void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
|
||||
if (inst && inst->chat_event_handler) { inst->chat_event_handler(inst, type, data, len); return; }
|
||||
if (!inst || type <= 0 || len < 0 || (len && !data)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "chat_event: invalid event instance=%p type=%d bytes=%d data=%p",
|
||||
(void*)inst, type, len, (const void*)data); return;
|
||||
}
|
||||
if (inst->chat_event_handler) { inst->chat_event_handler(inst, type, data, len); return; }
|
||||
|
||||
static const char* names[] = {
|
||||
[1]="MSG_RECEIVED", [2]="CONNECT_RESULT", [3]="NEW_PEER",
|
||||
@@ -40,7 +44,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data,
|
||||
[20]="CONN_LIST", [21]="CONN_METRICS",
|
||||
[22]="MEMBER_DETAIL", [23]="MEMBER_LIST",
|
||||
[24]="MEMBER_UPDATED", [25]="MEMBER_REMOVED",
|
||||
[26]="BGP_INFO", [27]="CONNECTING_NODES",
|
||||
[27]="CONNECTING_NODES",
|
||||
[28]="ADMIN_KEY_RECEIVED", [29]="INVITE_LINK_READY",
|
||||
[30]="INVITE_CANDIDATES", [31]="CHANNEL_DELETED",
|
||||
[32]="DM_MSG_RECEIVED", [33]="DM_CONV_UPDATED",
|
||||
@@ -50,15 +54,22 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data,
|
||||
[43]="RADIO_TALK",
|
||||
[44]="CALL_CONNECTION",
|
||||
[45]="DM_CONVERSATIONS", [46]="ATTACHMENT_PREPARATION", [47]="DM_CALL_ROUTE",
|
||||
[48]="DM_INVITE_RESULT", [49]="MEMBER_FILES_RESPONSE", [50]="MEMBER_FILES_TRANSFER",
|
||||
[51]="AVATAR_RESULT", [52]="GROUP_AVATAR_RESULT", [53]="FEEDBACK_CHANGED", [54]="FEEDBACK_RESULT",
|
||||
[55]="RADIO_MUTE_RESULT",
|
||||
[56]="MSG_SUBMITTED",
|
||||
[57]="CPU_LOAD",
|
||||
[58]="STREAM_SOURCES",
|
||||
[59]="DM_DELETE_RESULT",
|
||||
[60]="ATTACHMENT_KEYS_CHANGED",
|
||||
[61]="LOCAL_DEVICES", [62]="LOCAL_INVITE", [63]="LOCAL_DELIVERY", [64]="LOCAL_DISCOVERY_ACTIVE",
|
||||
[65]="RADIO_CALL", [66]="CALL_PHONE_BUSY", [67]="MSG_UPDATED",
|
||||
};
|
||||
const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?";
|
||||
const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : NULL;
|
||||
if (!n) n = "?";
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len);
|
||||
if (data && len > 0 && type != CHAT_EVT_LOCAL_INVITE) log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_CHAT, " ", data, len);
|
||||
if (data && len > 0 && type != CHAT_EVT_LOCAL_INVITE && type != CHAT_EVT_INVITE_LINK_READY)
|
||||
log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_CHAT, " ", data, len);
|
||||
}
|
||||
|
||||
/* Идентификатор связывает результат с конкретной отправкой, независимо от текущего канала GUI. */
|
||||
@@ -73,6 +84,7 @@ void chat_event_submitted(struct UTUN_INSTANCE* inst, uint64_t request_id, int s
|
||||
|
||||
/* ID задачи не даёт позднему завершению изменить статус другой подготовки. */
|
||||
void chat_event_attachment(struct UTUN_INSTANCE* inst, int is_dm, const char* target, const uint8_t id[16], int state) {
|
||||
if (!target || !id) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: preparation event requires target and UUID"); return; }
|
||||
char name[68];
|
||||
int n = snprintf(name, sizeof(name), "%s%s", is_dm ? "dm:" : "", target);
|
||||
if (n <= 0 || (size_t)n >= sizeof(name) || state < 0 || state > 2) {
|
||||
|
||||
+18
-14
@@ -6,7 +6,8 @@
|
||||
* post вызывает его сразу, в том же uasync-потоке; очереди событий здесь нет.
|
||||
* data заимствован только до возврата обработчика. Для передачи в другой поток нужна копия.
|
||||
*
|
||||
* Без обработчика — данные логируются через DEBUG_DEBUG + log_dump.
|
||||
* Без обработчика — DEBUG_DEBUG + log_dump; LOCAL_INVITE/INVITE_LINK_READY с секретами не дампятся.
|
||||
* Обработчик может удалить канал; stop/destroy сервиса выполняются вне callbacks.
|
||||
* Все вызовы — из uasync-потока.
|
||||
*/
|
||||
|
||||
@@ -24,14 +25,14 @@ extern "C" {
|
||||
|
||||
// Типы событий (совпадают с GUI_EVT_* в gui_bridge.h для прямой совместимости)
|
||||
#define CHAT_EVT_MSG_RECEIVED 1 /* [ch_id_len:1][ch_id:var][author_node_id:8][initial_sync:1] */
|
||||
#define CHAT_EVT_CONNECT_RESULT 2 /* [node_id:8][result:4][channel_id:8] */
|
||||
#define CHAT_EVT_CONNECT_RESULT 2 /* [node_id:8][result:4][channel_id:8][request_id:8], native */
|
||||
#define CHAT_EVT_NEW_PEER 3 /* [node_id:8] */
|
||||
#define CHAT_EVT_CHANNEL_UPDATED 4 /* [ch_id_len:1][ch_id:var] */
|
||||
#define CHAT_EVT_MEMBERS_CHANGED 5 /* [ch_id_len:1][ch_id:var] */
|
||||
#define CHAT_EVT_MY_NODE_ID 6 /* [node_id:8] */
|
||||
#define CHAT_EVT_MY_NODE_ID 6 /* [node_id:8],native;после commit метаданных и активации каналов chat_core */
|
||||
#define CHAT_EVT_AUTO_CONNECT_STATUS 7 /* [status:1][total_tried:2][node_count:2][success:2] */
|
||||
#define CHAT_EVT_CHANNEL_PEERS_ONLINE 8 /* [ch_id_len:1][ch_id:var][online_count:2] */
|
||||
#define CHAT_EVT_DB_READY 9 /* data: none */
|
||||
#define CHAT_EVT_DB_READY 9 /* нет data;готов chat_core,последующие части chat_service_start ещё могут отказать */
|
||||
#define CHAT_EVT_STATUS_REFRESH 10 /* data: status text */
|
||||
#define CHAT_EVT_KEYS_GENERATED 11 /* [pub_hex:64] */
|
||||
#define CHAT_EVT_SERVICE_STARTED 12 /* data: none */
|
||||
@@ -44,31 +45,30 @@ extern "C" {
|
||||
#define CHAT_EVT_LOCAL_SOCKETS 19 /* JSON: [{"si":id,"nm":"...","ad":"...","po":port,"pt":"TCP|UDP","st":0|1|2,"lk":n},...] */
|
||||
#define CHAT_EVT_CONN_LIST 20 /* [count:2][entry:46B]* — список ETCP-соединений */
|
||||
#define CHAT_EVT_CONN_METRICS 21 /* текст — полный дамп метрик выбранного соединения */
|
||||
#define CHAT_EVT_MEMBER_DETAIL 22 /* [node_id:8][flags:1][own_tcp_active:2][links...][socks...] — live-снапшот для панели деталей участника */
|
||||
#define CHAT_EVT_MEMBER_DETAIL 22 /* Адресованный ответ диагностики; wire описан в chat_member.h. */
|
||||
#define CHAT_EVT_MEMBER_LIST 23 /* [ch_id_len:1][ch_id:var][count:2][member:CHAT_MEMBER_DISPLAY_SIZE]* */
|
||||
#define CHAT_EVT_MEMBER_UPDATED 24 /* [ch_id_len:1][ch_id:var][count:1][member:CHAT_MEMBER_DISPLAY_SIZE] */
|
||||
#define CHAT_EVT_MEMBER_REMOVED 25 /* [ch_id_len:1][ch_id:var][node_id:8] — мембер удалён из канала */
|
||||
#define CHAT_EVT_BGP_INFO 26 /* текст: "rtt:XXms hop:X via bgp: [XXXX] name1 [XXXX] name2" */
|
||||
#define CHAT_EVT_CONNECTING_NODES 27 /* [ch_id_len:1][ch_id:var][count:2][node_id:8]* */
|
||||
#define CHAT_EVT_ADMIN_KEY_RECEIVED 28 /* [ch_id_len:1][ch_id:var][from_node_id:8] — получен канальный ключ (стал совладельцем) */
|
||||
#define CHAT_EVT_INVITE_LINK_READY 29 /* [ch_id_len:1][ch_id:var][request_id:8][link_len:2][link:var] — пустой link = ошибка */
|
||||
#define CHAT_EVT_INVITE_CANDIDATES 30 /* [ch_id_len:1][ch_id:var][auto_node_id:8][count:2][node_id:8]* — достижимые узлы для invite */
|
||||
#define CHAT_EVT_CHANNEL_DELETED 31 /* [ch_id_len:1][ch_id:var] — канал удалён локально */
|
||||
#define CHAT_EVT_DM_MSG_RECEIVED 32 /* [conv_id_len:1][conv_id:var][author_node_id:8] */
|
||||
#define CHAT_EVT_DM_CONV_UPDATED 33 /* [conv_id_len:1][conv_id:var][generation:8LE] — беседа создана/изменена */
|
||||
#define CHAT_EVT_DM_CONV_UPDATED 33 /* [conv_id_len:1][conv_id:var][generation:8 native] — беседа создана/изменена */
|
||||
#define CHAT_EVT_CALL_INCOMING 34 /* [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
|
||||
#define CHAT_EVT_CALL_RINGING 35 /* [call_id:8] */
|
||||
#define CHAT_EVT_CALL_ACCEPTED 36 /* [call_id:8] */
|
||||
#define CHAT_EVT_CALL_RINGING 35 /* [call_id:8][reason:1=0], native */
|
||||
#define CHAT_EVT_CALL_ACCEPTED 36 /* [call_id:8][reason:1=0], native */
|
||||
#define CHAT_EVT_CALL_ENDED 37 /* [call_id:8][reason:1] */
|
||||
#define CHAT_EVT_CALL_DECLINED 38 /* [call_id:8][reason:1] */
|
||||
#define CHAT_EVT_CALL_STATS 39 /* [call_id:8][rtt:2][buffer:2][tempo:2][dropped:2][underruns:2][min:2][max:2][reserve:2] */
|
||||
#define CHAT_EVT_CALL_ERROR 40 /* [call_id:8][err:1][text:var] */
|
||||
#define CHAT_EVT_DM_MESSAGES 41 /* [conv_id_len:1][conv_id:var][JSON:utf8] — сообщения беседы и состояния доставки */
|
||||
#define CHAT_EVT_CALL_ERROR 40 /* [call_id:8][err:1] */
|
||||
#define CHAT_EVT_DM_MESSAGES 41 /* [conv_id_len:1][conv_id:var][generation:8][JSON:utf8] — история указанного поколения */
|
||||
#define CHAT_EVT_CALL_PATH 42 /* [call_id:8][text:var] — BGP-маршрут до пира звонка */
|
||||
#define CHAT_EVT_RADIO_TALK 43 /* [group_id:8][src_node_id:8][stream_id:2][on:1] — кто-то начал/закончил говорить */
|
||||
#define CHAT_EVT_CALL_CONNECTION 44 /* [call_id:8][CONN_TYPE:1][next_hop_node_id:8] — текущий путь отправки звонка */
|
||||
#define CHAT_EVT_DM_CONVERSATIONS 45 /* [JSON:utf8] — последние 200 личных бесед */
|
||||
#define CHAT_EVT_ATTACHMENT_PREPARATION 46 /* [target_len:1][target:var][job_uuid:16][state:1], 0=start 1=ready 2=failed */
|
||||
#define CHAT_EVT_ATTACHMENT_PREPARATION 46 /* [target_len:1][target:var][job_uuid:16][state:1], target=channel либо "dm:"+conv;0/1/2=start/ready/failed */
|
||||
|
||||
#define CHAT_EVT_DM_CALL_ROUTE 47 /* [conv_id_len:1][conv_id:var][peer_node_id:8][group_id:8], 0 = нет BGP-пути */
|
||||
#define CHAT_EVT_DM_INVITE_RESULT 48 /* [request_id:8][result:4][peer_node_id:8][conv_id:8][generation:8], dm_invite_result */
|
||||
@@ -91,6 +91,8 @@ extern "C" {
|
||||
#define CHAT_EVT_LOCAL_DISCOVERY_ACTIVE 64 /* [active:1], Android MulticastLock */
|
||||
#define CHAT_EVT_RADIO_CALL 65 /* [id:8LE][group:8LE][peer:8LE][state:1][reason:1][incoming:1], radio_call.h */
|
||||
#define CHAT_EVT_CALL_PHONE_BUSY 66 /* [id:8LE][reserved:1]; вызывающий выбирает продолжить либо завершить */
|
||||
#define CHAT_EVT_MSG_UPDATED 67 /* [channel_len:1][channel][message_id:32] — правка после commit, без unread/звука */
|
||||
#define CHAT_EVT_GROUP_AVATAR_SNAPSHOT 68 /* Только Android JNI: id/group/version:8LE, hash:32, can_edit/failed:1, size:2LE, image:size. */
|
||||
|
||||
// ── Подключение получателя событий ──
|
||||
|
||||
@@ -101,10 +103,12 @@ void chat_event_set_handler(struct UTUN_INSTANCE* inst, chat_event_handler_fn ha
|
||||
|
||||
// ── Отправка событий из ядра ──
|
||||
|
||||
// Синхронно передать type и len байт data; память остаётся у отправителя. Формат зависит от CHAT_EVT_*.
|
||||
// Синхронно передать type>0 и len>=0 байт data; inst обязателен,data может быть NULL только при len=0.
|
||||
// Неверные аргументы логируются без доставки;формат CHAT_EVT_* проверяет получатель,память остаётся отправителю.
|
||||
void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len);
|
||||
|
||||
// Сообщить этап подготовки вложения: state=0 начало, 1 готово, 2 ошибка; target — ID канала или DM.
|
||||
// Сообщить этап подготовки: state=0 начало,1 готово,2 ошибка; target — ID без префикса, is_dm добавляет "dm:".
|
||||
// target/id обязательны, копируются в синхронный payload; неверный state/переполнение строки только логируется.
|
||||
void chat_event_attachment(struct UTUN_INSTANCE* inst, int is_dm, const char* target, const uint8_t id[16], int state);
|
||||
|
||||
// request_id=0 отключает подтверждение; вызывается один раз после сохранения либо ошибки подготовки.
|
||||
|
||||
+276
-93
@@ -41,16 +41,17 @@ static int fb_db_error(struct UTUN_INSTANCE* inst, const char* channel, const ch
|
||||
}
|
||||
|
||||
/* Канонический ID сообщения не зависит от локального порядкового номера. */
|
||||
int chat_message_id(uint64_t channel_id, uint64_t timestamp, const uint8_t signature[64], uint8_t id[32]) {
|
||||
static const char domain[] = "utun-chat-message-v1";
|
||||
uint8_t body[sizeof(domain) - 1 + 16 + 64];
|
||||
if (!channel_id || !signature || !id) {
|
||||
int chat_message_id(uint64_t channel_id, uint64_t timestamp, uint64_t author, const uint8_t nonce[16], uint8_t id[32]) {
|
||||
static const char domain[] = "utun-chat-message-v2";
|
||||
uint8_t body[sizeof(domain) - 1 + 24 + 16];
|
||||
if (!channel_id || !author || !nonce || !id) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid message identity arguments"); return -1;
|
||||
}
|
||||
memcpy(body, domain, sizeof(domain) - 1);
|
||||
fb_write64(body + sizeof(domain) - 1, channel_id);
|
||||
fb_write64(body + sizeof(domain) - 1 + 8, timestamp);
|
||||
memcpy(body + sizeof(domain) - 1 + 16, signature, 64);
|
||||
fb_write64(body + sizeof(domain) - 1 + 16, author);
|
||||
memcpy(body + sizeof(domain) - 1 + 24, nonce, 16);
|
||||
if (EVP_Digest(body, sizeof(body), id, NULL, EVP_sha256(), NULL) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: message identity digest failed"); return -1;
|
||||
}
|
||||
@@ -114,7 +115,8 @@ int chat_feedback_ensure_channel(struct UTUN_INSTANCE* inst, const char* channel
|
||||
"node_id INTEGER NOT NULL,kind INTEGER NOT NULL,ver INTEGER NOT NULL CHECK(ver>0),"
|
||||
"data BLOB NOT NULL,signature BLOB NOT NULL CHECK(length(signature)=64),"
|
||||
"UNIQUE(message_id,node_id,kind));"
|
||||
"CREATE INDEX IF NOT EXISTS idx_%s_bucket ON %s(bucket,record_key)", table, table, table);
|
||||
"CREATE INDEX IF NOT EXISTS idx_%s_bucket ON %s(bucket,record_key);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_%s_author ON %s(node_id,bucket)", table, table, table, table, table);
|
||||
if (sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "ensure table");
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "feedback: table ready ch=%s table=%s", channel, table); return 0;
|
||||
}
|
||||
@@ -133,6 +135,33 @@ static int fb_read_row(sqlite3_stmt* st, struct chat_feedback_record* r) {
|
||||
memcpy(r->signature, sqlite3_column_blob(st, 5), 64); return 0;
|
||||
}
|
||||
|
||||
/* Единственный отбор активного набора для хеша, страницы и восстановления индекса.
|
||||
* Неактивная история не интерпретируется; при возвращении автора проверяется заново. */
|
||||
static int fb_read_active(struct UTUN_INSTANCE* inst, const char* channel, uint64_t id,
|
||||
sqlite3_stmt* st, struct chat_feedback_record* r) {
|
||||
uint8_t key[32], body[FB_WIRE_MAX + 32];
|
||||
uint64_t author = (uint64_t)sqlite3_column_int64(st, 1);
|
||||
enum ms_author_status status = member_sync_author_key(inst, channel, author, key);
|
||||
if (status == MS_AUTHOR_ERROR) return -1;
|
||||
if (status != MS_AUTHOR_ACTIVE) return 0;
|
||||
if (fb_read_row(st, r) < 0) return -1;
|
||||
int bytes = fb_signed_bytes(id, r, body);
|
||||
if (bytes < 0) return -1;
|
||||
int signature = sc_ed25519_verify(key, body, bytes, r->signature);
|
||||
if (signature != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: stored signature failed ch=%s node=%016llx rc=%d",
|
||||
channel, (unsigned long long)author, signature); return -1;
|
||||
}
|
||||
merkle_key_t record_key;
|
||||
if (fb_key(id, r, &record_key) < 0) return -1;
|
||||
if (sqlite3_column_bytes(st, 6) != 32 || memcmp(record_key.bytes, sqlite3_column_blob(st, 6), 32)
|
||||
|| (uint64_t)sqlite3_column_int64(st, 7) != (fb_read64(record_key.bytes) >> 39)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: corrupt local index ch=%s node=%016llx",
|
||||
channel, (unsigned long long)author); return -1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int chat_feedback_get(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32],
|
||||
uint64_t node_id, uint8_t kind, struct chat_feedback_record* out) {
|
||||
char table[40], sql[256];
|
||||
@@ -142,12 +171,19 @@ int chat_feedback_get(struct UTUN_INSTANCE* inst, const char* channel, const uin
|
||||
snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature FROM %s"
|
||||
" WHERE message_id=? AND node_id=? AND kind=?", table);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "get");
|
||||
sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id);
|
||||
sqlite3_bind_int(st, 3, kind);
|
||||
int rc = sqlite3_step(st), result = rc == SQLITE_ROW ? (fb_read_row(st, out) < 0 ? -1 : 1) : 0;
|
||||
sqlite3_finalize(st);
|
||||
if (rc != SQLITE_ROW && rc != SQLITE_DONE) return fb_db_error(inst, channel, "get step");
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 3, kind);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
struct chat_feedback_record record;
|
||||
int result = rc == SQLITE_ROW ? (fb_read_row(st, &record) < 0 ? -1 : 1) : 0;
|
||||
int final = sqlite3_finalize(st);
|
||||
if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: lookup failed ch=%s node=%016llx kind=%u step=%d finalize=%d db=%s",
|
||||
channel, (unsigned long long)node_id, kind, rc, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1;
|
||||
}
|
||||
if (result > 0) *out = record;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -155,8 +191,14 @@ int chat_feedback_get(struct UTUN_INSTANCE* inst, const char* channel, const uin
|
||||
static int fb_apply_row(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_feedback_record* r) {
|
||||
char table[40], sql[512]; uint64_t id; uint8_t body[FB_WIRE_MAX + 32], key[32];
|
||||
if (fb_table(inst, channel, table, &id) < 0) return -1;
|
||||
enum ms_author_status status = member_sync_author_key(inst, channel, r->node_id, key);
|
||||
if (status == MS_AUTHOR_INACTIVE || status == MS_AUTHOR_PENDING) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "feedback: deferred/ignored ch=%s node=%016llx status=%d ver=%llu",
|
||||
channel, (unsigned long long)r->node_id, status, (unsigned long long)r->ver); return 0;
|
||||
}
|
||||
if (status != MS_AUTHOR_ACTIVE) return -1;
|
||||
int bytes = fb_signed_bytes(id, r, body);
|
||||
if (bytes < 0 || member_sync_verified_ed25519(inst, channel, r->node_id, key) < 0) return -1;
|
||||
if (bytes < 0) return -1;
|
||||
if (sc_ed25519_verify(key, body, bytes, r->signature) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid signature ch=%s node=%016llx kind=%u ver=%llu",
|
||||
channel, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver); return -1;
|
||||
@@ -182,14 +224,22 @@ static int fb_apply_row(struct UTUN_INSTANCE* inst, const char* channel, const s
|
||||
snprintf(sql, sizeof(sql), "INSERT INTO %s(record_key,bucket,message_id,node_id,kind,ver,data,signature)"
|
||||
" VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(record_key) DO UPDATE SET ver=excluded.ver,data=excluded.data,signature=excluded.signature", table);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "put");
|
||||
sqlite3_bind_blob(st, 1, record_key.bytes, 32, SQLITE_STATIC);
|
||||
sqlite3_bind_int64(st, 2, (sqlite3_int64)(fb_read64(record_key.bytes) >> 39));
|
||||
sqlite3_bind_blob(st, 3, r->message_id, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)r->node_id);
|
||||
sqlite3_bind_int(st, 5, r->kind); sqlite3_bind_int64(st, 6, (sqlite3_int64)r->ver);
|
||||
sqlite3_bind_blob(st, 7, r->data, r->data_len, SQLITE_STATIC); sqlite3_bind_blob(st, 8, r->signature, 64, SQLITE_STATIC);
|
||||
int rc = sqlite3_step(st); sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE) return fb_db_error(inst, channel, "put step");
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, record_key.bytes, 32, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)(fb_read64(record_key.bytes) >> 39));
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, r->message_id, 32, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 4, (sqlite3_int64)r->node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 5, r->kind);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 6, (sqlite3_int64)r->ver);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 7, r->data, r->data_len, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 8, r->signature, 64, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
int changed = rc == SQLITE_DONE ? sqlite3_changes(inst->topo_sqlite_db) : 0;
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || changed != 1 || final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: write failed ch=%s node=%016llx kind=%u step=%d changes=%d finalize=%d db=%s",
|
||||
channel, (unsigned long long)r->node_id, r->kind, rc, changed, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1;
|
||||
}
|
||||
if (merkle_sync_recompute_path(inst, MT_DATA_FEEDBACK, channel, fb_read64(record_key.bytes)) < 0) return -1;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "feedback: applied ch=%s message=%s node=%016llx kind=%u ver=%llu previous=%llu bytes=%u",
|
||||
channel, message_hex, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver,
|
||||
@@ -204,15 +254,23 @@ static void fb_committed(struct UTUN_INSTANCE* inst, const char* channel, const
|
||||
chat_event_post(inst, CHAT_EVT_FEEDBACK_CHANGED, event, (int)len + 34);
|
||||
}
|
||||
|
||||
/* Завершает savepoint, при ошибке не публикует частичные изменения. */
|
||||
/* Завершает только свою транзакцию; неудачный rollback никогда не переходит в commit. */
|
||||
static int fb_finish_transaction(struct UTUN_INSTANCE* inst, const char* channel, int result) {
|
||||
sqlite3* db = inst->topo_sqlite_db;
|
||||
if (result < 0 && sqlite3_exec(db, "ROLLBACK TO feedback_update", NULL, NULL, NULL) != SQLITE_OK)
|
||||
fb_db_error(inst, channel, "rollback");
|
||||
if (sqlite3_exec(db, "RELEASE feedback_update", NULL, NULL, NULL) == SQLITE_OK) return result;
|
||||
fb_db_error(inst, channel, "commit");
|
||||
if (sqlite3_exec(db, "ROLLBACK TO feedback_update; RELEASE feedback_update", NULL, NULL, NULL) != SQLITE_OK)
|
||||
fb_db_error(inst, channel, "commit cleanup");
|
||||
if (result >= 0) {
|
||||
int rc = sqlite3_exec(db, "COMMIT /* feedback update */", NULL, NULL, NULL);
|
||||
if (rc == SQLITE_OK) return result;
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: commit failed ch=%s rc=%d (%s) db=%s", channel, rc, sqlite3_errstr(rc), sqlite3_errmsg(db));
|
||||
}
|
||||
if (!sqlite3_get_autocommit(db)) {
|
||||
int rc = sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
|
||||
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: rollback failed ch=%s rc=%d (%s) db=%s",
|
||||
channel, rc, sqlite3_errstr(rc), sqlite3_errmsg(db));
|
||||
}
|
||||
if (!sqlite3_get_autocommit(db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: transaction remains open ch=%s; requesting core stop and owner DB close after callback", channel);
|
||||
utun_instance_stop(inst);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -224,7 +282,7 @@ int chat_feedback_apply(struct UTUN_INSTANCE* inst, const char* channel, const s
|
||||
if (!sqlite3_get_autocommit(inst->topo_sqlite_db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: public apply inside external transaction ch=%s", channel); return -1;
|
||||
}
|
||||
if (sqlite3_exec(inst->topo_sqlite_db, "SAVEPOINT feedback_update", NULL, NULL, NULL) != SQLITE_OK)
|
||||
if (sqlite3_exec(inst->topo_sqlite_db, "BEGIN IMMEDIATE /* feedback update */", NULL, NULL, NULL) != SQLITE_OK)
|
||||
return fb_db_error(inst, channel, "begin");
|
||||
int result = fb_finish_transaction(inst, channel, fb_apply_row(inst, channel, r));
|
||||
if (result > 0) { merkle_sync_changed(inst, MT_DATA_FEEDBACK, channel); fb_committed(inst, channel, r); }
|
||||
@@ -237,44 +295,69 @@ int chat_feedback_set(struct UTUN_INSTANCE* inst, const char* channel, const uin
|
||||
if (fb_table(inst, channel, table, &id) < 0 || !message_id || len > CHAT_FEEDBACK_DATA_MAX || (len && !data)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid local state"); return -1;
|
||||
}
|
||||
if (!sqlite3_get_autocommit(inst->topo_sqlite_db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local set inside external transaction ch=%s", channel); return -1;
|
||||
}
|
||||
if (sqlite3_exec(inst->topo_sqlite_db, "BEGIN IMMEDIATE /* feedback local set */", NULL, NULL, NULL) != SQLITE_OK)
|
||||
return fb_db_error(inst, channel, "local begin");
|
||||
int result = -1;
|
||||
struct chat_feedback_record r = { .node_id = inst->node_id, .ver = 1, .kind = kind, .data_len = len }, old;
|
||||
memcpy(r.message_id, message_id, 32); if (len) memcpy(r.data, data, len);
|
||||
uint8_t author_key[32];
|
||||
enum ms_author_status status = member_sync_author_key(inst, channel, inst->node_id, author_key);
|
||||
if (status != MS_AUTHOR_ACTIVE) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local action denied ch=%s node=%016llx status=%d",
|
||||
channel, (unsigned long long)inst->node_id, status); goto finish;
|
||||
}
|
||||
int found = chat_feedback_get(inst, channel, message_id, inst->node_id, kind, &old);
|
||||
if (found < 0) return -1;
|
||||
if (found && old.data_len == len && !memcmp(old.data, r.data, len)) { if (out_ver) *out_ver = old.ver; return 0; }
|
||||
if (found < 0) goto finish;
|
||||
if (found && old.data_len == len && !memcmp(old.data, r.data, len)) { r.ver = old.ver; result = 0; goto finish; }
|
||||
if (found && old.ver == INT64_MAX) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: version exhausted ch=%s", channel); return -1;
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: version exhausted ch=%s", channel); goto finish;
|
||||
}
|
||||
if (found) r.ver = old.ver + 1;
|
||||
uint8_t body[FB_WIRE_MAX + 32]; int bytes = fb_signed_bytes(id, &r, body);
|
||||
if (bytes < 0 || sc_ed25519_sign(inst->my_ed25519_privkey, body, bytes, r.signature) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local signing failed ch=%s", channel); return -1;
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local signing failed ch=%s", channel); goto finish;
|
||||
}
|
||||
int result = chat_feedback_apply(inst, channel, &r);
|
||||
result = fb_apply_row(inst, channel, &r);
|
||||
finish:
|
||||
result = fb_finish_transaction(inst, channel, result);
|
||||
if (result >= 0 && out_ver) *out_ver = r.ver;
|
||||
if (result > 0) DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local state saved ch=%s kind=%u ver=%llu bytes=%zu",
|
||||
channel, kind, (unsigned long long)r.ver, len);
|
||||
if (result > 0) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local state saved ch=%s kind=%u ver=%llu bytes=%zu",
|
||||
channel, kind, (unsigned long long)r.ver, len);
|
||||
merkle_sync_changed(inst, MT_DATA_FEEDBACK, channel); fb_committed(inst, channel, &r);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Читает лист в полном лексикографическом порядке record_key. */
|
||||
static int fb_page(void* ctx, const char* channel, uint64_t prefix, int av, const merkle_key_t* after,
|
||||
uint8_t* data, size_t* len, merkle_key_t* next, int* more) {
|
||||
struct UTUN_INSTANCE* inst = ctx; char table[40], sql[384];
|
||||
if (fb_table(inst, channel, table, NULL) < 0 || *len < 2) {
|
||||
struct UTUN_INSTANCE* inst = ctx; char table[40], sql[384]; uint64_t id;
|
||||
if (fb_table(inst, channel, table, &id) < 0 || !len || *len < 2 || !after || !data || !next || !more) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid page capacity"); return -1;
|
||||
}
|
||||
merkle_key_t cursor = *after; memset(next, 0, sizeof(*next)); *more = 0;
|
||||
snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature,record_key FROM %s"
|
||||
snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature,record_key,bucket FROM %s"
|
||||
" WHERE bucket=? AND (?=0 OR record_key>?) ORDER BY record_key", table);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "page");
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)(prefix >> 39)); sqlite3_bind_int(st, 2, av);
|
||||
sqlite3_bind_blob(st, 3, cursor.bytes, 32, SQLITE_STATIC);
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)(prefix >> 39));
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 2, av);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, cursor.bytes, 32, SQLITE_STATIC);
|
||||
if (rc != SQLITE_OK) {
|
||||
int final = sqlite3_finalize(st);
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: page query failed ch=%s rc=%d finalize=%d db=%s",
|
||||
channel, rc, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1;
|
||||
}
|
||||
size_t off = 2; unsigned count = 0; int step, result = 0;
|
||||
while ((step = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
struct chat_feedback_record r; uint8_t wire[FB_WIRE_MAX];
|
||||
if (fb_read_row(st, &r) < 0 || sqlite3_column_bytes(st, 6) != 32) { result = -1; break; }
|
||||
int active = fb_read_active(inst, channel, id, st, &r);
|
||||
if (active < 0) { result = -1; break; }
|
||||
if (!active) continue;
|
||||
int bytes = fb_encode(&r, wire); if (bytes < 0) { result = -1; break; }
|
||||
if (off + bytes > *len) {
|
||||
if (!count) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: record exceeds page capacity ch=%s", channel); result = -1; }
|
||||
@@ -285,30 +368,45 @@ static int fb_page(void* ctx, const char* channel, uint64_t prefix, int av, cons
|
||||
memcpy(next->bytes, sqlite3_column_blob(st, 6), 32);
|
||||
}
|
||||
if (result == 0 && !*more && step != SQLITE_DONE) result = fb_db_error(inst, channel, "page step");
|
||||
sqlite3_finalize(st); data[0] = count >> 8; data[1] = count & 255; *len = off; return result;
|
||||
int final = sqlite3_finalize(st);
|
||||
if (final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: page finalize failed ch=%s rc=%d", channel, final); result = -1;
|
||||
}
|
||||
data[0] = count >> 8; data[1] = count & 255; *len = off; return result;
|
||||
}
|
||||
|
||||
/* Хеширует подписанные тела: разные валидные подписи одного тела не меняют корень. */
|
||||
static int fb_hash(void* ctx, const char* channel, uint8_t level, uint64_t prefix, EVP_MD_CTX* hash) {
|
||||
struct UTUN_INSTANCE* inst = ctx; char table[40], sql[256];
|
||||
if (fb_table(inst, channel, table, NULL) < 0 || level != MT_MAX_LEVEL) {
|
||||
struct UTUN_INSTANCE* inst = ctx; char table[40], sql[256]; uint64_t id;
|
||||
if (fb_table(inst, channel, table, &id) < 0 || level != MT_MAX_LEVEL) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid hash level=%u", level); return -1;
|
||||
}
|
||||
snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature FROM %s WHERE bucket=? ORDER BY record_key", table);
|
||||
snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature,record_key,bucket"
|
||||
" FROM %s WHERE bucket=? ORDER BY record_key", table);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "hash");
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)(prefix >> 39));
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)(prefix >> 39));
|
||||
if (rc != SQLITE_OK) {
|
||||
int final = sqlite3_finalize(st);
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: hash query failed ch=%s rc=%d finalize=%d db=%s",
|
||||
channel, rc, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1;
|
||||
}
|
||||
int step, count = 0;
|
||||
while ((step = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
struct chat_feedback_record r; uint8_t wire[FB_WIRE_MAX];
|
||||
if (fb_read_row(st, &r) < 0) { count = -1; break; }
|
||||
int active = fb_read_active(inst, channel, id, st, &r);
|
||||
if (active < 0) { count = -1; break; }
|
||||
if (!active) continue;
|
||||
int bytes = fb_encode(&r, wire);
|
||||
if (bytes < 0 || EVP_DigestUpdate(hash, wire, bytes - 64) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: leaf digest failed ch=%s", channel); count = -1; break;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
int final = sqlite3_finalize(st);
|
||||
if (final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: hash finalize failed ch=%s rc=%d", channel, final); return -1;
|
||||
}
|
||||
if (count >= 0 && step != SQLITE_DONE) return fb_db_error(inst, channel, "hash step");
|
||||
return count;
|
||||
}
|
||||
@@ -316,7 +414,10 @@ static int fb_hash(void* ctx, const char* channel, uint8_t level, uint64_t prefi
|
||||
/* Вся страница применяется атомарно; события отправляются после её commit. */
|
||||
static int fb_apply_page(void* ctx, const char* channel, uint64_t peer, const uint8_t* data, size_t len) {
|
||||
struct UTUN_INSTANCE* inst = ctx; (void)peer;
|
||||
if (len < 2) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: short page ch=%s", channel); return -1; }
|
||||
if (!inst || !inst->topo_sqlite_db || !sqlite3_get_autocommit(inst->topo_sqlite_db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: page requires own transaction ch=%s", channel); return -1;
|
||||
}
|
||||
if (!data || len < 2) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: short/missing page ch=%s", channel); return -1; }
|
||||
unsigned count = ((unsigned)data[0] << 8) | data[1]; size_t off = 2;
|
||||
if (count > (len - 2) / FB_FIXED_SIZE) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid page count=%u bytes=%zu", count, len); return -1;
|
||||
@@ -330,7 +431,7 @@ static int fb_apply_page(void* ctx, const char* channel, uint64_t peer, const ui
|
||||
if (!count) return 0;
|
||||
uint8_t* changed = u_calloc(count, 1);
|
||||
if (!changed) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: page allocation failed"); return -1; }
|
||||
if (sqlite3_exec(inst->topo_sqlite_db, "SAVEPOINT feedback_update", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
if (sqlite3_exec(inst->topo_sqlite_db, "BEGIN IMMEDIATE /* feedback page */", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
u_free(changed); return fb_db_error(inst, channel, "page begin");
|
||||
}
|
||||
off = 2; int result = 0;
|
||||
@@ -361,45 +462,127 @@ static const struct merkle_sync_data_ops fb_ops = {
|
||||
.update_bucket_hash = fb_hash, .get_page = fb_page, .apply_items = fb_apply_page, .validate_peer = fb_peer
|
||||
};
|
||||
|
||||
/* Перестраивает индекс после регистрации модели; подписи проверяются повторно. */
|
||||
int chat_feedback_init(struct UTUN_INSTANCE* inst) {
|
||||
if (merkle_sync_register(inst, MT_DATA_FEEDBACK, &fb_ops, inst) < 0) return -1;
|
||||
sqlite3_stmt* channels = NULL;
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT channel_id FROM channels", -1, &channels, NULL) != SQLITE_OK)
|
||||
return fb_db_error(inst, "-", "init channels");
|
||||
int step, result = 0, records = 0;
|
||||
while ((step = sqlite3_step(channels)) == SQLITE_ROW) {
|
||||
char channel[21]; snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(channels, 0));
|
||||
char table[40], sql[256];
|
||||
if (chat_feedback_ensure_channel(inst, channel) < 0 || fb_table(inst, channel, table, NULL) < 0) { result = -1; break; }
|
||||
snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature,record_key,bucket FROM %s", table);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) {
|
||||
result = fb_db_error(inst, channel, "init records"); break;
|
||||
}
|
||||
int rc;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
struct chat_feedback_record r; uint64_t id; uint8_t body[FB_WIRE_MAX + 32], key[32]; merkle_key_t record_key;
|
||||
if (fb_read_row(st, &r) < 0 || fb_table(inst, channel, table, &id) < 0
|
||||
|| member_sync_verified_ed25519(inst, channel, r.node_id, key) < 0) { result = -1; break; }
|
||||
int bytes = fb_signed_bytes(id, &r, body);
|
||||
if (bytes < 0 || sc_ed25519_verify(key, body, bytes, r.signature) != SC_OK || fb_key(id, &r, &record_key) < 0
|
||||
|| merkle_sync_recompute_path(inst, MT_DATA_FEEDBACK, channel, fb_read64(record_key.bytes)) < 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: rebuild failed ch=%s node=%016llx",
|
||||
channel, (unsigned long long)r.node_id); result = -1; break;
|
||||
}
|
||||
if (sqlite3_column_bytes(st, 6) != 32 || memcmp(record_key.bytes, sqlite3_column_blob(st, 6), 32)
|
||||
|| (uint64_t)sqlite3_column_int64(st, 7) != (fb_read64(record_key.bytes) >> 39)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: corrupt local index ch=%s node=%016llx",
|
||||
channel, (unsigned long long)r.node_id); result = -1; break;
|
||||
}
|
||||
records++;
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
if (result < 0) break;
|
||||
if (rc != SQLITE_DONE) { result = fb_db_error(inst, channel, "init records step"); break; }
|
||||
/* Один канал за раз: callbacks после commit вправе менять каналы и участников. */
|
||||
static int fb_next_channel(struct UTUN_INSTANCE* inst, const char* after, char channel[21], int existing) {
|
||||
sqlite3_stmt* st = NULL;
|
||||
const char* sql = "SELECT channel_id FROM channels WHERE channel_id>? AND (?=0 OR EXISTS"
|
||||
" (SELECT 1 FROM sqlite_master WHERE type='table' AND name='feedback_'||channel_id)) ORDER BY channel_id LIMIT 1";
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, after, -1, SQLITE_STATIC);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 2, existing);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
if (rc == SQLITE_ROW) {
|
||||
const char* text = (const char*)sqlite3_column_text(st, 0); int bytes = sqlite3_column_bytes(st, 0);
|
||||
if (!text || bytes <= 0 || bytes >= 21) rc = SQLITE_CORRUPT;
|
||||
else { memcpy(channel, text, bytes); channel[bytes] = 0; }
|
||||
}
|
||||
sqlite3_finalize(channels);
|
||||
if (result == 0 && step != SQLITE_DONE) return fb_db_error(inst, "-", "init channels step");
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "feedback: initialization result=%d records=%d", result, records); return result;
|
||||
int final = sqlite3_finalize(st);
|
||||
if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) return fb_db_error(inst, "-", "channel scan");
|
||||
return rc == SQLITE_ROW;
|
||||
}
|
||||
|
||||
/* Пересчитывает только листья, зависящие от изменённого автора/ключа пригласившего. */
|
||||
int chat_feedback_member_update(struct UTUN_INSTANCE* inst, uint64_t node_id) {
|
||||
if (!merkle_sync_registered(inst, MT_DATA_FEEDBACK)) return 0;
|
||||
if (sqlite3_get_autocommit(inst->topo_sqlite_db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: member update requires transaction node=%016llx",
|
||||
(unsigned long long)node_id); return -1;
|
||||
}
|
||||
char channel[21] = "", next[21]; int scan;
|
||||
while ((scan = fb_next_channel(inst, channel, next, 1)) > 0) {
|
||||
strcpy(channel, next);
|
||||
char table[40], sql[384]; if (fb_table(inst, channel, table, NULL) < 0) return -1;
|
||||
snprintf(sql, sizeof(sql), "SELECT DISTINCT f.bucket FROM %s f LEFT JOIN peers_%s p ON p.node_id=f.node_id"
|
||||
" WHERE f.node_id=?1 OR p.signed_by=?1 ORDER BY f.bucket", table, channel);
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
|
||||
unsigned buckets = 0;
|
||||
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
sqlite3_int64 bucket = sqlite3_column_int64(st, 0);
|
||||
if (bucket < 0 || bucket >= (INT64_C(1) << 25)) { rc = SQLITE_CORRUPT; break; }
|
||||
if (merkle_sync_recompute_path(inst, MT_DATA_FEEDBACK, channel, (uint64_t)bucket << 39) < 0) {
|
||||
rc = SQLITE_ERROR; break;
|
||||
}
|
||||
buckets++;
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: member index failed ch=%s dependency=%016llx step=%d finalize=%d",
|
||||
channel, (unsigned long long)node_id, rc, final); return -1;
|
||||
}
|
||||
if (buckets) DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "feedback: member index updated ch=%s dependency=%016llx buckets=%u",
|
||||
channel, (unsigned long long)node_id, buckets);
|
||||
}
|
||||
return scan < 0 ? -1 : 0;
|
||||
}
|
||||
|
||||
/* Адресные события читаются по одному без cursor через пользовательский callback. */
|
||||
void chat_feedback_member_committed(struct UTUN_INSTANCE* inst, uint64_t node_id) {
|
||||
if (!merkle_sync_registered(inst, MT_DATA_FEEDBACK)) return;
|
||||
if (!sqlite3_get_autocommit(inst->topo_sqlite_db)) { fb_db_error(inst, "-", "member notify before commit"); return; }
|
||||
char channel[21] = "", next[21];
|
||||
while (fb_next_channel(inst, channel, next, 1) > 0) {
|
||||
strcpy(channel, next);
|
||||
/* Даже без локального feedback ключ может разрешить приём ранее отложенной страницы. */
|
||||
merkle_sync_changed(inst, MT_DATA_FEEDBACK, channel);
|
||||
merkle_sync_revalidate_peers(inst, MT_DATA_FEEDBACK, channel);
|
||||
if (!merkle_sync_registered(inst, MT_DATA_FEEDBACK)) return;
|
||||
char table[40], sql[512]; if (fb_table(inst, channel, table, NULL) < 0) return;
|
||||
snprintf(sql, sizeof(sql), "SELECT DISTINCT f.message_id,f.kind FROM %s f LEFT JOIN peers_%s p ON p.node_id=f.node_id"
|
||||
" WHERE (f.node_id=?1 OR p.signed_by=?1) AND (?2=0 OR f.message_id>?3 OR (f.message_id=?3 AND f.kind>?4))"
|
||||
" ORDER BY f.message_id,f.kind LIMIT 1", table, channel);
|
||||
struct chat_feedback_record r = {0}; int after = 0;
|
||||
for (;;) {
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 2, after);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, r.message_id, 32, SQLITE_TRANSIENT);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 4, r.kind);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
||||
if (rc == SQLITE_ROW) {
|
||||
int kind = sqlite3_column_int(st, 1);
|
||||
if (sqlite3_column_bytes(st, 0) != 32 || (kind != CHAT_FEEDBACK_REACTION && kind != CHAT_FEEDBACK_ANSWER))
|
||||
rc = SQLITE_CORRUPT;
|
||||
else { memcpy(r.message_id, sqlite3_column_blob(st, 0), 32); r.kind = kind; }
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) { fb_db_error(inst, channel, "member notify"); return; }
|
||||
if (rc == SQLITE_DONE) break;
|
||||
after = 1; fb_committed(inst, channel, &r);
|
||||
if (!merkle_sync_registered(inst, MT_DATA_FEEDBACK)) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Перестраивает активный индекс после purge MEMBERS, атомарно для всех каналов. */
|
||||
int chat_feedback_init(struct UTUN_INSTANCE* inst) {
|
||||
if (!inst || !inst->topo_sqlite_db || !sqlite3_get_autocommit(inst->topo_sqlite_db)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: init requires database outside transaction"); return -1;
|
||||
}
|
||||
if (merkle_sync_register(inst, MT_DATA_FEEDBACK, &fb_ops, inst) < 0) return -1;
|
||||
if (sqlite3_exec(inst->topo_sqlite_db, "BEGIN IMMEDIATE /* feedback init */", NULL, NULL, NULL) != SQLITE_OK)
|
||||
return fb_db_error(inst, "-", "init begin");
|
||||
char channel[21] = "", next[21]; int scan, result = 0; unsigned buckets = 0;
|
||||
while ((scan = fb_next_channel(inst, channel, next, 0)) > 0) {
|
||||
strcpy(channel, next);
|
||||
char table[40], sql[128];
|
||||
if (chat_feedback_ensure_channel(inst, channel) < 0 || fb_table(inst, channel, table, NULL) < 0) { result = -1; break; }
|
||||
snprintf(sql, sizeof(sql), "SELECT DISTINCT bucket FROM %s ORDER BY bucket", table);
|
||||
sqlite3_stmt* st = NULL; int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
|
||||
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
|
||||
sqlite3_int64 bucket = sqlite3_column_int64(st, 0);
|
||||
if (bucket < 0 || bucket >= (INT64_C(1) << 25)) { rc = SQLITE_CORRUPT; break; }
|
||||
if (merkle_sync_recompute_path(inst, MT_DATA_FEEDBACK, channel, (uint64_t)bucket << 39) < 0) {
|
||||
rc = SQLITE_ERROR; break;
|
||||
}
|
||||
buckets++;
|
||||
}
|
||||
int final = sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE || final != SQLITE_OK) { result = fb_db_error(inst, channel, "init buckets"); break; }
|
||||
}
|
||||
if (scan < 0) result = -1;
|
||||
result = fb_finish_transaction(inst, "-", result);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "feedback: initialization result=%d buckets=%u", result, buckets); return result;
|
||||
}
|
||||
+37
-15
@@ -1,9 +1,13 @@
|
||||
/* Подписанные состояния реакций и ответов, отдельная таблица feedback_<channel>.
|
||||
* Каждый (message_id, node_id, kind) хранит последнее полное состояние с ver.
|
||||
* Пустое data — сохраняемая отмена. Слой хранит и реплицирует подписанные байты;
|
||||
* Пустое data — сохраняемая отмена. Неактивные/ожидающие авторы сохраняются в БД,
|
||||
* но не входят в Merkle, выдачу и результаты. Допуск проверяет member_sync;
|
||||
* интерпретацию ответа относительно опроса выполняет вызывающий модуль.
|
||||
* Все операции — в uasync-потоке. init вызывается после member_sync_init;
|
||||
* общий Merkle-движок освобождается member_sync_destroy. */
|
||||
* общий Merkle-движок освобождается member_sync_destroy.
|
||||
* apply/set/приём страницы/init владеют своей BEGIN IMMEDIATE/COMMIT; внешняя транзакция запрещена.
|
||||
* Запись и Merkle-индекс фиксируются вместе, уведомление — после commit. Неудачный rollback
|
||||
* с открытой транзакцией запрашивает stop ядра; владелец закрывает БД вне callback. */
|
||||
#ifndef CHAT_FEEDBACK_H
|
||||
#define CHAT_FEEDBACK_H
|
||||
|
||||
@@ -12,36 +16,54 @@
|
||||
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define CHAT_FEEDBACK_REACTION 1
|
||||
#define CHAT_FEEDBACK_ANSWER 2
|
||||
#define CHAT_FEEDBACK_DATA_MAX 2048
|
||||
#define CHAT_MESSAGE_ID_SIZE 32
|
||||
|
||||
struct chat_feedback_record {
|
||||
uint8_t message_id[CHAT_MESSAGE_ID_SIZE];
|
||||
uint64_t node_id;
|
||||
uint8_t message_id[CHAT_MESSAGE_ID_SIZE]; /* Неизменяемый ID исходного сообщения. */
|
||||
uint64_t node_id; /* Автор этого состояния,1..INT64_MAX. */
|
||||
uint64_t ver; /* 1..INT64_MAX; отдельная версия для каждого kind */
|
||||
uint8_t kind;
|
||||
uint16_t data_len;
|
||||
uint8_t data[CHAT_FEEDBACK_DATA_MAX];
|
||||
uint8_t signature[64];
|
||||
uint8_t kind; /* REACTION либо ANSWER. */
|
||||
uint16_t data_len; /* Длина полного состояния,0 отменяет. */
|
||||
uint8_t data[CHAT_FEEDBACK_DATA_MAX]; /* Канонические байты codec из chat_poll.h. */
|
||||
uint8_t signature[64]; /* Ed25519 автора над domain/каналом/полями/data. */
|
||||
};
|
||||
|
||||
/* Производный ID неизменяемого сообщения; 0/-1. Числа хешируются как BE64. */
|
||||
int chat_message_id(uint64_t channel_id, uint64_t timestamp, const uint8_t signature[64], uint8_t id[32]);
|
||||
/* Регистрирует FEEDBACK и восстанавливает производный индекс из подписанных записей; 0/-1. */
|
||||
/* Постоянный ID: канал, время создания, автор и случайный nonce[16]; 0/-1. Числа — BE64. */
|
||||
int chat_message_id(uint64_t channel_id, uint64_t timestamp, uint64_t author, const uint8_t nonce[16], uint8_t id[32]);
|
||||
/* Регистрирует FEEDBACK и восстанавливает индекс допущенных записей; 0/-1.
|
||||
* Исключение или отсутствие зависимости автора не блокирует запуск и не удаляет историю. */
|
||||
int chat_feedback_init(struct UTUN_INSTANCE* inst);
|
||||
/* После изменения участника/его доверенного ключа пересчитать feedback автора и его
|
||||
* зависимых приглашённых во всех каналах. Вызывается внутри транзакции MEMBERS;
|
||||
* до регистрации FEEDBACK — no-op. 0/-1, ошибка требует rollback внешней транзакции. */
|
||||
int chat_feedback_member_update(struct UTUN_INSTANCE* inst, uint64_t node_id);
|
||||
/* После внешнего commit разбудить сессии и обновить затронутые результаты.
|
||||
* Обработчики событий вызываются без живого SQLite cursor; до регистрации — no-op. */
|
||||
void chat_feedback_member_committed(struct UTUN_INSTANCE* inst, uint64_t node_id);
|
||||
/* Подготовить таблицу канала; 0/-1. Не создаёт членство или сетевую группу. */
|
||||
int chat_feedback_ensure_channel(struct UTUN_INSTANCE* inst, const char* channel);
|
||||
/* Прочитать состояние: 1 найдено, 0 отсутствует, -1 ошибка. */
|
||||
/* Прочитать сохранённое состояние, включая неактивное:1 найдено,0 отсутствует,-1 ошибка.
|
||||
* out меняется только при успехе1;ошибка bind/step/finalize не считается отсутствием. */
|
||||
int chat_feedback_get(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32],
|
||||
uint64_t node_id, uint8_t kind, struct chat_feedback_record* out);
|
||||
/* Проверить и применить подписанную запись: 1 изменилось, 0 повтор/старая, -1 ошибка.
|
||||
/* Проверить и применить подписанную запись: 1 изменилось, 0 повтор/старая/автор недоступен, -1 ошибка.
|
||||
* Одинаковый ver разрешается сравнением data; подпись проверяется до сравнения. */
|
||||
int chat_feedback_apply(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_feedback_record* record);
|
||||
/* Подписать локальное полное состояние с ver+1; NULL/0 отменяет. 1/0/-1 как apply.
|
||||
* out_ver необязателен. Семантику data проверяет вызывающий модуль до этого вызова. */
|
||||
/* Подписать локальное полное состояние с ver+1; NULL/0 отменяет.1/0/-1 как apply.
|
||||
* Допуск/прежняя версия/подпись/запись выполняются под собственной блокировкой,включая no-op.
|
||||
* out_ver необязателен,меняется только после commit. Канонический формат проверяет codec,
|
||||
* семантику ответа относительно опроса проверяет вызывающий до этого вызова. */
|
||||
int chat_feedback_set(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32],
|
||||
uint8_t kind, const uint8_t* data, size_t len, uint64_t* out_ver);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
+410
-240
File diff suppressed because it is too large.
Load diff
@@ -3,13 +3,21 @@
|
||||
*
|
||||
* JSON line protocol:
|
||||
* Request: {"id":N,"cmd":"<command>",...params}
|
||||
* Response: {"id":N,"ok":true,"data":{...}} | {"id":N,"ok":false,"error":"..."}
|
||||
* Response: {"id":N,"ok":true,"data":<JSON>} | {"id":N,"ok":false,"error":"..."}
|
||||
* Event: {"event":"<type>",...}
|
||||
*
|
||||
* Команды каналов, DM, звонков/рации и настроек — в tools/chatcli_commands.txt;
|
||||
* Команды каналов, DM и взаимодействий — в tools/chatcli_commands.txt;
|
||||
* диспетчер реализации — chat_headless_control.c. Аудио PCM использует отдельные call/radio_headless сокеты.
|
||||
* init регистрирует listener и события chat_event, destroy закрывает клиентов и снимает подписку.
|
||||
* Все вызовы и команды исполняются в uasync-потоке экземпляра.
|
||||
* init требует готовый chat_core и ua==inst->ua. Listener IPv4,до64 клиентов,строка до16383байт без LF.
|
||||
* До команды проверяется весь строгий JSON-object:до32 уникальных ключей длиной<=31байта,
|
||||
* id — int32,строковые параметры не обрезаются,Unicode escapes декодируются,скрытый NUL запрещён.
|
||||
* Массивы опросов передаются общему feedback API;ошибка разбора/типов получает id=0,ошибка команды сохраняет id.
|
||||
* Исходящая очередь до32768байт:короткие записи/EAGAIN сохраняют порядок,переполнение закрывает клиента с WARN.
|
||||
* quit дописывает bye перед закрытием. attach отвечает после сохранения/отказа публикации,не после доставки пирам;
|
||||
* его completion освобождается также после disconnect. connect/call подтверждают передачу запроса ядру;
|
||||
* подписчикам приходят connect_result/call_error и состояния звонка,а не гарантия готовности связи из ответа команды.
|
||||
* Прежний chat_event handler вызывается после headless;destroy восстанавливает его,если handler всё ещё принадлежит headless.
|
||||
* Все вызовы/команды/completion — в uasync;stop/destroy вне callbacks этой службы. Диагностика — категория control.
|
||||
*/
|
||||
#ifndef CHAT_HEADLESS_CONTROL_H
|
||||
#define CHAT_HEADLESS_CONTROL_H
|
||||
@@ -20,10 +28,19 @@
|
||||
struct UASYNC;
|
||||
struct UTUN_INSTANCE;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* 0 — запущен/повтор с тем же bind,-1 — отказ с полной очисткой. port=0 выбирает ОС. */
|
||||
int chat_headless_control_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst,
|
||||
const char* bind_ip, int port);
|
||||
void chat_headless_control_destroy(struct UTUN_INSTANCE* inst);
|
||||
// port=0 при init выбирает ОС; getter возвращает 0 для остановленной службы или при ошибке.
|
||||
uint16_t chat_headless_control_get_port(struct UTUN_INSTANCE* inst);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CHAT_HEADLESS_CONTROL_H */
|
||||
+40
-25
@@ -8,6 +8,7 @@
|
||||
#include "chat_join.h"
|
||||
#include "member_sync.h"
|
||||
#include "chat_event.h"
|
||||
#include "chat_sync.h"
|
||||
|
||||
#include "../utun_instance.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
@@ -59,11 +60,11 @@ static void _keys_purge_expired(struct UTUN_INSTANCE* inst) {
|
||||
}
|
||||
|
||||
static int _keys_put(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t join_key, uint64_t inviter_id) {
|
||||
_keys_purge_expired(inst);
|
||||
if (!channel_id || !join_key || !inviter_id) {
|
||||
if (!inst || inst->join_stopping || !channel_id || channel_id > INT64_MAX || !join_key || !inviter_id) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid key registration", CJ_ID);
|
||||
return -1;
|
||||
}
|
||||
_keys_purge_expired(inst);
|
||||
for (struct join_key_entry* e = inst->join_keys; e; e = e->next) {
|
||||
if (e->channel_id != channel_id || e->join_key != join_key) continue;
|
||||
if (e->inviter_id != inviter_id) {
|
||||
@@ -103,13 +104,17 @@ static void _keys_free_all(struct UTUN_INSTANCE* inst) {
|
||||
|
||||
int chat_join_serialize_member(const struct join_member_data* m,
|
||||
uint8_t* out, size_t out_sz, size_t* out_len) {
|
||||
if (!m || !out || !out_len || !m->x25519 || !m->ed25519) return -1;
|
||||
if (!m || !out || !out_len || !m->x25519 || !m->ed25519) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "chat_join: invalid member serialization arguments"); return -1;
|
||||
}
|
||||
size_t ul = strnlen(m->userinfo, sizeof(m->userinfo));
|
||||
if (ul > MS_USERINFO_MAX) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "chat_join: profile exceeds limit bytes=%zu", ul); return -1;
|
||||
}
|
||||
size_t need = 8 + 32 + 32 + 64 + 8 + 64 + 8 + 2 + ul;
|
||||
if (need > out_sz) return -1;
|
||||
if (need > out_sz) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "chat_join: member buffer too small need=%zu capacity=%zu", need, out_sz); return -1;
|
||||
}
|
||||
size_t off = 0;
|
||||
memcpy(out + off, &m->node_id, 8); off += 8;
|
||||
memcpy(out + off, m->x25519, 32); off += 32;
|
||||
@@ -125,8 +130,11 @@ int chat_join_serialize_member(const struct join_member_data* m,
|
||||
return 0;
|
||||
}
|
||||
|
||||
int chat_join_parse_member(const uint8_t* data, size_t len, struct join_member_data* m) {
|
||||
if (!data || !m || len < 8 + 32 + 32 + 64 + 8 + 64 + 8 + 2) return -1;
|
||||
int chat_join_parse_member(const uint8_t* data, size_t len, struct join_member_data* output) {
|
||||
if (!data || !output || len < 8 + 32 + 32 + 64 + 8 + 64 + 8 + 2) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "chat_join: invalid/short member block bytes=%zu", len); return -1;
|
||||
}
|
||||
struct join_member_data parsed, *m = &parsed;
|
||||
memset(m, 0, sizeof(*m));
|
||||
size_t off = 0;
|
||||
memcpy(&m->node_id, data + off, 8); off += 8;
|
||||
@@ -142,6 +150,7 @@ int chat_join_parse_member(const uint8_t* data, size_t len, struct join_member_d
|
||||
}
|
||||
memcpy(m->userinfo, data + off, ul);
|
||||
m->userinfo[ul] = '\0';
|
||||
*output = parsed;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -150,6 +159,9 @@ int chat_join_parse_member(const uint8_t* data, size_t len, struct join_member_d
|
||||
void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
uint64_t from_node, uint64_t join_key,
|
||||
const uint8_t* member_data, size_t member_len) {
|
||||
if (!inst || inst->join_stopping || !group_id || group_id > INT64_MAX || !from_node) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid/stopped process request", CJ_ID); return;
|
||||
}
|
||||
struct join_member_data m;
|
||||
if (chat_join_parse_member(member_data, member_len, &m) != 0) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request parse failed ch=%llu from=%016llx", CJ_ID, (unsigned long long)group_id, (unsigned long long)from_node);
|
||||
@@ -188,6 +200,18 @@ void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
return;
|
||||
}
|
||||
|
||||
/* Локальная запись доверенная: до неё проверяем также профиль и уже известный ключ идентичности. */
|
||||
struct ms_member_rec record = {.node_id = m.node_id, .x25519 = m.x25519, .ed25519 = m.ed25519,
|
||||
.join_sig = m.join_sig, .join_ts = m.join_ts, .update_sig = m.update_sig, .update_ts = m.update_ts,
|
||||
.userinfo = m.userinfo};
|
||||
uint8_t trusted_ed[32];
|
||||
int lookup = topo_node_sqlite_get_ed25519_pubkey(db, m.node_id, trusted_ed);
|
||||
if (lookup == TOPO_SQLITE_LOOKUP_ERROR || (lookup == 0 && memcmp(trusted_ed, m.ed25519, sizeof(trusted_ed))) ||
|
||||
member_sync_verify_local_record(db, ch_id, &record) != 1) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: member validation failed ch=%s node=%016llx lookup=%d", CJ_ID,
|
||||
ch_id, (unsigned long long)m.node_id, lookup); return;
|
||||
}
|
||||
|
||||
/* 3. добавить мембера + подписать его pubkey (дерево приглашений) */
|
||||
uint8_t tree_sig[64];
|
||||
if (member_sync_sign_pubkey(inst->my_ed25519_privkey, m.x25519, tree_sig) != 0) {
|
||||
@@ -204,22 +228,8 @@ void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
return;
|
||||
}
|
||||
if (rc & MS_APPLY_CHANGED) {
|
||||
/* джойнер авторизован — добавляем в CHAT-группу сразу, чтобы BGP подключился */
|
||||
uint64_t gid = strtoull(ch_id, NULL, 10);
|
||||
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid);
|
||||
if (g && g->group_type == TOPO_GROUP_TYPE_CHAT) {
|
||||
struct ETCP_CONN* jc = instance_find_conn(inst, m.node_id);
|
||||
if (jc) {
|
||||
topo_group_new_conn(g, jc);
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: joiner 0x%016llx added to group ch=%s for BGP",
|
||||
CJ_ID, (unsigned long long)m.node_id, ch_id);
|
||||
}
|
||||
}
|
||||
/* уведомить GUI инвайтера об успешном присоединении джойнера */
|
||||
uint8_t cevt[20]; memcpy(cevt, &m.node_id, 8);
|
||||
int r = 0; memcpy(cevt + 8, &r, 4);
|
||||
memcpy(cevt + 12, &group_id, 8);
|
||||
chat_event_post(inst, CHAT_EVT_CONNECT_RESULT, cevt, 20);
|
||||
/* member_sync уже запускает сессию после commit; GUI получает общий формат результата. */
|
||||
chat_sync_join_result(inst, 0, group_id, m.node_id, CHAT_JOIN_OK);
|
||||
}
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: process_request accepted joiner=0x%016llx ch=%s via=0x%016llx key=%016llx — signed by me",
|
||||
@@ -354,7 +364,7 @@ static void join_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
||||
/* ── Жизненный цикл ── */
|
||||
|
||||
int chat_join_init(struct UTUN_INSTANCE* inst) {
|
||||
if (!inst) return -1;
|
||||
if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: init without instance", CJ_ID); return -1; }
|
||||
inst->join_stopping = 0;
|
||||
if (etcp_router_bind(inst, ETCP_RT_ID_JOIN, join_recv_cb) != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: etcp_router_bind failed", CJ_ID);
|
||||
@@ -382,7 +392,7 @@ int chat_join_store_local_key(struct UTUN_INSTANCE* inst, uint64_t channel_id, u
|
||||
|
||||
struct chat_join_registration* chat_join_register_key(struct UTUN_INSTANCE* inst, uint64_t channel_id,
|
||||
uint64_t target_node_id, uint64_t join_key, chat_join_registered_fn callback, void* arg) {
|
||||
if (!inst || !inst->ua || inst->join_stopping || !channel_id || !join_key || !callback) {
|
||||
if (!inst || !inst->ua || inst->join_stopping || !channel_id || channel_id > INT64_MAX || !join_key || !callback) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid registration arguments", CJ_ID);
|
||||
return NULL;
|
||||
}
|
||||
@@ -415,6 +425,9 @@ struct chat_join_registration* chat_join_register_key(struct UTUN_INSTANCE* inst
|
||||
}
|
||||
|
||||
uint64_t chat_join_lookup_inviter(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t join_key) {
|
||||
if (!inst || !channel_id || channel_id > INT64_MAX || !join_key) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid key lookup", CJ_ID); return 0;
|
||||
}
|
||||
struct join_key_entry* e = _keys_find(inst, channel_id, join_key);
|
||||
return e ? e->inviter_id : 0;
|
||||
}
|
||||
@@ -422,7 +435,9 @@ uint64_t chat_join_lookup_inviter(struct UTUN_INSTANCE* inst, uint64_t channel_i
|
||||
int chat_join_forward_request(struct UTUN_INSTANCE* inst, uint64_t channel_id,
|
||||
uint64_t inviter_id, uint64_t join_key,
|
||||
const struct join_member_data* m) {
|
||||
if (!inst || !m || inviter_id == 0) return -1;
|
||||
if (!inst || inst->join_stopping || !m || !channel_id || channel_id > INT64_MAX || !join_key || !inviter_id) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid forward arguments", CJ_ID); return -1;
|
||||
}
|
||||
|
||||
uint8_t mbuf[MS_USERINFO_MAX + 256]; size_t mlen = 0;
|
||||
if (chat_join_serialize_member(m, mbuf, sizeof(mbuf), &mlen) != 0) {
|
||||
|
||||
+12
-5
@@ -15,7 +15,7 @@
|
||||
* │ │◀─── JOIN_REQUEST ────────────┤ (key + полный набор мембера J)
|
||||
* │ │ валиден ключ → только pending, БЕЗ BGP new_conn
|
||||
* │◀── REQUEST_FWD ───────────┤ │
|
||||
* │ verif join_sig, node_id=derive(x25519) │
|
||||
* │ verif JOIN,профиля,известного Ed25519,node_id=derive(x25519) │
|
||||
* │ member_sync_put(J, signed_by=A, signature=sign(A, J_x25519||"group_join"))
|
||||
* │ ── merkle broadcast ──▶ (рекорд J дошёл по merkle) │
|
||||
* │ │── JOIN_READY(ch) ────────────▶│ добавление подтверждено у C
|
||||
@@ -64,7 +64,9 @@
|
||||
* invite-ключ как замену локальной таблицы участников.
|
||||
*
|
||||
* J <-> C: ETCP_ID_CHAT_SYNC, заголовок [service:1][group_id:8][type:1], payload
|
||||
* описан в chat_sync.h. A <-> C: ETCP_RT_ID_JOIN через etcp_router с подписью
|
||||
* описан в chat_sync.h. Для INFO_REQ/RESP, REQUEST, READY и ERROR payload начинается
|
||||
* с attempt_id:8BE; C сохраняет его в waiter и возвращает в ответах. Это ID попытки,
|
||||
* не join_key и не request_id интерфейса. A <-> C: ETCP_RT_ID_JOIN через etcp_router с подписью
|
||||
* и шифрованием; subcommands и payload описаны ниже.
|
||||
*
|
||||
* Модуль отвечает только за маршрутизируемую (etcp_router) часть:
|
||||
@@ -113,11 +115,14 @@ struct join_member_data {
|
||||
char userinfo[MS_USERINFO_MAX + 1]; /* JSON, null-terminated */
|
||||
};
|
||||
|
||||
/* Сериализовать member-блок (без join_key). Возвращает 0 и *out_len, -1 если не влез. */
|
||||
/* Сериализовать member-блок (без join_key). 0 и *out_len,-1 при неверных аргументах/размере.
|
||||
* На отказе out/out_len сохраняются; подписи NULL кодируются 64 нулями,ключи обязательны.
|
||||
* Числа native,кроме userinfo_len:u16BE;userinfo<=1024 байт без NUL на проводе. */
|
||||
int chat_join_serialize_member(const struct join_member_data* m,
|
||||
uint8_t* out, size_t out_sz, size_t* out_len);
|
||||
|
||||
/* Разобрать member-блок. Указатели ссылаются на data (не копируют). 0=ок, -1=ошибка. */
|
||||
/* Разобрать весь member-блок:проверить длину/отсутствие скрытого NUL. 0/-1,на отказе m сохраняется.
|
||||
* Ключи/подписи заимствованы у data,userinfo копируется;криптография здесь не проверяется. */
|
||||
int chat_join_parse_member(const uint8_t* data, size_t len, struct join_member_data* m);
|
||||
|
||||
/* Инициализация: etcp_router_bind(ETCP_RT_ID_JOIN). */
|
||||
@@ -145,7 +150,9 @@ int chat_join_forward_request(struct UTUN_INSTANCE* inst, uint64_t channel_id,
|
||||
const struct join_member_data* m);
|
||||
|
||||
/* Инвайтер (локально, когда connection==инвайтер, или из recv REQUEST_FWD):
|
||||
* верификация join_sig + добавление мембера + подпись дерева. */
|
||||
* Проверка ключа,идентичности,JOIN,подписи профиля при наличии и известного Ed25519;
|
||||
* затем доверенная локальная запись с подписью приглашения. Join-only допускается.
|
||||
* Подключение группы инициирует member_sync после commit;при изменении CONNECT_RESULT имеет request_id=0. */
|
||||
void chat_join_process_request(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
||||
uint64_t from_node, uint64_t join_key,
|
||||
const uint8_t* member_data, size_t member_len);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#include "chat_join_port.h"
|
||||
#include "chat_sync.h"
|
||||
#include "invite_link.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include <pthread.h>
|
||||
#include <string.h>
|
||||
|
||||
struct join_command {
|
||||
struct posted_task post; /* первый член: uasync освобождает всю команду */
|
||||
struct join_command* next;
|
||||
struct chat_join_port* port;
|
||||
uint64_t request, channel, peer, offer;
|
||||
size_t len;
|
||||
int completed;
|
||||
char link[];
|
||||
};
|
||||
struct chat_join_port {
|
||||
pthread_mutex_t lock;
|
||||
unsigned refs;
|
||||
uint64_t active_request;
|
||||
struct UTUN_INSTANCE* inst; /* только callback в uasync обращается к instance */
|
||||
struct UASYNC* ua; /* жив до close; producer публикует под lock */
|
||||
struct join_command* commands;
|
||||
};
|
||||
|
||||
struct chat_join_port* chat_join_port_create(struct UTUN_INSTANCE* inst) {
|
||||
struct chat_join_port* p = u_calloc(1, sizeof(*p));
|
||||
if (!p) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "join port allocation failed"); return NULL; }
|
||||
if (pthread_mutex_init(&p->lock, NULL)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "join port mutex initialization failed"); u_free(p); return NULL;
|
||||
}
|
||||
p->refs = 1; p->inst = inst; p->ua = inst->ua; return p;
|
||||
}
|
||||
void chat_join_port_retain(struct chat_join_port* p) {
|
||||
if (!p) return;
|
||||
pthread_mutex_lock(&p->lock); p->refs++; pthread_mutex_unlock(&p->lock);
|
||||
}
|
||||
void chat_join_port_release(struct chat_join_port* p) {
|
||||
if (!p) return;
|
||||
pthread_mutex_lock(&p->lock); unsigned refs = --p->refs; pthread_mutex_unlock(&p->lock);
|
||||
if (!refs) { pthread_mutex_destroy(&p->lock); u_free(p); }
|
||||
}
|
||||
static void execute(void* arg) {
|
||||
struct join_command* command = arg;
|
||||
struct chat_join_port* p = command->port;
|
||||
pthread_mutex_lock(&p->lock);
|
||||
struct join_command** link = &p->commands;
|
||||
while (*link && *link != command) link = &(*link)->next;
|
||||
if (*link) *link = command->next;
|
||||
struct UTUN_INSTANCE* inst = p->inst;
|
||||
if (!command->completed && inst && !p->active_request) p->active_request = command->request;
|
||||
pthread_mutex_unlock(&p->lock);
|
||||
if (!command->completed && inst) {
|
||||
int result = command->offer ? chat_sync_offer_link(inst, command->request, command->offer, command->link, command->len)
|
||||
: chat_sync_join_link(inst, command->request, command->link, command->len);
|
||||
if (result) {
|
||||
chat_join_port_complete(p, command->request);
|
||||
chat_sync_join_result(inst, command->request, command->channel, command->peer, result);
|
||||
}
|
||||
}
|
||||
chat_join_port_release(p); /* память command освободит uasync */
|
||||
}
|
||||
static int submit(struct chat_join_port* p, uint64_t request, const char* link, size_t len, uint64_t offer) {
|
||||
if (!p || !request || !link || !len || len >= INVITE_LINK_MAX) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "join command invalid or port unavailable"); return CHAT_CONNECT_INVALID;
|
||||
}
|
||||
struct InviteData invite;
|
||||
if (invite_link_decode(link, len, &invite, NULL, 0)) return CHAT_CONNECT_INVALID;
|
||||
if (!offer && invite.kind != INVITE_KIND_CHANNEL) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "server link requires selecting a local channel to add"); return CHAT_CONNECT_INVALID;
|
||||
}
|
||||
struct join_command* command = u_calloc(1, sizeof(*command) + len);
|
||||
if (!command) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "join command allocation failed"); return CHAT_CONNECT_ERROR; }
|
||||
command->post.callback = execute; command->post.arg = command; command->port = p;
|
||||
command->offer = offer; command->request = request; command->channel = offer ? offer : invite.channelId; command->peer = invite.nodeId;
|
||||
command->len = len; memcpy(command->link, link, len);
|
||||
pthread_mutex_lock(&p->lock);
|
||||
if (!p->inst) {
|
||||
pthread_mutex_unlock(&p->lock); u_free(command);
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "join command rejected: port closed"); return CHAT_CONNECT_CANCELLED;
|
||||
}
|
||||
if (p->active_request == request) {
|
||||
pthread_mutex_unlock(&p->lock); u_free(command);
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "join request already active"); return CHAT_CONNECT_BUSY;
|
||||
}
|
||||
for (struct join_command* c = p->commands; c; c = c->next) if (c->request == request) {
|
||||
pthread_mutex_unlock(&p->lock); u_free(command);
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "duplicate pending join request"); return CHAT_CONNECT_BUSY;
|
||||
}
|
||||
p->refs++; command->next = p->commands; p->commands = command;
|
||||
uasync_post_reserved(p->ua, &command->post);
|
||||
pthread_mutex_unlock(&p->lock);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "join command queued request=%llu", (unsigned long long)request);
|
||||
return 0;
|
||||
}
|
||||
void chat_join_port_close(struct chat_join_port* p) {
|
||||
if (!p) return;
|
||||
pthread_mutex_lock(&p->lock);
|
||||
struct UTUN_INSTANCE* inst = p->inst;
|
||||
struct UASYNC* ua = p->ua;
|
||||
struct join_command* commands = p->commands;
|
||||
p->commands = NULL; p->inst = NULL; p->ua = NULL;
|
||||
pthread_mutex_unlock(&p->lock);
|
||||
while (commands) {
|
||||
struct join_command* c = commands; commands = c->next;
|
||||
c->completed = 1;
|
||||
uint64_t request = c->request, channel = c->channel, peer = c->peer;
|
||||
/* Успешная отмена снимает ссылку команды; уже начатый execute снимает её самостоятельно. */
|
||||
if (uasync_cancel_post(ua, &c->post) == ERR_OK) chat_join_port_release(p);
|
||||
chat_sync_join_result(inst, request, channel, peer, CHAT_CONNECT_CANCELLED);
|
||||
}
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "join port closed");
|
||||
}
|
||||
|
||||
void chat_join_port_complete(struct chat_join_port* p, uint64_t request) {
|
||||
if (!p) return;
|
||||
pthread_mutex_lock(&p->lock);
|
||||
if (p->active_request == request) p->active_request = 0;
|
||||
pthread_mutex_unlock(&p->lock);
|
||||
}
|
||||
|
||||
int chat_join_port_submit(struct chat_join_port* p, uint64_t request, const char* link, size_t len) {
|
||||
return submit(p, request, link, len, 0);
|
||||
}
|
||||
int chat_join_port_offer(struct chat_join_port* p, uint64_t request, uint64_t channel, const char* link, size_t len) {
|
||||
if (!channel) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "offer missing channel"); return CHAT_CONNECT_INVALID; }
|
||||
return submit(p, request, link, len, channel);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Потокобезопасный вход команд JOIN для GUI/JNI. Контекст удерживается фронтендом
|
||||
* от bind до unbind; close в uasync отменяет команды и запрещает дальнейшие submit.
|
||||
* После close порт живёт до release последних ссылок, не содержит живого instance. */
|
||||
#ifndef CHAT_JOIN_PORT_H
|
||||
#define CHAT_JOIN_PORT_H
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
struct UTUN_INSTANCE;
|
||||
struct chat_join_port;
|
||||
/* Создать порт в uasync; одна ссылка принадлежит chat_sync, NULL при ошибке. */
|
||||
struct chat_join_port* chat_join_port_create(struct UTUN_INSTANCE* inst);
|
||||
/* Получить отдельную ссылку на порт текущего чата; только uasync, NULL до init. */
|
||||
struct chat_join_port* chat_sync_join_port(struct UTUN_INSTANCE* inst);
|
||||
/* Управление отдельной ссылкой. retain требует уже удерживаемой ссылки. */
|
||||
void chat_join_port_retain(struct chat_join_port* port);
|
||||
void chat_join_port_release(struct chat_join_port* port);
|
||||
/* uasync: снять корреляцию завершённой активной команды до уведомления. */
|
||||
void chat_join_port_complete(struct chat_join_port* port, uint64_t request);
|
||||
/* uasync: закрыть приём и завершить ожидающие команды CANCELLED; повтор допустим. */
|
||||
void chat_join_port_close(struct chat_join_port* port);
|
||||
/* Любой поток: скопировать ссылку и принять команду. 0 — будет один результат с request;
|
||||
* <0 — команда не принята, результата не будет. request != 0; link заимствован до возврата. */
|
||||
int chat_join_port_submit(struct chat_join_port* port, uint64_t request, const char* link, size_t len);
|
||||
/* Те же гарантии для приглашения адресата ссылки в собственный channel. */
|
||||
int chat_join_port_offer(struct chat_join_port* port, uint64_t request, uint64_t channel, const char* link, size_t len);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
+120
-9
@@ -4,6 +4,13 @@
|
||||
#include "chat_setting.h"
|
||||
#include "../utun_instance.h"
|
||||
#include "../local_transport/protocol.h"
|
||||
#include "../routing_layer/topo_group.h"
|
||||
#include "../routing_layer/topo_group_connect.h"
|
||||
#include "../routing_layer/topo_node_sqlite.h"
|
||||
#include "../routing_layer/sock_match.h"
|
||||
#include "../transport_layer/etcp.h"
|
||||
#include "../transport_layer/etcp_connections.h"
|
||||
#include "../transport_layer/node_conn_direct.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include <string.h>
|
||||
@@ -13,6 +20,16 @@ void chat_local_peers_trampoline(void* arg) {
|
||||
if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "device list request without instance"); return; }
|
||||
struct local_peer peers[LOCAL_TRANSPORT_PEERS_MAX];
|
||||
size_t count = local_transport_peers(inst->local_transport, peers, LOCAL_TRANSPORT_PEERS_MAX), offset = 2;
|
||||
if (inst->bluetooth_discovery) {
|
||||
const struct chat_local_provider* bt = inst->bluetooth_discovery;
|
||||
struct local_peer bluetooth[LOCAL_TRANSPORT_PEERS_MAX];
|
||||
size_t n = bt->peers(bt->ctx, bluetooth, LOCAL_TRANSPORT_PEERS_MAX);
|
||||
for (size_t i = 0; i < n && count < LOCAL_TRANSPORT_PEERS_MAX; i++) {
|
||||
size_t j;
|
||||
for (j = 0; j < count && peers[j].node_id != bluetooth[i].node_id; j++);
|
||||
if (j == count) peers[count++] = bluetooth[i];
|
||||
}
|
||||
}
|
||||
uint8_t data[2 + LOCAL_TRANSPORT_PEERS_MAX * (8 + 1 + LOCAL_TRANSPORT_NAME_MAX - 1)];
|
||||
data[0] = (uint8_t)(count >> 8); data[1] = (uint8_t)count;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
@@ -25,7 +42,7 @@ void chat_local_peers_trampoline(void* arg) {
|
||||
|
||||
static void peers_changed(void* arg) { chat_local_peers_trampoline(arg); }
|
||||
|
||||
static int valid_link(const char* link, size_t len) {
|
||||
int chat_local_valid_link(const char* link, size_t len) {
|
||||
struct InviteData invite = {0};
|
||||
char error[128];
|
||||
if (!len || len > LOCAL_TRANSPORT_DATA_MAX || memchr(link, 0, len) || len < 7 || memcmp(link, "utun://", 7)) {
|
||||
@@ -41,13 +58,15 @@ static int valid_link(const char* link, size_t len) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int received(void* arg, const struct local_peer* peer, const uint8_t* link, size_t len) {
|
||||
struct UTUN_INSTANCE* inst = arg;
|
||||
if (!chat_setting_get_int(inst, "local_invites_enabled", 1)) {
|
||||
int chat_local_receive(struct UTUN_INSTANCE* inst, const struct local_peer* peer, const uint8_t* link, size_t len, int bluetooth) {
|
||||
if (!inst || !peer || !link || strnlen(peer->name, sizeof(peer->name)) == sizeof(peer->name)) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "invalid invitation receive arguments"); return -1;
|
||||
}
|
||||
if (!chat_setting_get_int(inst, bluetooth ? "bluetooth_invites_enabled" : "local_invites_enabled", bluetooth ? 0 : 1)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "local invitation rejected peer=%016llx reason=receiving disabled",
|
||||
(unsigned long long)peer->node_id); return LOCAL_DELIVERY_FORBIDDEN;
|
||||
}
|
||||
if (!inst->chat_event_handler || !valid_link((const char*)link, len)) {
|
||||
if (!inst->chat_event_handler || !chat_local_valid_link((const char*)link, len)) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "local invitation rejected peer=%016llx handler=%d",
|
||||
(unsigned long long)peer->node_id, inst->chat_event_handler != NULL); return -1;
|
||||
}
|
||||
@@ -59,12 +78,23 @@ static int received(void* arg, const struct local_peer* peer, const uint8_t* lin
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void delivered(void* arg, uint64_t request, uint64_t peer, int status) {
|
||||
void chat_local_delivered(void* arg, uint64_t request, uint64_t peer, int status) {
|
||||
uint8_t data[17];
|
||||
local_put64(data, request); local_put64(data + 8, peer); data[16] = (uint8_t)status;
|
||||
chat_event_post(arg, CHAT_EVT_LOCAL_DELIVERY, data, sizeof(data));
|
||||
}
|
||||
|
||||
static int received(void* arg, const struct local_peer* peer, const uint8_t* link, size_t len) {
|
||||
return chat_local_receive(arg, peer, link, len, 0);
|
||||
}
|
||||
void chat_local_set_bluetooth(struct UTUN_INSTANCE* inst, const struct chat_local_provider* provider) {
|
||||
if (!inst || (provider && (!provider->ctx || !provider->peers || !provider->send))) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "invalid Bluetooth discovery provider"); return;
|
||||
}
|
||||
inst->bluetooth_discovery = provider;
|
||||
chat_local_peers_trampoline(inst);
|
||||
}
|
||||
|
||||
static void activity_changed(void* arg, int active) {
|
||||
uint8_t value = active != 0;
|
||||
chat_event_post(arg, CHAT_EVT_LOCAL_DISCOVERY_ACTIVE, &value, 1);
|
||||
@@ -76,9 +106,81 @@ static void activity(struct UTUN_INSTANCE* inst, int active, void* arg) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "failed to apply discovery activity node=%016llx", (unsigned long long)inst->node_id);
|
||||
}
|
||||
|
||||
/* Только принимающие рабочие endpoints этого IP; скрытые и SOCKS/probe-сокеты не публикуются. */
|
||||
static int route_sockets(void* arg, const struct sockaddr_storage* address, uint32_t index,
|
||||
struct local_route_socket* out, size_t capacity) {
|
||||
struct UTUN_INSTANCE* inst = arg;
|
||||
size_t count = 0;
|
||||
for (struct ETCP_SOCKET* sock = inst->etcp_sockets; sock; sock = sock->next) {
|
||||
if (sock->type == CFG_SERVER_TYPE_PRIVATE || !sock_matches_lan(sock, address, index)) continue;
|
||||
uint16_t port = ss_get_port(&sock->local_addr);
|
||||
if (!port || !sock->generation) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "LAN socket omitted name=%s id=%u port=%u generation=%016llx",
|
||||
sock->name, sock->sock_id, port, (unsigned long long)sock->generation); continue;
|
||||
}
|
||||
if (count == capacity) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "too many LAN sockets interface=%u capacity=%zu", index, capacity); return -1;
|
||||
}
|
||||
out[count++] = (struct local_route_socket){ .socket_id = sock->sock_id, .generation = sock->generation,
|
||||
.port = port, .protocol = sock->is_tcp ? LOCAL_ROUTE_TCP | (sock->reality_enabled ? LOCAL_ROUTE_REALITY : 0) : LOCAL_ROUTE_UDP };
|
||||
}
|
||||
return (int)count;
|
||||
}
|
||||
|
||||
/* Ключи не покидают ядро; wire magic/version/type отделяют эту подпись от других протоколов. */
|
||||
static int route_sign(void* arg, const uint8_t* data, size_t len, uint8_t signature[64]) {
|
||||
struct UTUN_INSTANCE* inst = arg;
|
||||
if (inst->node_id != sc_derive_node_id_from_pubkey(inst->my_keys.public_key) ||
|
||||
sc_ed25519_sign(inst->my_ed25519_privkey, data, len, signature) != SC_OK) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "LAN endpoint signing failed node=%016llx", (unsigned long long)inst->node_id); return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Отсутствие ключа не мешает открытому discovery, но не даёт адрес для NCD. */
|
||||
static int route_verify(void* arg, uint64_t peer, const uint8_t* data, size_t len, const uint8_t signature[64]) {
|
||||
struct UTUN_INSTANCE* inst = arg;
|
||||
uint8_t key[32];
|
||||
struct TOPO_NODE* node = topo_node_registry_find(inst->topo_groups, peer);
|
||||
if (node && node->timestamp) memcpy(key, node->ed25519_public_key, sizeof(key));
|
||||
else if (!inst->topo_sqlite_db || topo_node_sqlite_get_ed25519_pubkey(inst->topo_sqlite_db, peer, key)) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "LAN route ignored peer=%016llx reason=unknown signing key", (unsigned long long)peer); return 1;
|
||||
}
|
||||
if (sc_ed25519_verify(key, data, len, signature) != SC_OK) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "LAN route signature rejected peer=%016llx", (unsigned long long)peer); return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Общие адреса узла нельзя заменять, пока доступен прямой транспорт или любой BGP-путь. */
|
||||
static int route_offline(void* arg, uint64_t peer) {
|
||||
struct UTUN_INSTANCE* inst = arg;
|
||||
struct ETCP_CONN* conn = inst->connections ? instance_find_conn(inst, peer) : NULL;
|
||||
if (conn && conn->links_up && !conn->close_requested && conn->state != 2) return 0;
|
||||
for (struct ll_entry* e = inst->topo_groups && inst->topo_groups->group_list ? inst->topo_groups->group_list->head : NULL;
|
||||
e; e = e->next) {
|
||||
if (topo_node_best_path(topo_node_find_by_id((struct TOPO_GROUP*)e, peer))) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Обновление помогает уже удерживаемому NCD; право создавать соединение остаётся у сервисов. */
|
||||
static void routes_changed(void* arg, uint64_t peer) {
|
||||
struct UTUN_INSTANCE* inst = arg;
|
||||
if (inst->connections && node_conn_direct_update_local(inst, peer) < 0)
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "LAN address application failed peer=%016llx", (unsigned long long)peer);
|
||||
for (struct ll_entry* e = inst->topo_groups && inst->topo_groups->group_list ? inst->topo_groups->group_list->head : NULL;
|
||||
e; e = e->next) {
|
||||
struct TOPO_GROUP* group = (struct TOPO_GROUP*)e;
|
||||
if (group->group_type == TOPO_GROUP_TYPE_CHAT && !group->stopping) topo_group_connect_changed(group);
|
||||
}
|
||||
}
|
||||
|
||||
int chat_local_start(struct UTUN_INSTANCE* inst) {
|
||||
if (inst->local_transport || inst->client_type == CLIENT_TYPE_SERVER) return 0;
|
||||
struct local_transport_callbacks callbacks = { peers_changed, received, delivered, activity_changed };
|
||||
struct local_transport_callbacks callbacks = { .peers_changed = peers_changed, .received = received, .delivery = chat_local_delivered,
|
||||
.activity_changed = activity_changed, .route_sockets = route_sockets, .route_sign = route_sign,
|
||||
.route_verify = route_verify, .route_offline = route_offline, .routes_changed = routes_changed };
|
||||
inst->local_transport = local_transport_create(inst->ua, inst->node_id, inst->name, &callbacks, inst);
|
||||
if (!inst->local_transport) return -1;
|
||||
if (chat_local_apply_settings(inst) < 0) { chat_local_stop(inst); return -1; }
|
||||
@@ -107,7 +209,16 @@ void chat_local_send_trampoline(void* arg) {
|
||||
if (!req) return;
|
||||
size_t len = strnlen(req->link, sizeof(req->link));
|
||||
if (!req->inst) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "invitation send without instance"); u_free(req); return; }
|
||||
if (!valid_link(req->link, len) || local_transport_send(req->inst->local_transport, req->peer_id,
|
||||
req->request_id, req->link, len) < 0) delivered(req->inst, req->request_id, req->peer_id, 0);
|
||||
int result = -1;
|
||||
if (chat_local_valid_link(req->link, len)) {
|
||||
struct local_peer peers[LOCAL_TRANSPORT_PEERS_MAX];
|
||||
size_t count = local_transport_peers(req->inst->local_transport, peers, LOCAL_TRANSPORT_PEERS_MAX);
|
||||
for (size_t i = 0; i < count; i++) if (peers[i].node_id == req->peer_id) {
|
||||
result = local_transport_send(req->inst->local_transport, req->peer_id, req->request_id, req->link, len); break;
|
||||
}
|
||||
const struct chat_local_provider* bt = req->inst->bluetooth_discovery;
|
||||
if (result < 0 && bt) result = bt->send(bt->ctx, req->peer_id, req->request_id, req->link, len);
|
||||
}
|
||||
if (result < 0) chat_local_delivered(req->inst, req->request_id, req->peer_id, LOCAL_DELIVERY_FAILED);
|
||||
u_free(req);
|
||||
}
|
||||
Loaded 100 of 1041 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user