File: linux_me2me_host.py

package info (click to toggle)
chromium 139.0.7258.127-2
  • links: PTS, VCS
  • area: main
  • in suites: forky
  • size: 6,122,156 kB
  • sloc: cpp: 35,100,771; ansic: 7,163,530; javascript: 4,103,002; python: 1,436,920; asm: 946,517; xml: 746,709; pascal: 187,653; perl: 88,691; sh: 88,436; objc: 79,953; sql: 51,488; cs: 44,583; fortran: 24,137; makefile: 22,147; tcl: 15,277; php: 13,980; yacc: 8,984; ruby: 7,485; awk: 3,720; lisp: 3,096; lex: 1,327; ada: 727; jsp: 228; sed: 36
file content (2662 lines) | stat: -rwxr-xr-x 104,490 bytes parent folder | download | duplicates (6)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
#!/usr/bin/python3
# Copyright 2012 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# Virtual Me2Me implementation.  This script runs and manages the processes
# required for a Virtual Me2Me desktop, which are: X server, X desktop
# session, and Host process.
# This script is intended to run continuously as a background daemon
# process, running under an ordinary (non-root) user account.

import sys
if sys.version_info[0] != 3 or sys.version_info[1] < 5:
  print("This script requires Python version 3.5")
  sys.exit(1)

import abc
import argparse
import atexit
import base64
import errno
import fcntl
import getpass
import grp
import hashlib
import json
import logging
import os
import platform
import pwd
import re
import shlex
import shutil
import signal
import socket
import string
import struct
import subprocess
import syslog
import tempfile
import threading
import time
import uuid

import psutil
import xdg.BaseDirectory
from packaging import version

# If this env var is defined, extra host params will be loaded from this env var
# as a list of strings separated by space (\s+). Note that param that contains
# space is currently NOT supported and will be broken down into two params at
# the space character.
HOST_EXTRA_PARAMS_ENV_VAR = "CHROME_REMOTE_DESKTOP_HOST_EXTRA_PARAMS"

# This script has a sensible default for the initial and maximum desktop size,
# which can be overridden either on the command-line, or via a comma-separated
# list of sizes in this environment variable.
DEFAULT_SIZES_ENV_VAR = "CHROME_REMOTE_DESKTOP_DEFAULT_DESKTOP_SIZES"

# By default, this script launches Xorg as the virtual X display, using the
# dummy display driver and void input device, unless Xorg+Dummy is deemed
# unsupported. When this environment variable is set, the script will instead
# launch Xvfb.
USE_XVFB_ENV_VAR = "CHROME_REMOTE_DESKTOP_USE_XVFB"

# The amount of video RAM the dummy driver should claim to have, which limits
# the maximum possible resolution.
# 1048576 KiB = 1 GiB, which is the amount of video RAM needed to have a
# 16384x16384 pixel frame buffer (the maximum size supported by VP8) with 32
# bits per pixel.
XORG_DUMMY_VIDEO_RAM = 1048576 # KiB

# By default, provide a maximum size that is large enough to support clients
# with large or multiple monitors. This is a comma-separated list of
# resolutions that will be made available if the X server supports RANDR. These
# defaults can be overridden in ~/.profile.
DEFAULT_SIZES = "1600x1200,3840x2560"

# Decides number of monitors and their resolution that should be run for the
# wayland session.
WAYLAND_DESKTOP_SIZES_ENV = "CHROME_REMOTE_DESKTOP_WAYLAND_DESKTOP_SIZES"

# Default wayland monitor size if `CHROME_REMOTE_DESKTOP_DEFAULT_DESKTOP_SIZES`
# env variable is not set.
DEFAULT_WAYLAND_DESKTOP_SIZES = "1280x720"

SCRIPT_PATH = os.path.abspath(sys.argv[0])
SCRIPT_DIR = os.path.dirname(SCRIPT_PATH)

if (os.path.basename(sys.argv[0]) == 'linux_me2me_host.py'):
  # Needed for swarming/isolate tests.
  HOST_BINARY_PATH = os.path.join(SCRIPT_DIR,
                                  "../../../out/Release/remoting_me2me_host")
else:
  HOST_BINARY_PATH = os.path.join(SCRIPT_DIR, "chrome-remote-desktop-host")

USER_SESSION_PATH = os.path.join(SCRIPT_DIR, "user-session")

CRASH_UPLOADER_PATH = os.path.join(SCRIPT_DIR, "crash-uploader")

CHROME_REMOTING_GROUP_NAME = "chrome-remote-desktop"

HOME_DIR = os.environ["HOME"]
CONFIG_DIR = os.path.join(HOME_DIR, ".config/chrome-remote-desktop")
SESSION_FILE_PATH = os.path.join(HOME_DIR, ".chrome-remote-desktop-session")
SYSTEM_SESSION_FILE_PATH = "/etc/chrome-remote-desktop-session"
SYSTEM_PRE_SESSION_FILE_PATH = "/etc/chrome-remote-desktop-pre-session"

DEBIAN_XSESSION_PATH = "/etc/X11/Xsession"

X_LOCK_FILE_TEMPLATE = "/tmp/.X%d-lock"
FIRST_X_DISPLAY_NUMBER = 20

# Amount of time to wait between relaunching processes.
SHORT_BACKOFF_TIME = 5
LONG_BACKOFF_TIME = 60

# How long a process must run in order not to be counted against the restart
# thresholds.
MINIMUM_PROCESS_LIFETIME = 60

# Thresholds for switching from fast- to slow-restart and for giving up
# trying to restart entirely.
SHORT_BACKOFF_THRESHOLD = 5
MAX_LAUNCH_FAILURES = SHORT_BACKOFF_THRESHOLD + 10

# Number of seconds to save session output to the log.
SESSION_OUTPUT_TIME_LIMIT_SECONDS = 300

# Number of seconds to save the display server output to the log.
SERVER_OUTPUT_TIME_LIMIT_SECONDS = 300

# Host offline reason if the X server retry count is exceeded.
HOST_OFFLINE_REASON_X_SERVER_RETRIES_EXCEEDED = "X_SERVER_RETRIES_EXCEEDED"

# Host offline reason if the wayland server retry count is exceeded.
HOST_OFFLINE_REASON_WAYLAND_SERVER_RETRIES_EXCEEDED = (
  "WAYLAND_SERVER_RETRIES_EXCEEDED")

# Host offline reason if the X session retry count is exceeded.
HOST_OFFLINE_REASON_SESSION_RETRIES_EXCEEDED = "SESSION_RETRIES_EXCEEDED"

# Host offline reason if the host retry count is exceeded. (Note: It may or may
# not be possible to send this, depending on why the host is failing.)
HOST_OFFLINE_REASON_HOST_RETRIES_EXCEEDED = "HOST_RETRIES_EXCEEDED"

# Host offline reason if the crash-uploader retry count is exceeded.
HOST_OFFLINE_REASON_CRASH_UPLOADER_RETRIES_EXCEEDED = (
  "CRASH_UPLOADER_RETRIES_EXCEEDED")

# This is the file descriptor used to pass messages to the user_session binary
# during startup. It must be kept in sync with kMessageFd in
# remoting_user_session.cc.
USER_SESSION_MESSAGE_FD = 202

# This is the exit code used to signal to wrapper that it should restart instead
# of exiting. It must be kept in sync with kRelaunchExitCode in
# remoting_user_session.cc and RestartForceExitStatus in
# chrome-remote-desktop@.service.
RELAUNCH_EXIT_CODE = 41

# This exit code is returned when a needed binary such as user-session or sg
# cannot be found.
COMMAND_NOT_FOUND_EXIT_CODE = 127

# This exit code is returned when a needed binary exists but cannot be executed.
COMMAND_NOT_EXECUTABLE_EXIT_CODE = 126

# User runtime directory. This is where the wayland socket is created by the
# wayland compositor/server for clients to connect to.
# TODO(rkjnsn): Use xdg.BaseDirectory.get_runtime_dir instead
RUNTIME_DIR_TEMPLATE = "/run/user/%s"

# Binary name for `gnome-session`.
GNOME_SESSION = "gnome-session"

# Binary name for `gnome-session-quit`.
GNOME_SESSION_QUIT = "gnome-session-quit"

# Globals needed by the atexit cleanup() handler.
g_desktop = None
g_host_hash = hashlib.md5(socket.gethostname().encode()).hexdigest()

def gen_xorg_config():
  return (
      # This causes X to load the default GLX module, even if a proprietary one
      # is installed in a different directory.
      'Section "Files"\n'
      '  ModulePath "/usr/lib/xorg/modules"\n'
      'EndSection\n'
      '\n'
      # Suppress device probing, which happens by default.
      'Section "ServerFlags"\n'
      '  Option "AutoAddDevices" "false"\n'
      '  Option "AutoEnableDevices" "false"\n'
      '  Option "DontVTSwitch" "true"\n'
      '  Option "PciForceNone" "true"\n'
      'EndSection\n'
      '\n'
      'Section "InputDevice"\n'
      # The host looks for this name to check whether it's running in a virtual
      # session
      '  Identifier "Chrome Remote Desktop Input"\n'
      # While the xorg.conf man page specifies that both of these options are
      # deprecated synonyms for `Option "Floating" "false"`, it turns out that
      # if both aren't specified, the Xorg server will automatically attempt to
      # add additional devices.
      '  Option "CoreKeyboard" "true"\n'
      '  Option "CorePointer" "true"\n'
      # The "void" driver is no longer available since Debian 11, but having an
      # InputDevice section with an invalid driver will still prevent the Xorg
      # server from using a fallback InputDevice setting. However, "Chrome
      # Remote Desktop Input" will not appear in the device list if the driver
      # is not available.
      '  Driver "void"\n'
      'EndSection\n'
      '\n'
      'Section "Device"\n'
      '  Identifier "Chrome Remote Desktop Videocard"\n'
      '  Driver "dummy"\n'
      '  VideoRam {video_ram}\n'
      'EndSection\n'
      '\n'
      'Section "Monitor"\n'
      '  Identifier "Chrome Remote Desktop Monitor"\n'
      'EndSection\n'
      '\n'
      'Section "Screen"\n'
      '  Identifier "Chrome Remote Desktop Screen"\n'
      '  Device "Chrome Remote Desktop Videocard"\n'
      '  Monitor "Chrome Remote Desktop Monitor"\n'
      '  DefaultDepth 24\n'
      '  SubSection "Display"\n'
      '    Viewport 0 0\n'
      '    Depth 24\n'
      '  EndSubSection\n'
      'EndSection\n'
      '\n'
      'Section "ServerLayout"\n'
      '  Identifier   "Chrome Remote Desktop Layout"\n'
      '  Screen       "Chrome Remote Desktop Screen"\n'
      '  InputDevice  "Chrome Remote Desktop Input"\n'
      'EndSection\n'.format(
          video_ram=XORG_DUMMY_VIDEO_RAM))


def display_manager_is_gdm():
  try:
    # Open as binary to avoid any encoding errors
    with open('/etc/X11/default-display-manager', 'rb') as file:
      if file.read().strip() in [b'/usr/sbin/gdm', b'/usr/sbin/gdm3']:
        return True
    # Fall through to process checking even if the file doesn't contain gdm.
  except:
    # If we can't read the file, move on to checking the process list.
    pass

  for process in psutil.process_iter():
    if process.name() in ['gdm', 'gdm3']:
      return True

  return False


def is_supported_platform():
  # Always assume that the system is supported if the config directory or
  # session file exist.
  if (os.path.isdir(CONFIG_DIR) or os.path.isfile(SESSION_FILE_PATH) or
      os.path.isfile(SYSTEM_SESSION_FILE_PATH)):
    return True

  # There's a bug in recent versions of GDM that will prevent a user from
  # logging in via GDM when there is already an x11 session running for that
  # user (such as the one started by CRD). Since breaking local login is a
  # pretty serious issue, we want to disallow host set up through the website.
  # Unfortunately, there's no way to return a specific error to the website, so
  # we just return False to indicate an unsupported platform. The user can still
  # set up the host using the headless setup flow, where we can at least display
  # a warning. See https://gitlab.gnome.org/GNOME/gdm/-/issues/580 for details
  # of the bug and fix.
  if display_manager_is_gdm():
    return False;

  # The session chooser expects a Debian-style Xsession script.
  return os.path.isfile(DEBIAN_XSESSION_PATH);


def is_crash_reporting_enabled(config):
  # Enable crash reporting for Google hosts or when usage_stats_consent is true.
  return (config.get("host_owner", "").endswith("@google.com") or
          config.get("usage_stats_consent", False))


def get_pipewire_session_manager():
  """Returns the PipeWire session manager supported on this system (either
  "wireplumber" or "pipewire-media-session"), or None if a supported PipeWire
  installation is not found."""

  if shutil.which("pipewire") is None:
    logging.warning("PipeWire not found. Not enabling PipeWire audio support.")
    return None

  try:
    version_output = subprocess.check_output(["pipewire", "--version"],
                                             universal_newlines=True)
  except subprocess.CalledProcessError as e:
    logging.warning("Failed to execute pipewire. Not enabling PipeWire audio"
                    + " support: " + str(e))
    return None

  match = re.search(r"pipewire (\S+)$", version_output, re.MULTILINE)
  if not match:
    logging.warning("Failed to determine pipewire version. Not enabling"
                    + " PipeWire audio support.")
    return None

  try:
    pipewire_version = version.parse(match[1])
  except version.InvalidVersion as e:
    logging.warning("Failed to parse pipewire version. Not enabling PipeWire"
                    + " audio support: " + str(e))
    return None

  if pipewire_version < version.parse("0.3.53"):
    logging.warning("Installed pipewire version is too old. Not enabling"
                    + " PipeWire audio support.")
    return None

  session_manager = None
  for binary in ["wireplumber", "pipewire-media-session"]:
    if shutil.which(binary) is not None:
      session_manager = binary
      break

  if session_manager is None:
    logging.warning("No session manager found. Not enabling PipeWire audio"
                    + " support.")
    return None

  return session_manager


def get_wireplumber_version():
  """Returns the WirePlumber version installed on this system, or None if the
  version could not be obtained."""

  try:
    version_output = subprocess.check_output(["wireplumber", "--version"],
                                             universal_newlines=True)
  except subprocess.CalledProcessError as e:
    logging.warning("Failed to execute wireplumber: "  + str(e))
    return None

  match = re.search(r"wireplumber (\S+)$", version_output, re.MULTILINE)
  if not match:
    logging.warning("Failed to determine wireplumber version.")
    return None

  try:
    wireplumber_version = version.parse(match[1])
  except version.InvalidVersion as e:
    logging.warning("Failed to parse wireplumber version: " + str(e))
    return None

  return wireplumber_version


def terminate_process(pid, name):
  """Terminates the process with the given |pid|. Initially sends SIGTERM, but
  falls back to SIGKILL if the process fails to exit after 10 seconds. |name|
  is used for logging. Throws psutil.NoSuchProcess if the pid doesn't exist."""

  logging.info("Sending SIGTERM to %s proc (pid=%s)",
               name, pid)
  try:
    psutil_proc = psutil.Process(pid)
    psutil_proc.terminate()

    # Use a short timeout, to avoid delaying service shutdown if the
    # process refuses to die for some reason.
    psutil_proc.wait(timeout=10)
  except psutil.TimeoutExpired:
    logging.error("Timed out - sending SIGKILL")
    psutil_proc.kill()
  except psutil.Error:
    logging.error("Error terminating process")


def terminate_command_if_running(command_line):
  """Terminate any processes that match |command_line| (including all arguments)
  exactly. Note: this does not attempt to resolve the actual path to the
  executable. As such, arg0 much match exactly."""

  uid = os.getuid()
  this_pid = os.getpid()

  # This function should return the process with the --child-process flag if it
  # exists. If there's only a process without, it might be a legacy process.
  non_child_process = None

  # Support new & old psutil API. This is the right way to check, according to
  # http://grodola.blogspot.com/2014/01/psutil-20-porting.html
  if psutil.version_info >= (2, 0):
    psget = lambda x: x()
  else:
    psget = lambda x: x

  for process in psutil.process_iter():
    # Skip any processes that raise an exception, as processes may terminate
    # during iteration over the list.
    try:
      # Skip other users' processes.
      if psget(process.uids).real != uid:
        continue

      # Skip the current process.
      if process.pid == this_pid:
        continue

      # |cmdline| will be [python-interpreter, script-file, other arguments...]
      if psget(process.cmdline) == command_line:
        terminate_process(process.pid, command_line[0]);

    except (psutil.NoSuchProcess, psutil.AccessDenied):
      continue


class Config:
  def __init__(self, path):
    self.path = path
    self.data = {}
    self.changed = False

  def load(self):
    """Loads the config from file.

    Raises:
      IOError: Error reading data
      ValueError: Error parsing JSON
    """
    settings_file = open(self.path, 'r')
    self.data = json.load(settings_file)
    self.changed = False
    settings_file.close()

  def save(self):
    """Saves the config to file.

    Raises:
      IOError: Error writing data
      TypeError: Error serialising JSON
    """
    if not self.changed:
      return
    old_umask = os.umask(0o066)
    try:
      settings_file = open(self.path, 'w')
      settings_file.write(json.dumps(self.data, indent=2))
      settings_file.close()
      self.changed = False
    finally:
      os.umask(old_umask)

  def save_and_log_errors(self):
    """Calls self.save(), trapping and logging any errors."""
    try:
      self.save()
    except (IOError, TypeError) as e:
      logging.error("Failed to save config: " + str(e))

  def get(self, key, default = None):
    return self.data.get(key, default)

  def __getitem__(self, key):
    return self.data[key]

  def __setitem__(self, key, value):
    self.data[key] = value
    self.changed = True

  def clear(self):
    self.data = {}
    self.changed = True


class Authentication:
  """Manage authentication tokens for the host service account"""

  def __init__(self):
    # Note: Initial values are never used.
    self.service_account = None
    self.oauth_refresh_token = None

  def copy_from(self, config):
    """Loads the config and returns false if the config is invalid."""
    # service_account was added in M120 so hosts which were provisioned using
    # that build (or later) will have the new config key. Hosts which were first
    # configured with an older host version will only have xmpp_login so we need
    # to fallback to it for backward compatibility.
    self.service_account = config.get("service_account")
    if self.service_account is None:
      self.service_account = config.get("xmpp_login")
    if self.service_account is None:
      # Neither service_account nor xmpp_login exist so config is malformed.
      return False

    self.oauth_refresh_token = config.get("oauth_refresh_token")
    if self.oauth_refresh_token is None:
      return False

    return True

  def copy_to(self, config):
    config["xmpp_login"] = self.service_account
    config["service_account"] = self.service_account
    config["oauth_refresh_token"] = self.oauth_refresh_token


class Host:
  """This manages the configuration for a host."""

  def __init__(self):
    # Note: Initial values are never used.
    self.host_id = None
    self.host_name = None
    self.host_secret_hash = None
    self.private_key = None

  def copy_from(self, config):
    try:
      self.host_id = config.get("host_id")
      self.host_name = config["host_name"]
      self.host_secret_hash = config.get("host_secret_hash")
      self.private_key = config["private_key"]
    except KeyError:
      return False
    return bool(self.host_id)

  def copy_to(self, config):
    if self.host_id:
      config["host_id"] = self.host_id
    config["host_name"] = self.host_name
    config["host_secret_hash"] = self.host_secret_hash
    config["private_key"] = self.private_key


class SessionOutputFilterThread(threading.Thread):
  """Reads session log from a pipe and logs the output with the provided prefix
  for amount of time defined by time_limit, or indefinitely if time_limit is
  None."""

  def __init__(self, stream, prefix, time_limit):
    threading.Thread.__init__(self)
    self.stream = stream
    self.daemon = True
    self.prefix = prefix
    self.time_limit = time_limit

  def run(self):
    started_time = time.time()
    is_logging = True
    while True:
      try:
        line = self.stream.readline();
      except IOError as e:
        print("IOError when reading session output: ", e)
        return

      if line == b"":
        # EOF reached. Just stop the thread.
        return

      if not is_logging:
        continue

      if self.time_limit and time.time() - started_time >= self.time_limit:
        is_logging = False
        print("Suppressing rest of the session output.", flush=True)
      else:
        # Pass stream bytes through as is instead of decoding and encoding.
        sys.stdout.buffer.write(self.prefix.encode(sys.stdout.encoding) + line);
        sys.stdout.flush()


class Desktop(abc.ABC):
  """Manage a single virtual desktop"""

  def __init__(self, sizes, host_config, server_inhibitor=None,
               pipewire_inhibitor=None, session_inhibitor=None,
               host_inhibitor=None):
    self.sizes = sizes
    self.host_config = host_config
    self.server_proc = None
    self.pipewire_proc = None
    self.pipewire_pulse_proc = None
    self.pipewire_session_manager = None
    self.pipewire_session_manager_proc = None
    self.pre_session_proc = None
    self.session_proc = None
    self.host_proc = None
    self.child_env = None
    self.host_ready = False
    self.server_inhibitor = server_inhibitor
    self.pipewire_inhibitor = pipewire_inhibitor
    self.session_inhibitor = session_inhibitor
    self.host_inhibitor = host_inhibitor

    self._init_child_env();

    if self.server_inhibitor is None:
      self.server_inhibitor = RelaunchInhibitor("Display server")
    if self.pipewire_inhibitor is None:
      self.pipewire_inhibitor = RelaunchInhibitor("PipeWire")
    if self.session_inhibitor is None:
      self.session_inhibitor = RelaunchInhibitor("session")
    if self.host_inhibitor is None:
      self.host_inhibitor = RelaunchInhibitor("host")
    # Map of inhibitors to the corresponding host offline reason should that
    # session component fail. None indicates that the session component isn't
    # mandatory and its failure should not result in the host shutting down.
    self.inhibitors = {
        self.server_inhibitor: HOST_OFFLINE_REASON_X_SERVER_RETRIES_EXCEEDED,
        self.pipewire_inhibitor: None,
        self.session_inhibitor: HOST_OFFLINE_REASON_SESSION_RETRIES_EXCEEDED,
        self.host_inhibitor: HOST_OFFLINE_REASON_HOST_RETRIES_EXCEEDED
    }
    # Crash reporting is disabled by default.
    self.crash_reporting_enabled = False
    self.crash_uploader_proc = None
    self.crash_uploader_inhibitor = None

  def _init_child_env(self):
    self.child_env = dict(os.environ)

    self.child_env["CHROME_REMOTE_DESKTOP_SESSION"] = "1"

    # We used to create a separate profile/chrome config home for the virtual
    # session since the virtual session was independent of the local session in
    # curtain mode, and using the same Chrome profile between sessions would
    # lead to cross talk issues. This is no longer the case given modern desktop
    # environments don't support running two graphical sessions simultaneously.
    # Therefore, we don't set the env var unless the directory already exists.
    #
    # M61 introduced CHROME_CONFIG_HOME, which allows specifying a different
    # config base path while still using different user data directories for
    # different channels (Stable, Beta, Dev). For existing users who only have
    # chrome-profile, continue using CHROME_USER_DATA_DIR so they don't have to
    # set up their profile again.
    chrome_profile = os.path.join(CONFIG_DIR, "chrome-profile")
    chrome_config_home = os.path.join(CONFIG_DIR, "chrome-config")
    if (os.path.exists(chrome_profile)
        and not os.path.exists(chrome_config_home)):
      self.child_env["CHROME_USER_DATA_DIR"] = chrome_profile
    elif os.path.exists(chrome_config_home):
      self.child_env["CHROME_CONFIG_HOME"] = chrome_config_home

    # Ensure that the software-rendering GL drivers are loaded by the desktop
    # session, instead of any hardware GL drivers installed on the system.
    library_path = (
        "/usr/lib/mesa-diverted/%(arch)s-linux-gnu:"
        "/usr/lib/%(arch)s-linux-gnu/mesa:"
        "/usr/lib/%(arch)s-linux-gnu/dri:"
        "/usr/lib/%(arch)s-linux-gnu/gallium-pipe" %
        { "arch": platform.machine() })

    if "LD_LIBRARY_PATH" in self.child_env:
      library_path += ":" + self.child_env["LD_LIBRARY_PATH"]

    self.child_env["LD_LIBRARY_PATH"] = library_path

  def _setup_gnubby(self):
    self.ssh_auth_sockname = ("/tmp/chromoting.%s.ssh_auth_sock" %
                              os.environ["USER"])
    self.child_env["SSH_AUTH_SOCK"] = self.ssh_auth_sockname

  def _launch_pipewire(self, instance_name, runtime_path, sink_name):
    self.pipewire_session_manager = get_pipewire_session_manager()
    if self.pipewire_session_manager is None:
      return False

    try:
      for config_file in ["pipewire.conf", "pipewire-pulse.conf",
                          self.pipewire_session_manager + ".conf"]:
        with open(os.path.join(SCRIPT_DIR, config_file + ".template"),
                  "r") as infile, \
             open(os.path.join(runtime_path, config_file), "w") as outfile:
          template = string.Template(infile.read())
          outfile.write(template.substitute({
              "instance_name": instance_name,
              "runtime_path": runtime_path,
              "sink_name": sink_name}))

      logging.info("Launching pipewire")
      pipewire_cmd = ["pipewire", "-c",
                      os.path.join(runtime_path, "pipewire.conf")]
      # PulseAudio protocol support is built into PipeWire for the versions we
      # support. Invoking the pipewire binary directly instead of via the
      # pipewire-pulse symlink allows this to work even if the pipewire-pulse
      # package is not installed (e.g., if the user is still using PulseAudio
      # for local sessions).
      pipewire_pulse_cmd = ["pipewire", "-c",
                      os.path.join(runtime_path, "pipewire-pulse.conf")]
      session_manager_cmd = [
          self.pipewire_session_manager, "-c",
          os.path.join(runtime_path, self.pipewire_session_manager + ".conf")]

      # The WirePlumber config template does not work with versions 0.5 or
      # later. Instead, launch it with the system config, and use the
      # customized "chrome-remote-desktop" profile from the installed config
      # fragment.
      if self.pipewire_session_manager.endswith("wireplumber"):
        wireplumber_version = get_wireplumber_version()
        if wireplumber_version is None:
          logging.error("Failed to get WirePlumber version.")
          return False
        if wireplumber_version >= version.parse("0.5"):
          session_manager_cmd = [
              self.pipewire_session_manager,
              "--profile", "chrome-remote-desktop"]

      # Terminate any stale processes before relaunching.
      for command in [pipewire_cmd, pipewire_pulse_cmd, session_manager_cmd]:
        terminate_command_if_running(command)

      self.pipewire_proc = subprocess.Popen(pipewire_cmd, env=self.child_env)
      self.pipewire_pulse_proc = subprocess.Popen(pipewire_pulse_cmd,
                                                  env=self.child_env)

      # Directs native PipeWire clients to the correct instance.
      self.child_env["PIPEWIRE_REMOTE"] = instance_name

      # MEDIA_SESSION_CONFIG_DIR is needed to use an absolute path with
      # pipewire-media-session.
      self.pipewire_session_manager_proc = subprocess.Popen(session_manager_cmd,
          env={**self.child_env, "MEDIA_SESSION_CONFIG_DIR": "/"})

      return True
    except (IOError, OSError) as e:
      logging.error("Failed to start PipeWire: " + str(e))

      # Clean up any processes that did start
      for proc, name in [(self.pipewire_proc, "pipewire"),
                         (self.pipewire_pulse_proc, "pipewire-pulse"),
                         (self.pipewire_session_manager_proc,
                          self.pipewire_session_manager)]:
        if proc is not None:
          terminate_process(proc.pid, name)
      self.pipewire_proc = None
      self.pipewire_pulse_proc = None
      self.pipewire_session_manager_proc = None

    return False

  def _launch_pre_session(self):
    # Launch the pre-session script, if it exists. Returns true if the script
    # was launched, false if it didn't exist.
    if os.path.exists(SYSTEM_PRE_SESSION_FILE_PATH):
      pre_session_command = bash_invocation_for_script(
          SYSTEM_PRE_SESSION_FILE_PATH)

      logging.info("Launching pre-session: %s" % pre_session_command)
      self.pre_session_proc = subprocess.Popen(pre_session_command,
                                               stdin=subprocess.DEVNULL,
                                               stdout=subprocess.PIPE,
                                               stderr=subprocess.STDOUT,
                                               cwd=HOME_DIR,
                                               env=self.child_env)

      if not self.pre_session_proc.pid:
        raise Exception("Could not start pre-session")

      output_filter_thread = SessionOutputFilterThread(
          self.pre_session_proc.stdout, "Pre-session output: ", None)
      output_filter_thread.start()

      return True
    return False

  def launch_session(self, server_args, backoff_time):
    """Launches process required for session and records the backoff time
    for inhibitors so that process restarts are not attempted again until
    that time has passed."""
    logging.info("Setting up and launching session")
    self._setup_gnubby()
    self._launch_server(server_args)
    if not self._launch_pre_session():
      # If there was no pre-session script, launch the session immediately.
      self.launch_desktop_session()
    self.server_inhibitor.record_started(MINIMUM_PROCESS_LIFETIME,
                                      backoff_time)
    self.session_inhibitor.record_started(MINIMUM_PROCESS_LIFETIME,
                                     backoff_time)

  def _wait_for_setup_before_host_launch(self):
    """
    If a virtual desktop needs to do some setup before launching the host
    process, it can override this method and ensure that the required setup is
    done before returning from this process.
    """
    pass

  def launch_host(self, extra_start_host_args, backoff_time):
    self._wait_for_setup_before_host_launch()
    logging.info("Launching host process")

    # Start remoting host
    args = [HOST_BINARY_PATH, "--host-config=-"]
    if self.audio_pipe:
      args.append("--audio-pipe-name=%s" % self.audio_pipe)
    if self.ssh_auth_sockname:
      args.append("--ssh-auth-sockname=%s" % self.ssh_auth_sockname)

    args.extend(extra_start_host_args)

    # Have the host process use SIGUSR1 to signal a successful start.
    def sigusr1_handler(signum, frame):
      _ = signum, frame
      logging.info("Host ready to receive connections.")
      self.host_ready = True
      ParentProcessLogger.release_parent_if_connected(True)

    signal.signal(signal.SIGUSR1, sigusr1_handler)
    args.append("--signal-parent")

    logging.info(args)
    self.host_proc = subprocess.Popen(args, env=self.child_env,
                                      stdin=subprocess.PIPE)
    if not self.host_proc.pid:
      raise Exception("Could not start Chrome Remote Desktop host")

    try:
      self.host_proc.stdin.write(
          json.dumps(self.host_config.data).encode('UTF-8'))
      self.host_proc.stdin.flush()
    except IOError as e:
      # This can occur in rare situations, for example, if the machine is
      # heavily loaded and the host process dies quickly (maybe if the X
      # connection failed), the host process might be gone before this code
      # writes to the host's stdin. Catch and log the exception, allowing
      # the process to be retried instead of exiting the script completely.
      logging.error("Failed writing to host's stdin: " + str(e))
    finally:
      self.host_proc.stdin.close()
    self.host_inhibitor.record_started(MINIMUM_PROCESS_LIFETIME, backoff_time)

  def enable_crash_reporting(self):
    logging.info("Configuring crash reporting")
    self.crash_reporting_enabled = True
    self.crash_uploader_inhibitor = RelaunchInhibitor("Crash uploader")
    self.inhibitors[self.crash_uploader_inhibitor] = (
        HOST_OFFLINE_REASON_CRASH_UPLOADER_RETRIES_EXCEEDED
    )

  def launch_crash_uploader(self, backoff_time):
    if not self.crash_reporting_enabled:
      return

    if not os.path.exists(CRASH_UPLOADER_PATH):
      return

    logging.info("Launching crash uploader")

    args = [CRASH_UPLOADER_PATH]
    self.crash_uploader_proc = subprocess.Popen(args, env=self.child_env)

    if not self.crash_uploader_proc.pid:
      raise Exception("Could not start crash-uploader")

    self.crash_uploader_inhibitor.record_started(MINIMUM_PROCESS_LIFETIME,
                                               backoff_time)

  def cleanup(self):
    """Send SIGTERM to all procs and wait for them to exit. Will fallback to
    SIGKILL if a process doesn't exit within 10 seconds.
    """
    for proc, name in [(self.host_proc, "host"),
                       (self.crash_uploader_proc, "crash-uploader"),
                       (self.session_proc, "session"),
                       (self.pre_session_proc, "pre-session"),
                       (self.pipewire_proc, "pipewire"),
                       (self.pipewire_pulse_proc, "pipewire-pulse"),
                       (self.pipewire_session_manager_proc,
                        self.pipewire_session_manager),
                       (self.server_proc, "display server")]:
      if proc is not None:
        terminate_process(proc.pid, name)
    self.server_proc = None
    self.pipewire_proc = None
    self.pipewire_pulse_proc = None
    self.pipewire_session_manager_proc = None
    self.pre_session_proc = None
    self.session_proc = None
    self.host_proc = None
    self.crash_uploader_proc = None

  def report_offline_reason(self, reason):
    """Attempt to report the specified offline reason to the registry. This
    is best effort, and requires a valid host config.
    """
    logging.info("Attempting to report offline reason: " + reason)
    args = [HOST_BINARY_PATH, "--host-config=-",
            "--report-offline-reason=" + reason]
    proc = subprocess.Popen(args, env=self.child_env, stdin=subprocess.PIPE)
    proc.communicate(json.dumps(self.host_config.data).encode('UTF-8'))

  def on_process_exit(self, pid, status):
    """Checks for which process has exited and whether or not the exit was
    expected. Returns a boolean indicating whether or not tear down of the
    processes is needed."""
    tear_down = False
    pipewire_process = False
    if self.server_proc is not None and pid == self.server_proc.pid:
      logging.info("Display server process terminated")
      self.server_proc = None
      self.server_inhibitor.record_stopped(expected=False)
      tear_down = True

    if (self.pre_session_proc is not None and
        pid == self.pre_session_proc.pid):
      self.pre_session_proc = None
      if status == 0:
        logging.info("Pre-session terminated successfully. Starting session.")
        self.launch_desktop_session()
      else:
        logging.info("Pre-session failed. Tearing down.")
        # The pre-session may have exited on its own or been brought down by
        # the display server dying. Check if the display server is still running
        # so we know whom to penalize.
        if self.check_server_responding():
          # Pre-session and session use the same inhibitor.
          self.session_inhibitor.record_stopped(expected=False)
        else:
          self.server_inhibitor.record_stopped(expected=False)
        # Either way, we want to tear down the session.
        tear_down = True

    if self.pipewire_proc is not None and pid == self.pipewire_proc.pid:
      logging.info("PipeWire process terminated")
      self.pipewire_proc = None
      pipewire_process = True

    if (self.pipewire_pulse_proc is not None
        and pid == self.pipewire_pulse_proc.pid):
      logging.info("PipeWire-Pulse process terminated")
      self.pipewire_pulse_proc = None
      pipewire_process = True

    if (self.pipewire_session_manager_proc is not None
        and pid == self.pipewire_session_manager_proc.pid):
      logging.info(self.pipewire_session_manager + " process terminated")
      self.pipewire_session_manager_proc = None
      pipewire_process = True

    if pipewire_process:
      self.pipewire_inhibitor.record_stopped(expected=False)
      # Terminate other PipeWire-related processes to start fresh.
      for proc, name in [(self.pipewire_proc, "pipewire"),
                         (self.pipewire_pulse_proc, "pipewire-pulse"),
                         (self.pipewire_session_manager_proc,
                          self.pipewire_session_manager)]:
        if proc is not None:
          terminate_process(proc.pid, name)
      self.pipewire_proc = None
      self.pipewire_pulse_proc = None
      self.pipewire_session_manager_proc = None

    if self.session_proc is not None and pid == self.session_proc.pid:
      logging.info("Session process terminated")
      self.session_proc = None
      # The session may have exited on its own or been brought down by the
      # display server dying. Check if the display server is still running so we
      # know whom to penalize.
      if self.check_server_responding():
        self.session_inhibitor.record_stopped(expected=False)
      else:
        self.server_inhibitor.record_stopped(expected=False)
      # Either way, we want to tear down the session.
      tear_down = True

    if self.host_proc is not None and pid == self.host_proc.pid:
      logging.info("Host process terminated")
      self.host_proc = None
      self.host_ready = False

      # These exit-codes must match the ones used by the host.
      # See remoting/host/base/host_exit_codes.h.
      # Delete the host or auth configuration depending on the returned error
      # code, so the next time this script is run, a new configuration
      # will be created and registered.
      if os.WIFEXITED(status):
        if os.WEXITSTATUS(status) == 100:
          logging.info("Host configuration is invalid - exiting.")
          sys.exit(0)
        elif os.WEXITSTATUS(status) == 101:
          logging.info("Host ID has been deleted - exiting.")
          self.host_config.clear()
          self.host_config.save_and_log_errors()
          sys.exit(0)
        elif os.WEXITSTATUS(status) == 102:
          logging.info("OAuth credentials are invalid - exiting.")
          sys.exit(0)
        elif os.WEXITSTATUS(status) == 103:
          logging.info("Host domain is blocked by policy - exiting.")
          sys.exit(0)
        # Nothing to do for Mac-only status 104 (login screen unsupported)
        elif os.WEXITSTATUS(status) == 105:
          logging.info("Username is blocked by policy - exiting.")
          sys.exit(0)
        elif os.WEXITSTATUS(status) == 106:
          logging.info("Host has been deleted - exiting.")
          self.host_config.clear()
          self.host_config.save_and_log_errors()
          sys.exit(0)
        elif os.WEXITSTATUS(status) == 107:
          logging.info("Remote access is disallowed by policy - exiting.")
          sys.exit(0)
        elif os.WEXITSTATUS(status) == 108:
          logging.info("This CPU is not supported - exiting.")
          sys.exit(0)
        else:
          logging.info("Host exited with status %s." % os.WEXITSTATUS(status))
      elif os.WIFSIGNALED(status):
        logging.info("Host terminated by signal %s." % os.WTERMSIG(status))

      # The host may have exited on it's own or been brought down by the display
      # server dying. Check if the display server is still running so we know
      # whom to penalize.
      if self.check_server_responding():
        self.host_inhibitor.record_stopped(expected=False)
      else:
        self.server_inhibitor.record_stopped(expected=False)
        # Only tear down if the display server isn't responding.
        tear_down = True

    if (self.crash_uploader_proc is not None and
            pid == self.crash_uploader_proc.pid):
      logging.info("Crash uploader process terminated")
      self.crash_uploader_proc = None
      self.crash_uploader_inhibitor.record_stopped(expected=False)
      # Don't tear down the host if the uploader is killed or crashes.
      tear_down = False

    return tear_down

  def aggregate_failure_count(self):
    failure_count = 0
    for inhibitor, offline_reason in self.inhibitors.items():
      if inhibitor.running:
        inhibitor.record_stopped(True)
      # Only count mandatory processes
      if offline_reason is not None:
        failure_count += inhibitor.failures
    return failure_count

  def setup_audio(self, host_id, backoff_time):
    """Launches a CRD-specific instance of PipeWire for audio forwarding within
    the session and sets up the restart inhibitor for it, if supported on this
    system. Otherwise, falls back to writing a legacy PulseAudio
    configuration."""
    self.audio_pipe = None

    # PipeWire and PulseAudio uses UNIX sockets for communication. The length of
    # a UNIX socket name is limited to 108 characters, so audio will not work
    # properly if the path is too long. To workaround this problem we use only
    # first 10 symbols (60 bits) of the base64url-encoded hash of the host id.
    suffix = base64.urlsafe_b64encode(hashlib.sha256(
        host_id.encode("utf-8")).digest()).decode("ascii")[0:10]
    runtime_dirname = "crd_audio#%s" % suffix
    pipewire_instance = runtime_dirname + "/pipewire"
    runtime_path = os.path.join(
        xdg.BaseDirectory.get_runtime_dir(strict=False), runtime_dirname)
    if len(runtime_path) + len("/pipewire") >= 108:
      logging.error("Audio will not be enabled because audio UNIX socket path" +
                    " is too long.")
      self.pipewire_inhibitor.disable()
      return

    sink_name = "chrome_remote_desktop_session"
    pipe_name = os.path.join(runtime_path, "fifo_output")

    try:
      if not os.path.exists(runtime_path):
        os.mkdir(runtime_path)
    except IOError as e:
      logging.error("Failed to create audio runtime path: " + str(e))
      self.pipewire_inhibitor.disable()
      return

    self.audio_pipe = pipe_name

    # Used both with PipeWire-Pulse and PulseAudio
    self.child_env["PULSE_RUNTIME_PATH"] = runtime_path
    self.child_env["PULSE_SINK"] = sink_name

    # Configure and launch PipeWire if supported on this system.
    if self._launch_pipewire(pipewire_instance, runtime_path, sink_name):
      self.pipewire_inhibitor.record_started(MINIMUM_PROCESS_LIFETIME,
                                             backoff_time)
      return

    self.pipewire_inhibitor.disable()

    # Used only by the PulseAudio daemon in a legacy setup.
    self.child_env["PULSE_CONFIG_PATH"] = runtime_path
    self.child_env["PULSE_STATE_PATH"] = runtime_path

    # Write a legacy PulseAudio config. This isn't used by PipeWire, but allows
    # users with a legacy configuration without PipeWire where PulseAudio is
    # started by their session to continue functioning.
    try:
      with open(os.path.join(runtime_path, "daemon.conf"), "w") as pulse_config:
        pulse_config.write("default-sample-format = s16le\n")
        pulse_config.write("default-sample-rate = 48000\n")
        pulse_config.write("default-sample-channels = 2\n")

      with open(os.path.join(runtime_path, "default.pa"), "w") as pulse_script:
        pulse_script.write("load-module module-native-protocol-unix\n")
        pulse_script.write(
            ("load-module module-pipe-sink sink_name=%s file=\"%s\" " +
             "rate=48000 channels=2 format=s16le\n") %
            (sink_name, pipe_name))
    except IOError as e:
      logging.error("Failed to write pulseaudio config: " + str(e))

  @abc.abstractmethod
  def launch_desktop_session(self):
    """Start desktop session."""
    pass

  @abc.abstractmethod
  def check_server_responding(self):
    """Checks if the display server is responding to connections."""
    return False


class WaylandDesktop(Desktop):
  """Manage a single virtual wayland based desktop"""

  WL_SOCKET_CHECK_DELAY_SECONDS = 1
  WL_SOCKET_CHECK_TIMEOUT_SECONDS = 5
  WL_SERVER_REPLY_TIMEOUT_SECONDS = 1
  # We scan for the unused socket starting from number 1. If we are not able to
  # find anything between 1 and 100 then we error out since there could be a
  # socket leak and we don't want to keep retrying forever.
  MAX_WAYLAND_SOCKET_NUM = 100

  def __init__(self, sizes, host_config):
    self.debug = False
    self._wayland_socket = None
    self._runtime_dir = None
    super(WaylandDesktop, self).__init__(sizes, host_config)
    self.inhibitors[self.server_inhibitor] \
        = HOST_OFFLINE_REASON_WAYLAND_SERVER_RETRIES_EXCEEDED
    global g_desktop
    assert(g_desktop is None)
    g_desktop = self

  @property
  def runtime_dir(self):
    if not self._runtime_dir:
      self._runtime_dir = RUNTIME_DIR_TEMPLATE % os.getuid()
    return self._runtime_dir

  def _init_child_env(self):
    super(WaylandDesktop, self)._init_child_env()
    self.child_env["GDK_BACKEND"] = "wayland,x11"
    self.child_env["XDG_SESSION_TYPE"] = "wayland"
    self.child_env["XDG_RUNTIME_DIR"] = self.runtime_dir

    if self.debug:
      self.child_env["G_MESSAGES_DEBUG"] = "all"
      self.child_env["GDK_DEBUG"]  = "all"
      self.child_env["G_DEBUG"] = "fatal-criticals"
      self.child_env["WAYLAND_DEBUG"] = "1"

  def _get_unused_wayland_socket(self):
    """
    Return a candidate wayland socket that is not already taken by another
    compositor.
    """
    socket_num = starting_socket_num = 0
    full_sock_path = os.path.join(self.runtime_dir, "wayland-%s" % socket_num)
    while ((os.path.exists(full_sock_path)) and
            socket_num <= self.MAX_WAYLAND_SOCKET_NUM):
      socket_num += 1
      full_sock_path = os.path.join(self.runtime_dir, "wayland-%s" % socket_num)
    if socket_num > self.MAX_WAYLAND_SOCKET_NUM:
      logging.error("Unable to find an unused wayland socket (searched between "
                    "'wayland-%s' to 'wayland-%s' under runtime directory",
                    starting_socket_num,
                    self.MAX_WAYLAND_SOCKET_NUM, self.runtime_dir)
      return None
    return "wayland-%s" % socket_num

  @staticmethod
  def _is_gnome_session_present():
    if not shutil.which(GNOME_SESSION):
      logging.warning("Unable to find '%s' on the host" % GNOME_SESSION)
      return False
    return True

  def _launch_server(self, *args, **kwargs):
    if not self._is_gnome_session_present():
      logging.error("Only GNOME based wayland hosts are supported currently. "
                    "If the host is a GNOME host, please ensure that "
                    "'gnome-shell' is installed on it")
      # Error won't be fixed without user intervention so we quit here without
      # attempting to relaunch.
      sys.exit(1)
    logging.info("Launching wayland server.")
    self._wayland_socket = self._get_unused_wayland_socket()
    if self._wayland_socket is None:
      logging.error("Unable to find unused wayland socket, running compositor "
                    "is going to fail")
      sys.exit(1)
    else:
      self.child_env["WAYLAND_DISPLAY"] = self._wayland_socket
    self.server_proc = subprocess.Popen([GNOME_SESSION],
                                        stdout=subprocess.PIPE,
                                        stderr=subprocess.STDOUT,
                                        env=self.child_env)

    if not self.server_proc.pid:
      raise Exception("Could not start wayland session")

    output_filter_thread = SessionOutputFilterThread(self.server_proc.stdout,
        "Wayland server output: ", SERVER_OUTPUT_TIME_LIMIT_SECONDS)
    output_filter_thread.start()

  def _wait_for_wayland_compositor_running(self):
    """
    Waits for wayland socket to be created by the wayland compositor. Returns
    true if socket is created within the allowed timeout, else false.
    """
    full_socket_path = os.path.join(self.runtime_dir, self._wayland_socket)
    start_time = time.time()
    while not os.path.exists(full_socket_path):
      time_passed = time.time() - start_time
      if time_passed >= self.WL_SOCKET_CHECK_TIMEOUT_SECONDS:
        break
      logging.info("Wayland socket not yet present. Will wait for %s seconds "
                   "for compositor to create it (remaining wait time: %s "
                   "seconds)" %
                   (self.WL_SOCKET_CHECK_DELAY_SECONDS,
                    int(self.WL_SOCKET_CHECK_TIMEOUT_SECONDS - time_passed)))
      time.sleep(self.WL_SOCKET_CHECK_DELAY_SECONDS)
    if not os.path.exists(full_socket_path):
      logging.error("Waited for wayland compositor to create wayland "
                    "socket: %s, but it didn't happen in %s seconds" %
                    (full_socket_path, self.WL_SOCKET_CHECK_TIMEOUT_SECONDS))
      return False
    logging.info("Wayland socket detected in %s seconds: " %
                 str(time.time() - start_time))
    return True

  def launch_desktop_session(self):
    """
    Restarts the portal services so that they can connect to the wayland socket.
    This helps host process to talk to call into the the xdg-desktop-portal
    APIs.
    """
    if not self._wait_for_wayland_compositor_running():
      logging.error("Aborting wayland session since compositor isn't running")
      sys.exit(1)
    logging.info("Wayland compositor is running, restarting the portal "
                 "services now")
    try:
      subprocess.check_output(["systemctl", "--user", "import-environment"],
                              stderr=subprocess.STDOUT,
                              env=self.child_env)
    except subprocess.CalledProcessError as err:
      logging.error("Unable to import env vars into systemd, "
                    "returncode: %s, output: %s" % (err.returncode,
                                                    err.output))
      # Host process will not be functional without these services.
      sys.exit(1)

    try:
      subprocess.check_output(["systemctl", "--user", "restart",
                               "xdg-desktop-portal",
                               "xdg-desktop-portal-gnome",
                               "xdg-desktop-portal-gtk"],
                               stderr=subprocess.STDOUT, env=self.child_env)
    except subprocess.CalledProcessError as err:
      logging.error("Unable to restart portal services on the host, "
                    "returncode: %s, output: %s" % (err.returncode, err.output))
      # Host process will not be functional without these services.
      sys.exit(1)
    logging.info("Done restarting the portal services")

  def _wait_for_setup_before_host_launch(self):
    return self._wait_for_wayland_compositor_running()

  def cleanup(self):
    if self.host_proc is not None:
      logging.info("Sending SIGTERM to host proc (pid=%s)", self.host_proc.pid)
      try:
        psutil_proc = psutil.Process(self.host_proc.pid)
        psutil_proc.terminate()

        # Use a short timeout, to avoid delaying service shutdown if the
        # process refuses to die for some reason.
        psutil_proc.wait(timeout=10)
      except psutil.TimeoutExpired:
        logging.error("Timed out - sending SIGKILL")
        psutil_proc.kill()
      except psutil.Error:
        logging.error("Error terminating process")
      self.host_proc = None

    # We only support gnome-session, which is currently managed by CRD itself.
    logging.info("Executing %s" % GNOME_SESSION_QUIT)
    if shutil.which(GNOME_SESSION_QUIT):
      cleanup_proc = subprocess.Popen(
        [GNOME_SESSION_QUIT, "--force", "--no-prompt"],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        env=self.child_env)
      stdout, stderr = cleanup_proc.communicate()
      if stderr:
        logging.error("Failed to execute %s:\n%s" %
                      (GNOME_SESSION_QUIT, stderr))
      self.session_proc = None
    else:
      logging.warning("No %s found on the system" % GNOME_SESSION_QUIT)

    super(WaylandDesktop, self).cleanup()
    if self._wayland_socket:
      full_socket_path = os.path.join(self.runtime_dir, self._wayland_socket)
      for to_remove in (full_socket_path, "%s.lock" % full_socket_path):
        try:
          os.remove(to_remove)
        except FileNotFoundError:
          pass
      self._wayland_socket = None

  def check_server_responding(self):
    """
    Connects to the server that is listening on the wayland socket.
    If the connection succeeds, it means that the server is still up and
    running.
    """
    try:
      with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
        sock.connect(os.path.join(self.runtime_dir, self._wayland_socket))
        # Asks the server for the global registry object
        # (See: https://wayland-book.com/registry.html)
        sock.sendall(struct.pack("<III", 0x00000001, 0x000C0001, 0x00000002))

        num_bytes_received = 0
        NUM_BYTES_EXPECTED = 32
        # We don't want to wait forever for a reply so we set a timeout here.
        sock.settimeout(self.WL_SERVER_REPLY_TIMEOUT_SECONDS)
        while num_bytes_received < NUM_BYTES_EXPECTED:
            data = sock.recv(NUM_BYTES_EXPECTED)
            if len(data) == 0:  # Expect empty reply if server dies
               break
            num_bytes_received += len(data)
            logging.debug("Wayland server replied with: %s" % data)
        if not num_bytes_received:
          # If we don't receive a reply at all then the server is likely not
          # listening on the socket.
          return False
    except socket.error as err:
        logging.error("Wayland server is not responding: %s" % err)
        return False
    return True


class XDesktop(Desktop):
  """Manage a single virtual X desktop"""

  def __init__(self, sizes, host_config):
    super(XDesktop, self).__init__(sizes, host_config)
    self.xorg_conf = None
    self.audio_pipe = None
    self.server_supports_randr = False
    self.randr_add_sizes = False
    self.ssh_auth_sockname = None
    self.use_xvfb = self.should_use_xvfb()
    global g_desktop
    assert(g_desktop is None)
    g_desktop = self

  @staticmethod
  def should_use_xvfb():
    """Return whether XVFB should be used. This will be true if USE_XVFB_ENV_VAR
    is set, or if installed dependencies can't support Xorg+Dummy. Note that
    this method performs expensive IO so the output should be cached."""

    if USE_XVFB_ENV_VAR in os.environ:
      return True

    # Check if xserver-xorg-video-dummy is up-to-date. Older versions don't
    # support the DUMMY* outputs and can't be used.
    # Unfortunately, dummy_drv.so doesn't seem to have any version info so we
    # have to query the dpkg database.
    try:
      video_dummy_info = subprocess.check_output(
          ['dpkg-query', '-s', 'xserver-xorg-video-dummy'])
      match = re.search(
          br'^Version: (\S+)$', video_dummy_info, re.MULTILINE)
      if not match:
        logging.error('Version line is not found')
        return True
      version = match[1]
      retcode = subprocess.call(
          ['dpkg', '--compare-versions', version, 'ge', '1:0.4.0'])
      if retcode != 0:
        logging.info('xserver-xorg-video-dummy is not up-to-date')
        return True
    except subprocess.CalledProcessError:
      logging.info('xserver-xorg-video-dummy is not installed')
      return True
    except Exception as e:
      logging.warning(
          'Failed to get xserver-xorg-video-dummy version: ' + str(e))

    return False

  @staticmethod
  def get_unused_display_number():
    """Return a candidate display number for which there is currently no
    X Server lock file"""
    display = FIRST_X_DISPLAY_NUMBER
    while os.path.exists(X_LOCK_FILE_TEMPLATE % display):
      display += 1
    return display

  def _init_child_env(self):
    super(XDesktop, self)._init_child_env()
    # Force GDK to use the X11 backend, as otherwise parts of the host that use
    # GTK can end up connecting to an active Wayland display instead of the
    # CRD X11 session.
    self.child_env["GDK_BACKEND"] = "x11"
    self.child_env["XDG_SESSION_TYPE"] = "x11"

  def launch_session(self, *args, **kwargs):
    logging.info("Launching X server and X session.")
    super(XDesktop, self).launch_session(*args, **kwargs)

  # Returns child environment not containing TMPDIR.
  # Certain values of TMPDIR can break the X server (crbug.com/672684), so we
  # want to make sure it isn't set in the environment used to start the server.
  def _x_env(self):
    if "TMPDIR" not in self.child_env:
      return self.child_env
    else:
      env_copy = dict(self.child_env)
      del env_copy["TMPDIR"]
      return env_copy

  def check_server_responding(self):
    """Checks if the X server is responding to connections."""
    exit_code = subprocess.call("xdpyinfo", env=self.child_env,
                                stdout=subprocess.DEVNULL)
    return exit_code == 0

  def _wait_for_x(self):
    # Wait for X to be active.
    for _test in range(20):
      if self.check_server_responding():
        logging.info("X server is active.")
        return
      time.sleep(0.5)

    raise Exception("Could not connect to X server.")

  def _launch_xvfb(self, display, x_auth_file, extra_x_args):
    max_width = max([width for width, height in self.sizes])
    max_height = max([height for width, height in self.sizes])

    logging.info("Starting Xvfb on display :%d" % display)
    screen_option = "%dx%dx24" % (max_width, max_height)
    self.server_proc = subprocess.Popen(
        ["Xvfb", ":%d" % display,
         "-auth", x_auth_file,
         "-nolisten", "tcp",
         "-noreset",
         "-screen", "0", screen_option
        ] + extra_x_args, env=self._x_env())
    if not self.server_proc.pid:
      raise Exception("Could not start Xvfb.")

    self._wait_for_x()

    exit_code = subprocess.call("xrandr", env=self.child_env,
                                stdout=subprocess.DEVNULL,
                                stderr=subprocess.DEVNULL)
    if exit_code == 0:
      # RandR is supported
      self.server_supports_randr = True
      self.randr_add_sizes = True

  def _launch_xorg(self, display, x_auth_file, extra_x_args):
    config_dir = tempfile.mkdtemp(prefix="chrome_remote_desktop_")
    with open(os.path.join(config_dir, "xorg.conf"), "wb") as config_file:
      config_file.write(gen_xorg_config().encode())

    self.server_supports_randr = True
    self.randr_add_sizes = True
    self.xorg_conf = config_file.name

    xorg_binary = "/usr/lib/xorg/Xorg";
    if not os.access(xorg_binary, os.X_OK):
      xorg_binary = "Xorg";

    logging.info("Starting %s on display :%d" % (xorg_binary, display))
    # We use the child environment so the Xorg server picks up the Mesa libGL
    # instead of any proprietary versions that may be installed, thanks to
    # LD_LIBRARY_PATH.
    # Note: This prevents any environment variable the user has set from
    # affecting the Xorg server.
    self.server_proc = subprocess.Popen(
        [xorg_binary, ":%d" % display,
         "-auth", x_auth_file,
         "-nolisten", "tcp",
         "-noreset",
         # Disable logging to a file and instead bump up the stderr verbosity
         # so the equivalent information gets logged in our main log file.
         "-logfile", "/dev/null",
         "-verbose", "3",
         "-configdir", config_dir,
         # Pass a non-existent file, to prevent Xorg from reading the default
         # config file: /etc/X11/xorg.conf
         "-config", os.path.join(config_dir, "none")
        ] + extra_x_args, env=self._x_env())
    if not self.server_proc.pid:
      raise Exception("Could not start Xorg.")
    self._wait_for_x()

  def _launch_server(self, extra_x_args):
    x_auth_file = os.path.expanduser("~/.Xauthority")
    self.child_env["XAUTHORITY"] = x_auth_file
    display = self.get_unused_display_number()

    # Run "xauth add" with |child_env| so that it modifies the same XAUTHORITY
    # file which will be used for the X session.
    exit_code = subprocess.call("xauth add :%d . `mcookie`" % display,
                                env=self.child_env, shell=True)
    if exit_code != 0:
      raise Exception("xauth failed with code %d" % exit_code)

    # Disable the Composite extension iff the X session is the default
    # Unity-2D, since it uses Metacity which fails to generate DAMAGE
    # notifications correctly. See crbug.com/166468.
    x_session = choose_x_session()
    if (len(x_session) == 2 and
        x_session[1] == "/usr/bin/gnome-session --session=ubuntu-2d"):
      extra_x_args.extend(["-extension", "Composite"])

    self.child_env["DISPLAY"] = ":%d" % display

    if self.use_xvfb:
      self._launch_xvfb(display, x_auth_file, extra_x_args)
    else:
      self._launch_xorg(display, x_auth_file, extra_x_args)

    # The remoting host expects the server to use "evdev" keycodes, but Xvfb
    # starts configured to use the "base" ruleset, resulting in XKB configuring
    # for "xfree86" keycodes, and screwing up some keys. See crbug.com/119013.
    # Reconfigure the X server to use "evdev" keymap rules.  The X server must
    # be started with -noreset otherwise it'll reset as soon as the command
    # completes, since there are no other X clients running yet.
    exit_code = subprocess.call(["setxkbmap", "-rules", "evdev"],
                                env=self.child_env)
    if exit_code != 0:
      logging.error("Failed to set XKB to 'evdev'")

    if not self.server_supports_randr:
      return

    # Register the screen sizes with RandR, if needed.  Errors here are
    # non-fatal; the X server will continue to run with the dimensions from
    # the "-screen" option.
    if self.randr_add_sizes:
      refresh_rates = ["60"]
      try:
        proc_num = subprocess.check_output("nproc", universal_newlines=True)

        # Keep the proc_num logic in sync with desktop_resizer_x11.cc
        if (int(proc_num) > 16):
          refresh_rates.append("120")
      except (ValueError, OSError, subprocess.CalledProcessError) as e:
        logging.error("Failed to retrieve processor count: " + str(e))

      output_names = (
          ["screen"]
          if self.use_xvfb
          else ["DUMMY0","DUMMY1","DUMMY2","DUMMY3"])

      for output_name in output_names:
        for refresh_rate in refresh_rates:
          for width, height in self.sizes:
            # This sets dot-clock, vtotal and htotal such that the computed
            # refresh-rate will have a realistic value:
            # refresh rate = dot-clock / (vtotal * htotal).
            label = "%dx%d_%s" % (width, height, refresh_rate)
            args = ["xrandr", "--newmode", label, refresh_rate, str(width), "0",
                    "0", "1000", str(height), "0", "0", "1000"]
            subprocess.call(args, env=self.child_env, stdout=subprocess.DEVNULL,
                            stderr=subprocess.DEVNULL)
            args = ["xrandr", "--addmode", output_name, label]
            subprocess.call(args, env=self.child_env, stdout=subprocess.DEVNULL,
                            stderr=subprocess.DEVNULL)

    # Set the initial mode to the first size specified, otherwise the X server
    # would default to (max_width, max_height), which might not even be in the
    # list.
    initial_size = self.sizes[0]
    label = "%dx%d" % initial_size
    args = ["xrandr", "-s", label]
    subprocess.call(args, env=self.child_env, stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL)

    # Set the physical size of the display so that the initial mode is running
    # at approximately 96 DPI, since some desktops require the DPI to be set
    # to something realistic.
    args = ["xrandr", "--dpi", "96"]
    subprocess.call(args, env=self.child_env, stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL)

    if self.use_xvfb:
      # Monitor for any automatic resolution changes from the desktop
      # environment. This is needed only for Xvfb sessions because Xvfb sets
      # the first mode to be the maximum supported resolution, and some
      # desktop-environments would mistakenly set this as the preferred mode,
      # leading to a huge desktop with tiny text. With Xorg, the modes are
      # all reasonably sized, so the problem doesn't occur.
      args = [SCRIPT_PATH, "--watch-resolution", str(initial_size[0]),
              str(initial_size[1])]

      # It is not necessary to wait() on the process here, as this script's main
      # loop will reap the exit-codes of all child processes.
      subprocess.Popen(args, env=self.child_env, stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL)

  def launch_desktop_session(self):
    # Start desktop session.
    # The /dev/null input redirection is necessary to prevent the X session
    # reading from stdin.  If this code runs as a shell background job in a
    # terminal, any reading from stdin causes the job to be suspended.
    # Daemonization would solve this problem by separating the process from the
    # controlling terminal.
    xsession_command = choose_x_session()
    if xsession_command is None:
      raise Exception("Unable to choose suitable X session command.")

    logging.info("Launching X session: %s" % xsession_command)
    self.session_proc = subprocess.Popen(xsession_command,
                                         stdin=subprocess.DEVNULL,
                                         stdout=subprocess.PIPE,
                                         stderr=subprocess.STDOUT,
                                         cwd=HOME_DIR,
                                         env=self.child_env)

    if not self.session_proc.pid:
      raise Exception("Could not start X session")

    output_filter_thread = SessionOutputFilterThread(self.session_proc.stdout,
        "Session output: ", SESSION_OUTPUT_TIME_LIMIT_SECONDS)
    output_filter_thread.start()


def parse_config_arg(args):
  """Parses only the --config option from a given command-line.

  Returns:
    A two-tuple. The first element is the value of the --config option (or None
    if it is not specified), and the second is a list containing the remaining
    arguments
  """

  # By default, argparse will exit the program on error. We would like it not to
  # do that.
  class ArgumentParserError(Exception):
    pass
  class ThrowingArgumentParser(argparse.ArgumentParser):
    def error(self, message):
      raise ArgumentParserError(message)

  parser = ThrowingArgumentParser()
  parser.add_argument("--config", nargs='?', action="store")

  try:
    result = parser.parse_known_args(args)
    return (result[0].config, result[1])
  except ArgumentParserError:
    return (None, list(args))


def get_daemon_proc(config_file, require_child_process=False):
  """Checks if there is already an instance of this script running against
  |config_file|, and returns a psutil.Process instance for it. If
  |require_child_process| is true, only check for an instance with the
  --child-process flag specified.

  If a process is found without --config in the command line, get_daemon_proc
  will fall back to the old behavior of checking whether the script path matches
  the current script. This is to facilitate upgrades from previous versions.

  Returns:
    A Process instance for the existing daemon process, or None if the daemon
    is not running.
  """

  # Note: When making changes to how instances are detected, it is imperative
  # that this function retains the ability to find older versions. Otherwise,
  # upgrades can leave the user with two running sessions, with confusing
  # results.

  uid = os.getuid()
  this_pid = os.getpid()

  # This function should return the process with the --child-process flag if it
  # exists. If there's only a process without, it might be a legacy process.
  non_child_process = None

  # Support new & old psutil API. This is the right way to check, according to
  # http://grodola.blogspot.com/2014/01/psutil-20-porting.html
  if psutil.version_info >= (2, 0):
    psget = lambda x: x()
  else:
    psget = lambda x: x

  for process in psutil.process_iter():
    # Skip any processes that raise an exception, as processes may terminate
    # during iteration over the list.
    try:
      # Skip other users' processes.
      if psget(process.uids).real != uid:
        continue

      # Skip the process for this instance.
      if process.pid == this_pid:
        continue

      # |cmdline| will be [python-interpreter, script-file, other arguments...]
      cmdline = psget(process.cmdline)
      if len(cmdline) < 2:
        continue
      if (os.path.basename(cmdline[0]).startswith('python') and
          os.path.basename(cmdline[1]) == os.path.basename(sys.argv[0]) and
          "--start" in cmdline):
        process_config = parse_config_arg(cmdline[2:])[0]

        # Fall back to old behavior if there is no --config argument
        # TODO(rkjnsn): Consider removing this fallback once sufficient time
        # has passed.
        if process_config == config_file or (process_config is None and
                                             cmdline[1] == sys.argv[0]):
          if "--child-process" in cmdline:
            return process
          else:
            non_child_process = process

    except (psutil.NoSuchProcess, psutil.AccessDenied):
      continue

  return non_child_process if not require_child_process else None


def bash_invocation_for_script(script):
  """Chooses the appropriate bash command to run the provided script."""
  if os.path.exists(script):
    if os.access(script, os.X_OK):
      # "/bin/sh -c" is smart about how to execute the session script and
      # works in cases where plain exec() fails (for example, if the file is
      # marked executable, but is a plain script with no shebang line).
      return ["/bin/sh", "-c", shlex.quote(script)]
    else:
      # If this is a system-wide session script, it should be run using the
      # system shell, ignoring any login shell that might be set for the
      # current user.
      return ["/bin/sh", script]

def choose_x_session():
  """Chooses the most appropriate X session command for this system.

  Returns:
    A string containing the command to run, or a list of strings containing
    the executable program and its arguments, which is suitable for passing as
    the first parameter of subprocess.Popen().  If a suitable session cannot
    be found, returns None.
  """
  XSESSION_FILES = [
    SESSION_FILE_PATH,
    SYSTEM_SESSION_FILE_PATH ]
  for startup_file in XSESSION_FILES:
    startup_file = os.path.expanduser(startup_file)
    if os.path.exists(startup_file):
      return bash_invocation_for_script(startup_file)

  # If there's no configuration, show the user a session chooser.
  return [HOST_BINARY_PATH, "--type=xsession_chooser"]

class ParentProcessLogger(object):
  """Redirects logs to the parent process, until the host is ready or quits.

  This class creates a pipe to allow logging from the daemon process to be
  copied to the parent process. The daemon process adds a log-handler that
  directs logging output to the pipe. The parent process reads from this pipe
  and writes the content to stderr. When the pipe is no longer needed (for
  example, the host signals successful launch or permanent failure), the daemon
  removes the log-handler and closes the pipe, causing the the parent process
  to reach end-of-file while reading the pipe and exit.

  The file descriptor for the pipe to the parent process should be passed to
  the constructor. The (grand-)child process should call start_logging() when
  it starts, and then use logging.* to issue log statements, as usual. When the
  child has either succesfully started the host or terminated, it must call
  release_parent() to allow the parent to exit.
  """

  __instance = None

  def __init__(self, write_fd):
    """Constructor.

    Constructs the singleton instance of ParentProcessLogger. This should be
    called at most once.

    write_fd: The write end of the pipe created by the parent process. If
              write_fd is not a valid file descriptor, the constructor will
              throw either IOError or OSError.
    """
    # Ensure write_pipe is closed on exec, otherwise it will be kept open by
    # child processes (X, host), preventing the read pipe from EOF'ing.
    old_flags = fcntl.fcntl(write_fd, fcntl.F_GETFD)
    fcntl.fcntl(write_fd, fcntl.F_SETFD, old_flags | fcntl.FD_CLOEXEC)
    self._write_file = os.fdopen(write_fd, 'w')
    self._logging_handler = None
    ParentProcessLogger.__instance = self

  def _start_logging(self):
    """Installs a logging handler that sends log entries to a pipe, prefixed
    with the string 'MSG:'. This allows them to be distinguished by the parent
    process from commands sent over the same pipe.

    Must be called by the child process.
    """
    self._logging_handler = logging.StreamHandler(self._write_file)
    self._logging_handler.setFormatter(logging.Formatter(fmt='MSG:%(message)s'))
    logging.getLogger().addHandler(self._logging_handler)

  def _release_parent(self, success):
    """Uninstalls logging handler and closes the pipe, releasing the parent.

    Must be called by the child process.

    success: If true, write a "host ready" message to the parent process before
             closing the pipe.
    """
    if self._logging_handler:
      logging.getLogger().removeHandler(self._logging_handler)
      self._logging_handler = None
    if not self._write_file.closed:
      if success:
        try:
          self._write_file.write("READY\n")
          self._write_file.flush()
        except IOError:
          # A "broken pipe" IOError can happen if the receiving process
          # (remoting_user_session) has exited (probably due to timeout waiting
          # for the host to start).
          # Trapping the error here means the host can continue running.
          logging.info("Caught IOError writing READY message.")
      try:
        self._write_file.close()
      except IOError:
        pass

  @staticmethod
  def try_start_logging(write_fd):
    """Attempt to initialize ParentProcessLogger and start forwarding log
    messages.

    Returns False if the file descriptor was invalid (safe to ignore).
    """
    try:
      ParentProcessLogger(USER_SESSION_MESSAGE_FD)._start_logging()
      return True
    except (IOError, OSError):
      # One of these will be thrown if the file descriptor is invalid, such as
      # if the the fd got closed by the login shell. In that case, just continue
      # without sending log messages.
      return False

  @staticmethod
  def release_parent_if_connected(success):
    """If ParentProcessLogger is active, stop logging and release the parent.

    success: If true, signal to the parent that the script was successful.
    """
    instance = ParentProcessLogger.__instance
    if instance is not None:
      ParentProcessLogger.__instance = None
      instance._release_parent(success)


def run_command_with_group(command, group):
  """Run a command with a different primary group."""

  # This is implemented using sg, which is an odd character and will try to
  # prompt for a password if it can't verify the user is a member of the given
  # group, along with in a few other corner cases. (It will prompt in the
  # non-member case even if the group doesn't have a password set.)
  #
  # To prevent sg from prompting the user for a password that doesn't exist,
  # redirect stdin and detach sg from the TTY. It will still print something
  # like "Password: crypt: Invalid argument", so redirect stdout and stderr, as
  # well. Finally, have the shell unredirect them when executing user-session.
  #
  # It is also desirable to have some way to tell whether any errors are
  # from sg or the command, which is done using a pipe.

  def pre_exec(read_fd, write_fd):
    os.close(read_fd)

    # /bin/sh may be dash, which only allows redirecting file descriptors 0-9,
    # the minimum required by POSIX. Since there may be files open elsewhere,
    # move the relevant file descriptors to specific numbers under that limit.
    # Because this runs in the child process, it doesn't matter if existing file
    # descriptors are closed in the process. After, stdio will be redirected to
    # /dev/null, write_fd will be moved to 6, and the old stdio will be moved
    # to 7, 8, and 9.
    if (write_fd != 6):
      os.dup2(write_fd, 6)
      os.close(write_fd)
    os.dup2(0, 7)
    os.dup2(1, 8)
    os.dup2(2, 9)
    devnull = os.open(os.devnull, os.O_RDWR)
    os.dup2(devnull, 0)
    os.dup2(devnull, 1)
    os.dup2(devnull, 2)
    os.close(devnull)

    # os.setsid will detach subprocess from the TTY
    os.setsid()

  # Pipe to check whether sg successfully ran our command.
  read_fd, write_fd = os.pipe()
  try:
    # sg invokes the provided argument using /bin/sh. In that shell, first write
    # "success\n" to the pipe, which is checked later to determine whether sg
    # itself succeeded, and then restore stdio, close the extra file
    # descriptors, and exec the provided command.
    process = subprocess.Popen(
        ["sg", group,
         "echo success >&6; exec {command} "
           # Restore original stdio
           "0<&7 1>&8 2>&9 "
           # Close no-longer-needed file descriptors
           "6>&- 7<&- 8>&- 9>&-"
           .format(command=" ".join(map(shlex.quote, command)))],
        # It'd be nice to use pass_fds instead close_fds=False. Unfortunately,
        # pass_fds doesn't seem usable with remapping. It runs after preexec_fn,
        # which does the remapping, but complains if the specified fds don't
        # exist ahead of time.
        close_fds=False, preexec_fn=lambda: pre_exec(read_fd, write_fd))
    result = process.wait()
  except OSError as e:
    logging.error("Failed to execute sg: {}".format(e.strerror))
    if e.errno == errno.ENOENT:
      result = COMMAND_NOT_FOUND_EXIT_CODE
    else:
      result = COMMAND_NOT_EXECUTABLE_EXIT_CODE
    # Skip pipe check, since sg was never executed.
    os.close(read_fd)
    return result
  except KeyboardInterrupt:
    # Because sg is in its own session, it won't have gotten the interrupt.
    try:
      os.killpg(os.getpgid(process.pid), signal.SIGINT)
      result = process.wait()
    except OSError:
      logging.warning("Command may still be running")
      result = 1
  finally:
    os.close(write_fd)

  with os.fdopen(read_fd) as read_file:
    contents = read_file.read()
  if contents != "success\n":
    # No success message means sg didn't execute the command. (Maybe the user
    # is not a member of the group?)
    logging.error("Failed to access {} group. Is the user a member?"
                  .format(group))
    result = COMMAND_NOT_EXECUTABLE_EXIT_CODE

  return result


def run_command_as_root(command):
  if os.getenv("DISPLAY"):
    # TODO(rickyz): Add a Polkit policy that includes a more friendly
    # message about what this command does.
    command = ["/usr/bin/pkexec"] + command
  else:
    command = ["/usr/bin/sudo", "-k", "--"] + command

  return subprocess.call(command)


def exec_self_via_login_shell():
  """Attempt to run the user's login shell and run this script under it. This
  will allow the user's ~/.profile or similar to be processed, which may set
  environment variables to configure Chrome Remote Desktop."""
  args = [sys.argv[0], "--child-process"] + [arg for arg in sys.argv[1:]
                                             if arg != "--new-session"]
  try:
    shell = os.getenv("SHELL")

    if shell is not None:
      # Shells consider themselves a login shell if arg0 starts with a '-'.
      shell_arg0 = "-" + os.path.basename(shell)

      # First, ensure we can execute commands via the user's login shell. Some
      # users have an incorrect .profile or similar that breaks this.
      output = subprocess.check_output(
          [shell_arg0], executable=shell,
          input=b"exec echo CRD_SHELL_TEST_OUTPUT",
          timeout=15)

      if b"CRD_SHELL_TEST_OUTPUT" in output:
        # subprocess doesn't support calling exec without fork, so we need to
        # set up our pipe manually.
        read_fd, write_fd = os.pipe()
        # The command line should easily fit in the 16KiB pipe buffer.
        os.write(
            write_fd,
            b"exec " + os.fsencode(" ".join(map(shlex.quote, args))))
        os.close(write_fd)
        os.dup2(read_fd, 0)
        os.close(read_fd)
        os.execv(shell, [shell_arg0])
      else:
        logging.warning("Login shell doesn't execute standard input.")
    else:
      logging.warning("SHELL envirionment variable not set.")
  except Exception as e:
    logging.warning(str(e))

  logging.warning(
      "Failed to run via login shell; continuing without. Environment "
      "variables set via ~/.profile or similar won't be processed.")
  os.execv(args[0], args)


def start_via_user_session(foreground):
  # We need to invoke user-session
  command = [USER_SESSION_PATH, "start"]
  if foreground:
    command += ["--foreground"]
  command += ["--"] + sys.argv[1:]
  try:
    process = subprocess.Popen(command)
    result = process.wait()
  except OSError as e:
    if e.errno == errno.EACCES:
      # User may have just been added to the CRD group, in which case they
      # won't be able to execute user-session directly until they log out and
      # back in. In the mean time, we can try to switch to the CRD group and
      # execute user-session.
      result = run_command_with_group(command, CHROME_REMOTING_GROUP_NAME)
    else:
      logging.error("Could not execute {}: {}"
                    .format(USER_SESSION_PATH, e.strerror))
      if e.errno == errno.ENOENT:
        result = COMMAND_NOT_FOUND_EXIT_CODE
      else:
        result = COMMAND_NOT_EXECUTABLE_EXIT_CODE
  except KeyboardInterrupt:
    # Child will have also gotten the interrupt. Wait for it to exit.
    result = process.wait()

  return result


def cleanup():
  logging.info("Cleanup.")

  global g_desktop
  if g_desktop is not None:
    g_desktop.cleanup()
    if getattr(g_desktop, 'xorg_conf', None) is not None:
      os.remove(g_desktop.xorg_conf)
      os.rmdir(os.path.dirname(g_desktop.xorg_conf))

  g_desktop = None
  ParentProcessLogger.release_parent_if_connected(False)


class SignalHandler:
  """Reload the config file on SIGHUP. Since we pass the configuration to the
  host processes via stdin, they can't reload it, so terminate them. They will
  be relaunched automatically with the new config."""

  def __init__(self, host_config):
    self.host_config = host_config

  def __call__(self, signum, _stackframe):
    logging.info("Caught signal: " + str(signum))
    if signum == signal.SIGHUP:
      logging.info("SIGHUP caught, restarting host.")
      try:
        self.host_config.load()
      except (IOError, ValueError) as e:
        logging.error("Failed to load config: " + str(e))
      if g_desktop is not None and g_desktop.host_proc:
        g_desktop.host_proc.send_signal(signal.SIGTERM)
    else:
      # Exit cleanly so the atexit handler, cleanup(), gets called.
      raise SystemExit


class RelaunchInhibitor:
  """Helper class for inhibiting launch of a child process before a timeout has
  elapsed.

  A managed process can be in one of these states:
    running, not inhibited (running == True)
    stopped and inhibited (running == False and is_inhibited() == True)
    stopped but not inhibited (running == False and is_inhibited() == False)

  Attributes:
    label: Name of the tracked process. Only used for logging.
    running: Whether the process is currently running.
    earliest_relaunch_time: Time before which the process should not be
      relaunched, or 0 if there is no limit.
    failures: The number of times that the process ran for less than a
      specified timeout, and had to be inhibited.  This count is reset to 0
      whenever the process has run for longer than the timeout.
  """

  def __init__(self, label):
    self.label = label
    self.running = False
    self.disabled = False
    self.earliest_relaunch_time = 0
    self.earliest_successful_termination = 0
    self.failures = 0

  def is_inhibited(self):
    return (not self.running) and (time.time() < self.earliest_relaunch_time)

  def record_started(self, minimum_lifetime, relaunch_delay):
    """Record that the process was launched, and set the inhibit time to
    |timeout| seconds in the future."""
    self.earliest_relaunch_time = time.time() + relaunch_delay
    self.earliest_successful_termination = time.time() + minimum_lifetime
    self.running = True

  def record_stopped(self, expected):
    """Record that the process was stopped, and adjust the failure count
    depending on whether the process ran long enough. If the process was
    intentionally stopped (expected is True), the failure count will not be
    incremented."""
    self.running = False
    if time.time() >= self.earliest_successful_termination:
      self.failures = 0
    elif not expected:
      self.failures += 1
    logging.info("Failure count for '%s' is now %d", self.label, self.failures)

  def disable(self):
    """Disable launching this process, such as if the needed components are
    missing and launching it is never expected to succeed. Only makes sense for
    non-critical processes. (Otherwise, the script should just bail.)"""
    self.disabled = True


def relaunch_self():
  """Relaunches the session to pick up any changes to the session logic in case
  Chrome Remote Desktop has been upgraded. We return a special exit code to
  inform user-session that it should relaunch.
  """

  # cleanup run via atexit
  sys.exit(RELAUNCH_EXIT_CODE)


def waitpid_with_timeout(pid, deadline):
  """Wrapper around os.waitpid() which waits until either a child process dies
  or the deadline elapses.

  Args:
    pid: Process ID to wait for, or -1 to wait for any child process.
    deadline: Waiting stops when time.time() exceeds this value.

  Returns:
    (pid, status): Same as for os.waitpid(), except that |pid| is 0 if no child
    changed state within the timeout.

  Raises:
    Same as for os.waitpid().
  """
  while time.time() < deadline:
    pid, status = os.waitpid(pid, os.WNOHANG)
    if pid != 0:
      return (pid, status)
    time.sleep(1)
  return (0, 0)


def waitpid_handle_exceptions(pid, deadline):
  """Wrapper around os.waitpid()/waitpid_with_timeout(), which waits until
  either a child process exits or the deadline elapses, and retries if certain
  exceptions occur.

  Args:
    pid: Process ID to wait for, or -1 to wait for any child process.
    deadline: If non-zero, waiting stops when time.time() exceeds this value.
      If zero, waiting stops when a child process exits.

  Returns:
    (pid, status): Same as for waitpid_with_timeout(). |pid| is non-zero if and
    only if a child exited during the wait.

  Raises:
    Same as for os.waitpid(), except:
      OSError with errno==EINTR causes the wait to be retried (this can happen,
      for example, if this parent process receives SIGHUP).
      OSError with errno==ECHILD means there are no child processes, and so
      this function sleeps until |deadline|. If |deadline| is zero, this is an
      error and the OSError exception is raised in this case.
  """
  while True:
    try:
      if deadline == 0:
        pid_result, status = os.waitpid(pid, 0)
      else:
        pid_result, status = waitpid_with_timeout(pid, deadline)
      return (pid_result, status)
    except OSError as e:
      if e.errno == errno.EINTR:
        continue
      elif e.errno == errno.ECHILD:
        now = time.time()
        if deadline == 0:
          # No time-limit and no child processes. This is treated as an error
          # (see docstring).
          raise
        elif deadline > now:
          time.sleep(deadline - now)
        return (0, 0)
      else:
        # Anything else is an unexpected error.
        raise


def watch_for_resolution_changes(initial_size):
  """Watches for any resolution-changes which set the maximum screen resolution,
  and resets the initial size if this happens.

  The Ubuntu desktop has a component (the 'xrandr' plugin of
  unity-settings-daemon) which often changes the screen resolution to the
  first listed mode. This is the built-in mode for the maximum screen size,
  which can trigger excessive CPU usage in some situations. So this is a hack
  which waits for any such events, and undoes the change if it occurs.

  Sometimes, the user might legitimately want to use the maximum available
  resolution, so this monitoring is limited to a short time-period.
  """
  for _ in range(30):
    time.sleep(1)

    xrandr_output = subprocess.Popen(["xrandr"],
                                     stdout=subprocess.PIPE).communicate()[0]
    match = re.search(br'current (\d+) x (\d+), maximum (\d+) x (\d+)',
                      xrandr_output)

    # No need to handle ValueError. If xrandr fails to give valid output,
    # there's no point in continuing to monitor.
    current_size = (int(match.group(1)), int(match.group(2)))
    maximum_size = (int(match.group(3)), int(match.group(4)))

    if current_size != initial_size:
      # Resolution change detected.
      if current_size == maximum_size:
        # This was probably an automated change from unity-settings-daemon, so
        # undo it.
        label = "%dx%d" % initial_size
        args = ["xrandr", "-s", label]
        subprocess.call(args)
        args = ["xrandr", "--dpi", "96"]
        subprocess.call(args)

      # Stop monitoring after any change was detected.
      break


def setup_argument_parser():
  EPILOG = """This script is not intended for use by end-users. To configure
Chrome Remote Desktop, please install the app from the Chrome
Web Store: https://chrome.google.com/remotedesktop"""
  parser = argparse.ArgumentParser(
      usage="Usage: %(prog)s [options] [ -- [ X server options ] ]",
      epilog=EPILOG)
  parser.add_argument("-s", "--size", dest="size", action="append",
                      help="Dimensions of virtual desktop. This can be "
                      "specified multiple times to make multiple screen "
                      "resolutions available (if the X server supports this).")
  parser.add_argument("-f", "--foreground", dest="foreground", default=False,
                      action="store_true",
                      help="Don't run as a background daemon.")
  parser.add_argument("--start", dest="start", default=False,
                      action="store_true",
                      help="Start the host.")
  parser.add_argument("-k", "--stop", dest="stop", default=False,
                      action="store_true",
                      help="Stop the daemon currently running.")
  parser.add_argument("--get-status", dest="get_status", default=False,
                      action="store_true",
                      help="Prints host status")
  parser.add_argument("--check-running", dest="check_running",
                      default=False, action="store_true",
                      help="Return 0 if the daemon is running, or 1 otherwise.")
  parser.add_argument("--config", dest="config", action="store",
                      help="Use the specified configuration file.")
  parser.add_argument("--reload", dest="reload", default=False,
                      action="store_true",
                      help="Signal currently running host to reload the "
                      "config.")
  parser.add_argument("--enable-and-start", dest="enable_and_start",
                      default=False, action="store_true",
                      help="Enable and start chrome-remote-desktop for the "
                      "current user.")
  parser.add_argument("--add-user-as-root", dest="add_user_as_root",
                      action="store", metavar="USER",
                      help="Adds the specified user to the "
                      "chrome-remote-desktop group (must be run as root).")
  # The script is being run as a child process under the user-session binary.
  # Don't daemonize and use the inherited environment.
  parser.add_argument("--child-process", dest="child_process", default=False,
                      action="store_true",
                      help=argparse.SUPPRESS)
  # The script is being run in a new PAM session. Don't daemonize so the parent
  # knows when to clean up the PAM session, and attempt to exec a login shell to
  # allow the user's ~/.profile or similar to run.
  parser.add_argument("--new-session", dest="new_session", default=False,
                      action="store_true",
                      help=argparse.SUPPRESS)
  parser.add_argument("--watch-resolution", dest="watch_resolution",
                      type=int, nargs=2, default=False, action="store",
                      help=argparse.SUPPRESS)
  parser.add_argument(dest="args", nargs="*", help=argparse.SUPPRESS)
  return parser


def main():
  parser = setup_argument_parser()
  options = parser.parse_args()

  # Determine the filename of the host configuration.
  if options.config:
    config_file = options.config
  else:
    config_file = os.path.join(CONFIG_DIR, "host#%s.json" % g_host_hash)
  config_file = os.path.realpath(config_file)

  # Check for a modal command-line option (start, stop, etc.)
  if options.get_status:
    proc = get_daemon_proc(config_file)
    if proc is not None:
      print("STARTED")
    elif is_supported_platform():
      print("STOPPED")
    else:
      print("NOT_IMPLEMENTED")
    return 0

  # TODO(sergeyu): Remove --check-running once NPAPI plugin and NM host are
  # updated to always use get-status flag instead.
  if options.check_running:
    proc = get_daemon_proc(config_file)
    return 1 if proc is None else 0

  if options.stop:
    proc = get_daemon_proc(config_file)
    if proc is None:
      print("The daemon is not currently running")
    else:
      print("Killing process %s" % proc.pid)
      proc.terminate()
      try:
        proc.wait(timeout=30)
      except psutil.TimeoutExpired:
        print("Timed out trying to kill daemon process")
        return 1
    return 0

  if options.reload:
    proc = get_daemon_proc(config_file)
    if proc is None:
      return 1
    proc.send_signal(signal.SIGHUP)
    return 0

  if options.enable_and_start:
    user = getpass.getuser()

    if os.path.isdir("/run/systemd/system"):
      # While systemd will generally prompt for a password via polkit if run by
      # a normal user, it won't properly fall back to prompting on the TTY if
      # stdin is redirected, such as is done by the start-host binary.
      # Additionally, some configurations can result in systemctl prompting the
      # user for their password multiple times, which can be confusing and
      # annoying. Running it as root avoids both issues.
      return run_command_as_root(["systemctl", "enable", "--now",
                                  "chrome-remote-desktop@" + user])
    else:
      try:
        if user in grp.getgrnam(CHROME_REMOTING_GROUP_NAME).gr_mem:
          logging.info("User '%s' is already a member of '%s'." %
                       (user, CHROME_REMOTING_GROUP_NAME))
          return 0
      except KeyError:
        logging.info("Group '%s' not found." % CHROME_REMOTING_GROUP_NAME)

      if run_command_as_root([SCRIPT_PATH, '--add-user-as-root', user]) != 0:
        logging.error("Failed to add user to group")
        return 1

      # Replace --enable-and-start with --start in the command-line arguments,
      # which are used later to reinvoke the script as a child of user-session.
      sys.argv = [arg if arg != "--enable-and-start" else "--start"
                  for arg in sys.argv]
      options.start = True

  if options.add_user_as_root is not None:
    if os.getuid() != 0:
      logging.error("--add-user-as-root can only be specified as root.")
      return 1;

    user = options.add_user_as_root
    try:
      pwd.getpwnam(user)
    except KeyError:
      logging.error("user '%s' does not exist." % user)
      return 1

    try:
      subprocess.check_call(["/usr/sbin/groupadd", "-f",
                             CHROME_REMOTING_GROUP_NAME])
      subprocess.check_call(["/usr/bin/gpasswd", "--add", user,
                             CHROME_REMOTING_GROUP_NAME])
    except (ValueError, OSError, subprocess.CalledProcessError) as e:
      logging.error("Command failed: " + str(e))
      return 1

    return 0

  if options.watch_resolution:
    watch_for_resolution_changes(tuple(options.watch_resolution))
    return 0

  if not options.start:
    # If no modal command-line options specified, print an error and exit.
    print(EPILOG, file=sys.stderr)
    return 1

  # Determine whether a desktop is already active for the specified host
  # configuration.
  if get_daemon_proc(config_file, options.child_process) is not None:
    # Debian policy requires that services should "start" cleanly and return 0
    # if they are already running.
    if options.child_process:
      # If the script is running under user-session, try to relay the message.
      ParentProcessLogger.try_start_logging(USER_SESSION_MESSAGE_FD)
    logging.info("Service already running.")
    ParentProcessLogger.release_parent_if_connected(True)
    return 0

  if config_file != options.config:
    # --config was either not specified or isn't a canonical absolute path.
    # Replace it with the canonical path so get_daemon_proc can find us.
    sys.argv = ([sys.argv[0], "--config=" + config_file] +
                parse_config_arg(sys.argv[1:])[1])
    if options.child_process:
      os.execvp(sys.argv[0], sys.argv)

  if options.new_session:
    exec_self_via_login_shell()

  if not options.child_process:
    if os.path.isdir("/run/systemd/system"):
      return run_command_as_root(["systemctl", "start",
                                  "chrome-remote-desktop@" + getpass.getuser()])
    else:
      return start_via_user_session(options.foreground)

  # Start logging to user-session messaging pipe if it exists.
  ParentProcessLogger.try_start_logging(USER_SESSION_MESSAGE_FD)

  if display_manager_is_gdm():
    # See https://gitlab.gnome.org/GNOME/gdm/-/issues/580 for details on the
    # bug.
    gdm_message = (
        "WARNING: This system uses GDM. Some GDM versions have a bug that "
        "prevents local login while Chrome Remote Desktop is running. If you "
        "run into this issue, you can stop Chrome Remote Desktop by visiting "
        "https://remotedesktop.google.com/access on another machine and "
        "clicking the delete icon next to this machine. It may take up to five "
        "minutes for the Chrome Remote Desktop to exit on this machine and for "
        "local login to start working again.")
    logging.warning(gdm_message)
    # Also log to syslog so the user has a higher change of discovering the
    # message if they go searching.
    syslog.syslog(syslog.LOG_WARNING | syslog.LOG_DAEMON, gdm_message)

  default_sizes = DEFAULT_SIZES

  # Collate the list of sizes that XRANDR should support.
  if not options.size:
    if DEFAULT_SIZES_ENV_VAR in os.environ:
      default_sizes = os.environ[DEFAULT_SIZES_ENV_VAR]
    options.size = default_sizes.split(",")

  sizes = []
  for size in options.size:
    size_components = size.split("x")
    if len(size_components) != 2:
      parser.error("Incorrect size format '%s', should be WIDTHxHEIGHT" % size)

    try:
      width = int(size_components[0])
      height = int(size_components[1])

      # Enforce minimum desktop size, as a sanity-check.  The limit of 100 will
      # detect typos of 2 instead of 3 digits.
      if width < 100 or height < 100:
        raise ValueError
    except ValueError:
      parser.error("Width and height should be 100 pixels or greater")

    sizes.append((width, height))

  # Register an exit handler to clean up session process and the PID file.
  atexit.register(cleanup)

  # Load the initial host configuration.
  host_config = Config(config_file)
  try:
    host_config.load()
  except (IOError, ValueError) as e:
    print("Failed to load config: " + str(e), file=sys.stderr)
    return 1

  # Register handler to re-load the configuration in response to signals.
  for s in [signal.SIGHUP, signal.SIGINT, signal.SIGTERM]:
    signal.signal(s, SignalHandler(host_config))

  # Verify that the initial host configuration has the necessary fields.
  auth = Authentication()
  auth_config_valid = auth.copy_from(host_config)
  host = Host()
  host_config_valid = host.copy_from(host_config)
  if not host_config_valid or not auth_config_valid:
    logging.error("Failed to load host configuration.")
    return 1

  if host.host_id:
    logging.info("Using host_id: " + host.host_id)

  extra_start_host_args = []
  if HOST_EXTRA_PARAMS_ENV_VAR in os.environ:
      extra_start_host_args = \
          re.split(r"\s+", os.environ[HOST_EXTRA_PARAMS_ENV_VAR].strip())
  is_wayland = any([opt == '--enable-wayland' for opt in extra_start_host_args])
  if is_wayland:
    desktop = WaylandDesktop(sizes, host_config)
  else:
    desktop = XDesktop(sizes, host_config)

  if is_crash_reporting_enabled(host_config):
    desktop.enable_crash_reporting()

  # Whether we are tearing down because the display server and/or session
  # exited. This keeps us from counting processes exiting because we've
  # terminated them as errors.
  tear_down = False

  while True:
    # If the session process or display server stops running (e.g. because the
    # user logged out), terminate all processes. The session will be restarted
    # once everything has exited.
    if tear_down:
      desktop.cleanup()

      failure_count = desktop.aggregate_failure_count()
      tear_down = False

      if (failure_count == 0):
        # Since the user's desktop is already gone at this point, there's no
        # state to lose and now is a good time to pick up any updates to this
        # script that might have been installed.
        logging.info("Relaunching self")
        relaunch_self()
      else:
        # If there is a non-zero |failures| count, restarting the whole script
        # would lose this information, so just launch the session as normal,
        # below.
        pass

    relaunch_times = []

    # Set the backoff interval and exit if a process failed too many times.
    backoff_time = SHORT_BACKOFF_TIME
    for inhibitor, offline_reason in desktop.inhibitors.items():
      if inhibitor.disabled:
        continue
      if inhibitor.failures >= MAX_LAUNCH_FAILURES:
        if offline_reason is None:
          logging.error("Too many launch failures of '%s', not retrying."
                        % inhibitor.label)
        else:
          logging.error("Too many launch failures of '%s', exiting."
                        % inhibitor.label)
          desktop.report_offline_reason(offline_reason)
          sys.exit(1)
      elif inhibitor.failures >= SHORT_BACKOFF_THRESHOLD:
        backoff_time = LONG_BACKOFF_TIME

      if inhibitor.is_inhibited():
        relaunch_times.append(inhibitor.earliest_relaunch_time)

    if relaunch_times:
      # We want to wait until everything is ready to start so we don't end up
      # launching things in the wrong order due to differing relaunch times.
      logging.info("Waiting before relaunching")
    else:
      if (desktop.pipewire_proc is None and desktop.pipewire_pulse_proc is None
          and desktop.pipewire_session_manager_proc is None
          and not desktop.pipewire_inhibitor.disabled
          and desktop.pipewire_inhibitor.failures < MAX_LAUNCH_FAILURES):
        desktop.setup_audio(host.host_id, backoff_time)
      if (desktop.server_proc is None and desktop.pre_session_proc is None and
          desktop.session_proc is None):
        desktop.launch_session(options.args, backoff_time)
      if desktop.host_proc is None:
        desktop.launch_host(extra_start_host_args, backoff_time)
      if desktop.crash_uploader_proc is None:
        desktop.launch_crash_uploader(backoff_time)

    deadline = max(relaunch_times) if relaunch_times else 0
    pid, status = waitpid_handle_exceptions(-1, deadline)
    if pid == 0:
      continue

    logging.info("wait() returned (%s,%s)" % (pid, status))

    # When a process has terminated, and we've reaped its exit-code, any Popen
    # instance for that process is no longer valid. Reset any affected instance
    # to None.
    tear_down = desktop.on_process_exit(pid, status)

if __name__ == "__main__":
  logging.basicConfig(level=logging.DEBUG,
                      format="%(asctime)s:%(levelname)s:%(message)s")
  sys.exit(main())